From 785eba5768ec91f39aefc459055c4961c570e704 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Thu, 19 Feb 2026 17:15:33 +0900 Subject: [PATCH 01/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gpt/geumpumtabackend/global/exception/ExceptionType.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java index 1e55320..4e82872 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java @@ -69,6 +69,9 @@ public enum ExceptionType { FCM_INVALID_TOKEN(BAD_REQUEST, "F002", "유효하지 않은 FCM 토큰입니다."), FCM_TOKEN_NOT_FOUND(NOT_FOUND, "F003", "등록된 FCM 토큰이 없습니다."), + // Badge + BADGE_NOT_FOUND(NOT_FOUND, "BOO1", "배지가 존재하지 않습니다"), + BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다") ; private final HttpStatus status; From 551e77cf20fee835bdcb607c6eaaee3e85e91734 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:27:41 +0900 Subject: [PATCH 02/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gpt/geumpumtabackend/global/exception/ExceptionType.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java index 4e82872..2009854 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java @@ -71,7 +71,9 @@ public enum ExceptionType { // Badge BADGE_NOT_FOUND(NOT_FOUND, "BOO1", "배지가 존재하지 않습니다"), - BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다") + BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다"), + BADGE_CODE_ALREADY_EXISTS(CONFLICT, "B003", "이미 존재하는 배지 코드입니다"), + BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다") ; private final HttpStatus status; From 9365d77a8a053d3cbee375bb9242e261e5138fec Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:29:16 +0900 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/controller/BadgeController.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java new file mode 100644 index 0000000..57ddef5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java @@ -0,0 +1,90 @@ +package com.gpt.geumpumtabackend.badge.controller; + +import com.gpt.geumpumtabackend.badge.api.BadgeApi; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/badge") +public class BadgeController implements BadgeApi { + + private final BadgeService badgeService; + + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.createBadge(request) + )); + } + + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity>> getAllBadges() { + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getAllBadges() + )); + } + + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ) { + badgeService.deleteBadge(badgeId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + } + + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getMyBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getMyBadges(userId) + )); + } + + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.setRepresentativeBadge(request, userId) + )); + } + + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getUnnotifiedBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getUnnotifiedBadges(userId) + )); + } +} From 1dbad2cff86ce3615ebcc7ab42b14b6e2ff865f6 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:29:37 +0900 Subject: [PATCH 04/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geumpumtabackend/badge/domain/Badge.java | 54 +++++++++++++++++++ .../badge/domain/BadgeType.java | 8 +++ .../badge/domain/UserBadge.java | 41 ++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java new file mode 100644 index 0000000..6880a08 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java @@ -0,0 +1,54 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Badge extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String code; + + private String name; + + private String description; + + private String iconUrl; + + @Enumerated(EnumType.STRING) + private BadgeType badgeType; + + private Long thresholdValue; + + @Column(name = "badge_rank") + private Long rank; + + @Builder + private Badge( + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank + ) { + this.code = code; + this.name = name; + this.description = description; + this.iconUrl = iconUrl; + this.badgeType = badgeType; + this.thresholdValue = thresholdValue; + this.rank = rank; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java new file mode 100644 index 0000000..f352131 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java @@ -0,0 +1,8 @@ +package com.gpt.geumpumtabackend.badge.domain; + +public enum BadgeType { + WELCOME, + STREAK_DAYS, + TOTAL_HOURS, + SEASON_PERSONAL_RANK +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java new file mode 100644 index 0000000..f7fb4cf --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "user_badge", + uniqueConstraints = @UniqueConstraint(name="uk_user_badge", columnNames = {"user_id", "badge_id"}) +) +public class UserBadge { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @Column(name = "user_id") + Long userId; + + @Column(name = "badge_id") + Long badgeId; + + private LocalDateTime awardedAt; + + private LocalDateTime notifiedAt; + + public UserBadge(Long userId, Long badgeId, LocalDateTime awardedAt, LocalDateTime notifiedAt) { + this.userId = userId; + this.badgeId = badgeId; + this.awardedAt = awardedAt; + this.notifiedAt = notifiedAt; + } + + public void markNotified(LocalDateTime notifiedAt) { + this.notifiedAt = notifiedAt; + } +} From a8c0fffcce1ebdb6c1d3159ec653fe5c9e73d3b8 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:29:48 +0900 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20dto=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 --- .../badge/dto/request/BadgeCreateRequest.java | 16 +++++++++++ .../request/RepresentativeBadgeRequest.java | 6 ++++ .../dto/response/BadgeCreateResponse.java | 28 +++++++++++++++++++ .../badge/dto/response/BadgeResponse.java | 28 +++++++++++++++++++ .../badge/dto/response/MyBadgeResponse.java | 12 ++++++++ .../dto/response/MyBadgeStatusResponse.java | 25 +++++++++++++++++ .../badge/dto/response/NewBadgeResponse.java | 19 +++++++++++++ .../response/RepresentativeBadgeResponse.java | 19 +++++++++++++ 8 files changed, 153 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java new file mode 100644 index 0000000..fd22ee1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BadgeCreateRequest( + @NotBlank String code, + @NotBlank String name, + @NotBlank String description, + @NotBlank String iconUrl, + @NotNull BadgeType badgeType, + Long thresholdValue, + Long rank +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java new file mode 100644 index 0000000..9656ac1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java @@ -0,0 +1,6 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +public record RepresentativeBadgeRequest( + String badgeCode +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java new file mode 100644 index 0000000..eb5a423 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeCreateResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeCreateResponse from(Badge badge) { + return new BadgeCreateResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java new file mode 100644 index 0000000..809a0b1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeResponse from(Badge badge) { + return new BadgeResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java new file mode 100644 index 0000000..edfe683 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java @@ -0,0 +1,12 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import java.time.LocalDateTime; + +public record MyBadgeResponse( + String code, + String name, + String description, + String iconUrl, + LocalDateTime awardedAt +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java new file mode 100644 index 0000000..4c96039 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +import java.time.LocalDateTime; + +public record MyBadgeStatusResponse( + String code, + String name, + String description, + String iconUrl, + boolean owned, + LocalDateTime awardedAt +) { + public static MyBadgeStatusResponse from(Badge badge, LocalDateTime awardedAt) { + return new MyBadgeStatusResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + awardedAt != null, + awardedAt + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java new file mode 100644 index 0000000..7d8efe7 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record NewBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static NewBadgeResponse from(Badge badge) { + return new NewBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java new file mode 100644 index 0000000..b83461d --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record RepresentativeBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static RepresentativeBadgeResponse from(Badge badge){ + return new RepresentativeBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} From d075a0f5a0b233895b1a764c085ee66a71d8cb1d Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:30:11 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/dto/response/StudyEndResponse.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java diff --git a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java new file mode 100644 index 0000000..6f1abff --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java @@ -0,0 +1,13 @@ +package com.gpt.geumpumtabackend.study.dto.response; + +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; + +import java.util.List; + +public record StudyEndResponse( + List newBadges +) { + public static StudyEndResponse of(List newBadges) { + return new StudyEndResponse(newBadges); + } +} From 96b8f34535857c6b3857e93839eccb02fbe732f7 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:34:04 +0900 Subject: [PATCH 07/32] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EC=9B=B0=EC=BB=B4?= =?UTF-8?q?=20=EB=B0=B0=EC=A7=80=20=EC=A0=95=EB=B3=B4=EB=8F=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/CompleteRegistrationResponse.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java diff --git a/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java new file mode 100644 index 0000000..ab4473e --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java @@ -0,0 +1,15 @@ +package com.gpt.geumpumtabackend.user.dto.response; + +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; + +import java.util.List; + +public record CompleteRegistrationResponse( + TokenResponse token, + NewBadgeResponse newBadge +) { + public static CompleteRegistrationResponse of(TokenResponse token, NewBadgeResponse newBadge) { + return new CompleteRegistrationResponse(token, newBadge); + } +} From 284043fd2300193c3d617a2e5e411c0fd4fb6419 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:34:23 +0900 Subject: [PATCH 08/32] =?UTF-8?q?docs:=20=EB=B0=B0=EC=A7=80=20=EC=8A=A4?= =?UTF-8?q?=EC=9B=A8=EA=B1=B0=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geumpumtabackend/badge/api/BadgeApi.java | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java new file mode 100644 index 0000000..b6dec2b --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java @@ -0,0 +1,183 @@ +package com.gpt.geumpumtabackend.badge.api; + +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +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.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "배지 API", description = """ + 배지 생성/조회/삭제 및 사용자 배지 조회 기능을 제공합니다. + """) +public interface BadgeApi { + + @Operation( + summary = "배지 생성", + description = """ + ADMIN 권한으로 새로운 배지를 생성합니다. + - code: 배지 고유 코드 (중복 불가) + - badgeType: 배지 종류 + - thresholdValue: 누적 시간/연속 일수 계열 배지 기준값 + - rank: 시즌 랭킹 배지 등수 값(예: 1,2,3) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeCreateResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeCreateResponse.class, + description = "배지 생성 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_CODE_ALREADY_EXISTS) + } + ) + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ); + + @Operation( + summary = "전체 배지 조회", + description = "ADMIN 권한으로 전체 배지 목록을 조회합니다." + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeResponse.class, + description = "전체 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED) + } + ) + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity>> getAllBadges(); + + @Operation( + summary = "배지 삭제", + description = """ + ADMIN 권한으로 배지를 삭제합니다. + 이미 사용자에게 지급된 이력이 있으면 삭제할 수 없고 B004(BADGE_IN_USE)를 반환합니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = ResponseBody.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse(description = "배지 삭제 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_IN_USE) + } + ) + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ); + + @Operation( + summary = "내 배지 조회", + description = """ + 항상 전체 배지 목록을 반환합니다. + 각 원소는 아래 정보를 포함합니다. + - owned: 사용자의 배지 보유 여부 + - awardedAt: 배지 획득 시각 (owned=false이면 null) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeStatusResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeStatusResponse.class, + description = "내 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getMyBadges( + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "대표 배지 설정", + description = """ + 보유한 배지 중 하나를 대표 배지로 설정합니다. + 요청은 badgeCode 기준으로 처리됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = RepresentativeBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = RepresentativeBadgeResponse.class, + description = "대표 배지 설정 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_OWNED) + } + ) + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "미확인 배지 조회", + description = """ + 사용자의 미확인 배지 목록을 조회합니다. + 조회된 배지는 같은 요청 트랜잭션에서 확인 처리(notifiedAt 설정)됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeResponse.class, + description = "미확인 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getUnnotifiedBadges( + @Parameter(hidden = true) Long userId + ); +} From 20f8d9fd65ad78ee0fda1c7101f7f885cdd6f6a6 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:36:11 +0900 Subject: [PATCH 09/32] =?UTF-8?q?test:=20=ED=86=B5=EA=B3=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StatisticsControllerIntegrationTest.java | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..830b9f2 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,165 @@ +package com.gpt.geumpumtabackend.integration.statistics; + +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("Statistics Controller 통합 테스트") +@AutoConfigureMockMvc +class StatisticsControllerIntegrationTest extends BaseIntegrationTest { + + private static final LocalDate BASE_DATE = LocalDate.of(2024, 1, 10); + private static final long ONE_HOUR_MILLIS = 60 * 60 * 1000L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtHandler jwtHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudySessionRepository studySessionRepository; + + private User testUser; + private User otherUser; + private String accessToken; + + @BeforeEach + void setUp() { + testUser = createUser("테스트유저", "stats@kumoh.ac.kr", Department.SOFTWARE); + otherUser = createUser("다른유저", "other-stats@kumoh.ac.kr", Department.COMPUTER_ENGINEERING); + + JwtUserClaim claim = new JwtUserClaim(testUser.getId(), UserRole.USER, false); + Token token = jwtHandler.createTokens(claim); + accessToken = token.getAccessToken(); + } + + private User createUser(String name, String email, Department department) { + User user = User.builder() + .name(name) + .email(email) + .department(department) + .role(UserRole.USER) + .picture("profile.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("provider-" + email) + .build(); + return userRepository.save(user); + } + + private StudySession createStudySession(User user, LocalDateTime startTime, LocalDateTime endTime) { + StudySession session = new StudySession(); + session.startStudySession(startTime, user); + session.endStudySession(endTime); + return studySessionRepository.save(session); + } + + @Test + @DisplayName("일간 통계를 2시간 슬롯과 최대 집중 시간으로 조회한다") + void 일간_통계를_조회한다() throws Exception { + LocalDateTime startTime = BASE_DATE.atTime(1, 0); + LocalDateTime endTime = BASE_DATE.atTime(3, 0); + createStudySession(testUser, startTime, endTime); + + createStudySession(otherUser, BASE_DATE.atTime(2, 0), BASE_DATE.atTime(4, 0)); + + mockMvc.perform(get("/api/v1/statistics/day") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.statisticsList", hasSize(12))) + .andExpect(jsonPath("$.data.statisticsList[0].slotStart").value("00:00")) + .andExpect(jsonPath("$.data.statisticsList[0].slotEnd").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[0].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.statisticsList[1].slotStart").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[1].slotEnd").value("04:00")) + .andExpect(jsonPath("$.data.statisticsList[1].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.totalStudyMillis").value((int) (ONE_HOUR_MILLIS * 2))) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.maxFocusMillis").value((int) (ONE_HOUR_MILLIS * 2))); + } + + @Test + @DisplayName("주간 통계의 총합/연속일/평균을 계산한다") + void 주간_통계를_조회한다() throws Exception { + LocalDate weekDate = BASE_DATE; + LocalDate monday = weekDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + createStudySession(testUser, monday.atTime(10, 0), monday.atTime(11, 0)); + createStudySession(testUser, monday.plusDays(1).atTime(12, 0), monday.plusDays(1).atTime(13, 0)); + + mockMvc.perform(get("/api/v1/statistics/week") + .param("date", weekDate.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.weeklyStatistics.totalWeekMillis").value(ONE_HOUR_MILLIS * 2)) + .andExpect(jsonPath("$.data.weeklyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.weeklyStatistics.averageDailyMillis").value(1_028_571)); + } + + @Test + @DisplayName("월간 통계의 총합/연속일/평균/공부일수를 계산한다") + void 월간_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(3).atTime(14, 0), BASE_DATE.withDayOfMonth(3).atTime(16, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(5).atTime(20, 0), BASE_DATE.withDayOfMonth(5).atTime(21, 0)); + + mockMvc.perform(get("/api/v1/statistics/month") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.monthlyStatistics.totalMonthMillis").value(14_400_000L)) + .andExpect(jsonPath("$.data.monthlyStatistics.averageDailyMillis").value(464_516)) + .andExpect(jsonPath("$.data.monthlyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.monthlyStatistics.studiedDays").value(3)); + } + + @Test + @DisplayName("잔디 통계는 4개월 범위의 날짜별 레벨을 반환한다") + void 잔디_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + + mockMvc.perform(get("/api/v1/statistics/grass") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.grassStatistics", hasSize(123))) + .andExpect(jsonPath("$.data.grassStatistics[?(@.date=='2024-01-02')].level", + hasItem(greaterThanOrEqualTo(1)))); + } +} From 70948f1fb239a2724c39593bdfa3db3c3192b9c8 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:36:23 +0900 Subject: [PATCH 10/32] =?UTF-8?q?test:=20=EB=B2=A0=EC=A7=80=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=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 --- .../unit/badge/service/BadgeServiceTest.java | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java new file mode 100644 index 0000000..e886f1d --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java @@ -0,0 +1,549 @@ +package com.gpt.geumpumtabackend.unit.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BadgeService 단위 테스트") +class BadgeServiceTest { + + @Mock + private BadgeRepository badgeRepository; + + @Mock + private UserBadgeRepository userBadgeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + @InjectMocks + private BadgeService badgeService; + + @Test + @DisplayName("관리자가 배지를 생성하면 저장 후 응답을 반환한다") + void 배지를_생성하면_응답을_반환한다() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + Badge savedBadge = createBadge("WELCOME_001", BadgeType.WELCOME, null); + ReflectionTestUtils.setField(savedBadge, "id", 100L); + + when(badgeRepository.existsByCode(request.code())).thenReturn(false); + when(badgeRepository.save(any(Badge.class))).thenReturn(savedBadge); + + // When + BadgeCreateResponse response = badgeService.createBadge(request); + + // Then + assertThat(response.id()).isEqualTo(100L); + assertThat(response.code()).isEqualTo("WELCOME_001"); + verify(badgeRepository).save(any(Badge.class)); + } + + @Test + @DisplayName("중복 코드로 배지 생성 시 BADGE_CODE_ALREADY_EXISTS 예외가 발생한다") + void 중복코드_배지생성시_예외발생() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + when(badgeRepository.existsByCode(request.code())).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.createBadge(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_CODE_ALREADY_EXISTS); + verify(badgeRepository, never()).save(any(Badge.class)); + } + + @Test + @DisplayName("관리자가 전체 배지를 조회하면 배지 목록을 반환한다") + void 전체_배지를_조회하면_목록을_반환한다() { + // Given + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + when(badgeRepository.findAll()).thenReturn(List.of(badge1, badge2)); + + // When + List responses = badgeService.getAllBadges(); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(BadgeResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + } + + @Test + @DisplayName("내 배지 조회 시 전체 배지를 반환하고 보유 여부를 표시한다") + void 내배지_조회시_전체배지와_보유여부를_반환한다() { + // Given + Long userId = 1L; + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge1, "id", 1L); + ReflectionTestUtils.setField(badge2, "id", 2L); + LocalDateTime awardedAt = LocalDateTime.of(2026, 2, 1, 10, 0); + UserBadge ownedBadge = new UserBadge(userId, 1L, awardedAt, awardedAt); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findAll()).thenReturn(List.of(badge2, badge1)); + when(userBadgeRepository.findByUserId(userId)).thenReturn(List.of(ownedBadge)); + + // When + List responses = badgeService.getMyBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(MyBadgeStatusResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + assertThat(responses.get(0).owned()).isTrue(); + assertThat(responses.get(0).awardedAt()).isEqualTo(awardedAt); + assertThat(responses.get(1).owned()).isFalse(); + assertThat(responses.get(1).awardedAt()).isNull(); + } + + @Test + @DisplayName("지급된 이력이 없는 배지는 삭제한다") + void 지급이력_없는_배지는_삭제한다() { + // Given + Long badgeId = 30L; + Badge badge = createBadge("TOTAL_HOURS_100", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(false); + + // When + badgeService.deleteBadge(badgeId); + + // Then + verify(badgeRepository, times(1)).delete(badge); + } + + @Test + @DisplayName("이미 지급된 배지는 삭제 시 BADGE_IN_USE 예외가 발생한다") + void 이미_지급된_배지는_삭제할수없다() { + // Given + Long badgeId = 31L; + Badge badge = createBadge("TOTAL_HOURS_200", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.deleteBadge(badgeId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_IN_USE); + verify(badgeRepository, never()).delete(any(Badge.class)); + } + + @Test + @DisplayName("회원가입 후 웰컴 배지가 정상 반환된다") + void 회원가입후_웰컴배지가_반환된다() { + // Given + Long userId = 1L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findByBadgeType(BadgeType.WELCOME)).thenReturn(badge); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + NewBadgeResponse granted = badgeService.grantWelcomeBadge(userId); + + // Then + assertThat(granted.code()).isEqualTo(badge.getCode()); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(1)).save(captor.capture()); + UserBadge saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getBadgeId()).isEqualTo(badge.getId()); + assertThat(saved.getNotifiedAt()).isNotNull(); + } + + @Test + @DisplayName("미확인 배지 조회 시 배지를 반환하고 확인 처리한다") + void 미확인_배지_조회시_배지를_반환하고_확인처리한다() { + // Given + Long userId = 2L; + Badge badge1 = createBadge("SEASON_2025_RANK_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge badge2 = createBadge("SEASON_2025_RANK_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + LocalDateTime awardedAt1 = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime awardedAt2 = LocalDateTime.of(2026, 1, 2, 11, 0); + UserBadge userBadge1 = new UserBadge(userId, badge1.getId(), awardedAt1, null); + UserBadge userBadge2 = new UserBadge(userId, badge2.getId(), awardedAt2, null); + + when(userRepository.existsById(userId)).thenReturn(true); + when(userBadgeRepository.findUnnotifiedBadgeResponses(userId)).thenReturn(List.of( + new MyBadgeResponse(badge1.getCode(), badge1.getName(), badge1.getDescription(), badge1.getIconUrl(), awardedAt1), + new MyBadgeResponse(badge2.getCode(), badge2.getName(), badge2.getDescription(), badge2.getIconUrl(), awardedAt2) + )); + when(userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId)).thenReturn(List.of(userBadge1, userBadge2)); + when(userBadgeRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.getUnnotifiedBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(MyBadgeResponse::code) + .containsExactlyInAnyOrder(badge1.getCode(), badge2.getCode()); + assertThat(responses) + .extracting(MyBadgeResponse::awardedAt) + .containsExactlyInAnyOrder(awardedAt1, awardedAt2); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userBadgeRepository, times(1)).saveAll(listCaptor.capture()); + List saved = listCaptor.getValue(); + assertThat(saved).hasSize(2); + assertThat(saved).allSatisfy(ub -> assertThat(ub.getNotifiedAt()).isNotNull()); + LocalDateTime notifiedAt = saved.get(0).getNotifiedAt(); + assertThat(saved) + .extracting(UserBadge::getNotifiedAt) + .allMatch(time -> time.equals(notifiedAt)); + } + + @Test + @DisplayName("대표 배지 설정 시 배지 코드로 조회해 대표 배지를 설정한다") + void 대표_배지_설정시_코드로_배지를_찾아_설정한다() { + // Given + Long userId = 10L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + User user = mock(User.class); + + when(badgeRepository.findByCode("WELCOME_001")).thenReturn(badge); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + badgeService.setRepresentativeBadge(new com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest("WELCOME_001"), userId); + + // Then + verify(user, times(1)).setRepresentativeBadge(badge.getId()); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 미보유하면 발급한다") + void 누적_공부시간_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 3L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 50L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 이미 보유하면 발급하지 않는다") + void 누적_공부시간_기준_배지를_이미_보유한_경우_발급하지_않는다() { + // Given + Long userId = 4L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 60L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 미달이면 배지를 발급하지 않는다") + void 누적_공부시간_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 5L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 49L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 배지를 미보유하면 발급한다") + void 연속_공부일수_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 6L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(7); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 미달이면 배지를 발급하지 않는다") + void 연속_공부일수_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 7L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(6); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 배지를 지급한다") + void 시즌_전체랭킹_배지를_지급한다() { + // Given + Long seasonId = 100L; + Long userId = 11L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(1); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 랭킹 배지를 이미 보유하면 지급하지 않는다") + void 시즌_랭킹_배지를_이미_보유하면_지급하지_않는다() { + // Given + Long seasonId = 101L; + Long userId = 12L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(true); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isZero(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 1,2,3등 유저에게 각 등수 배지를 지급한다") + void 시즌_전체랭킹_1_2_3등에_각각_해당_배지를_지급한다() { + // Given + Long seasonId = 102L; + Long user1 = 21L; + Long user2 = 22L; + Long user3 = 23L; + + Badge rank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge rank2Badge = createBadge("SEASON_OVERALL_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + Badge rank3Badge = createBadge("SEASON_OVERALL_3", BadgeType.SEASON_PERSONAL_RANK, 3L); + + SeasonRankingSnapshot snapshot1 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user1) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(3_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot2 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user2) + .rankType(RankType.OVERALL) + .finalRank(2) + .finalTotalMillis(2_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot3 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user3) + .rankType(RankType.OVERALL) + .finalRank(3) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(rank1Badge, rank2Badge, rank3Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1, 2, 3))) + .thenReturn(List.of(snapshot3, snapshot1, snapshot2)); + when(userBadgeRepository.existsByUserIdAndBadgeId(anyLong(), anyLong())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(3)).save(captor.capture()); + List saved = captor.getAllValues(); + Map badgeByUser = saved.stream() + .collect(Collectors.toMap(UserBadge::getUserId, UserBadge::getBadgeId)); + + assertThat(badgeByUser).containsEntry(user1, rank1Badge.getId()); + assertThat(badgeByUser).containsEntry(user2, rank2Badge.getId()); + assertThat(badgeByUser).containsEntry(user3, rank3Badge.getId()); + } + + private Badge createBadge(String code, BadgeType type, Long rank) { + Badge badge = Badge.builder() + .code(code) + .name("badge-name") + .description("badge-desc") + .iconUrl("http://example.com/badge.png") + .badgeType(type) + .thresholdValue(10L) + .rank(rank) + .build(); + ReflectionTestUtils.setField(badge, "id", rank); + return badge; + } +} From 2e3cd4df6299191f4ab1febebc41cf5385593f1e Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:36:33 +0900 Subject: [PATCH 11/32] =?UTF-8?q?test:=20=EC=9C=A0=EC=A0=80=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/user/service/UserServiceTest.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java index ee697a8..e97da4b 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java @@ -1,5 +1,7 @@ package com.gpt.geumpumtabackend.unit.user.service; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.fcm.service.FcmService; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; @@ -15,6 +17,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.repository.UserRepository; import com.gpt.geumpumtabackend.user.service.UserService; @@ -51,6 +54,9 @@ class UserServiceTest { @Mock private FcmService fcmService; + @Mock + private BadgeService badgeService; + @InjectMocks private UserService userService; @@ -129,19 +135,27 @@ class CompleteRegistration { .accessToken("access-token") .refreshToken("refresh-token") .build(); + NewBadgeResponse newBadge = new NewBadgeResponse( + "WELCOME_001", + "웰컴 배지", + "회원가입 기념 배지", + "https://example.com/welcome.png" + ); given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(userRepository.existsBySchoolEmail(request.email())).willReturn(false); given(userRepository.existsByStudentId(request.studentId())).willReturn(false); given(userRepository.existsByNickname(any())).willReturn(false); given(jwtHandler.createTokens(any(JwtUserClaim.class))).willReturn(token); + given(badgeService.grantWelcomeBadge(userId)).willReturn(newBadge); // When - TokenResponse response = userService.completeRegistration(request, userId); + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); // Then - assertThat(response.accessToken()).isEqualTo("access-token"); - assertThat(response.refreshToken()).isEqualTo("refresh-token"); + assertThat(response.token().accessToken()).isEqualTo("access-token"); + assertThat(response.token().refreshToken()).isEqualTo("refresh-token"); + assertThat(response.newBadge().code()).isEqualTo("WELCOME_001"); assertThat(user.getSchoolEmail()).isEqualTo(request.email()); assertThat(user.getStudentId()).isEqualTo(request.studentId()); assertThat(user.getDepartment()).isEqualTo(Department.SOFTWARE); @@ -152,6 +166,7 @@ class CompleteRegistration { claim.role().equals(UserRole.USER) && !claim.withdrawn() )); + verify(badgeService).grantWelcomeBadge(userId); } @Test From a9bc32d5ffe47c537da5ba6f6f2732ab409fabe1 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:37:49 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=ED=95=98=EB=8A=94=20dto=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 5 +++-- .../gpt/geumpumtabackend/user/service/UserService.java | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java index 9f633b7..5418be9 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java @@ -9,6 +9,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.service.UserService; @@ -35,11 +36,11 @@ public class UserController implements UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, Long userId ){ - TokenResponse response = userService.completeRegistration(request, userId); + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java index 1725023..0137a7d 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java @@ -6,6 +6,8 @@ import com.gpt.geumpumtabackend.global.exception.ExceptionType; import com.gpt.geumpumtabackend.global.jwt.JwtHandler; import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.token.domain.Token; import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; @@ -14,6 +16,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -32,6 +35,7 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final JwtHandler jwtHandler; private final FcmService fcmService; + private final BadgeService badgeService; private static final Random RANDOM = new Random(); private static final List ADJECTIVES = List.of( @@ -58,7 +62,7 @@ public void generateRandomNickname(User user){ } @Transactional - public TokenResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { + public CompleteRegistrationResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { User user = userRepository.findById(userId) .orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND)); validateDuplication(request); @@ -68,7 +72,9 @@ public TokenResponse completeRegistration(CompleteRegistrationRequest request, L // 토큰 재발급 JwtUserClaim jwtUserClaim = JwtUserClaim.create(user); Token token = jwtHandler.createTokens(jwtUserClaim); - return TokenResponse.to(token); + TokenResponse tokenResponse = TokenResponse.to(token); + NewBadgeResponse newBadge = badgeService.grantWelcomeBadge(userId); + return CompleteRegistrationResponse.of(tokenResponse, newBadge); } private void validateDuplication(CompleteRegistrationRequest request) { From 0b11773c5dc0dab5fed9bf0c6d48b5b5284a3472 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:39:03 +0900 Subject: [PATCH 13/32] =?UTF-8?q?feat:=20=EB=8C=80=ED=91=9C=EB=B0=B0?= =?UTF-8?q?=EC=A7=80=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=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 --- .../java/com/gpt/geumpumtabackend/user/domain/User.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java index 1acfb78..0c4d403 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java @@ -63,6 +63,8 @@ public class User extends BaseEntity { @Column(length = 255) private String fcmToken; + private Long representativeBadgeId; + @Builder public User(String email, UserRole role, String name, String picture, OAuth2Provider provider, String providerId, Department department) { this.email = email; @@ -106,4 +108,8 @@ public void updateFcmToken(String fcmToken) { public void clearFcmToken() { this.fcmToken = null; } + + public void setRepresentativeBadge(Long badgeId) { + this.representativeBadgeId = badgeId; + } } From 836345c8ce9523dd6581159061b73055e02a40f9 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:39:19 +0900 Subject: [PATCH 14/32] =?UTF-8?q?docs:=20=EC=9C=A0=EC=A0=80=20=EC=8A=A4?= =?UTF-8?q?=EC=9B=A8=EA=B1=B0=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gpt/geumpumtabackend/user/api/UserApi.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java index 52cd317..23a814c 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java @@ -14,6 +14,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,10 +36,10 @@ public interface UserApi { description = "GUEST 권한을 가진 사용자는 회원가입 완료를 위해 학번과 학부를 입력합니다." + "사용자의 권한이 USER로 변경되고 accessToken과 refreshToken을 재발급받습니다." ) - @ApiResponse(content = @Content(schema = @Schema(implementation = TokenResponse.class))) + @ApiResponse(content = @Content(schema = @Schema(implementation = CompleteRegistrationResponse.class))) @SwaggerApiResponses( success = @SwaggerApiSuccessResponse( - response = TokenResponse.class, + response = CompleteRegistrationResponse.class, description = "회원가입 완료 및 accessToken과 refreshToken 재발급 완료"), errors = { @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), @@ -48,7 +49,7 @@ public interface UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, @Parameter(hidden = true) Long userId ); From a7e8e8e8eac9d49ba477608f46a349acebe343d4 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:39:32 +0900 Subject: [PATCH 15/32] =?UTF-8?q?docs:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gpt/geumpumtabackend/study/api/StudySessionApi.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java index f1c17ae..ea2f84c 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java @@ -8,6 +8,7 @@ import com.gpt.geumpumtabackend.global.response.ResponseBody; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; +import com.gpt.geumpumtabackend.study.dto.response.StudyEndResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import io.swagger.v3.oas.annotations.Operation; @@ -126,6 +127,7 @@ ResponseEntity> startStudySession( ) @SwaggerApiResponses( success = @SwaggerApiSuccessResponse( + response = StudyEndResponse.class, description = "학습 세션 종료 성공"), errors = { @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), @@ -136,8 +138,8 @@ ResponseEntity> startStudySession( @PostMapping("/end") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('USER')") - ResponseEntity> endStudySession( + ResponseEntity> endStudySession( @Valid @RequestBody StudyEndRequest request, @Parameter(hidden = true) Long userId ); -} \ No newline at end of file +} From 10b4f8396924e662cd4281d5a345e0666a7d4b59 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:41:01 +0900 Subject: [PATCH 16/32] =?UTF-8?q?feat:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=EC=97=85=EC=A0=81?= =?UTF-8?q?=EC=9D=84=20=EB=8B=AC=EC=84=B1=ED=95=9C=20=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/controller/StudySessionController.java | 9 ++++++--- .../study/service/StudySessionService.java | 8 +++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java index 7176498..82672c7 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java @@ -6,6 +6,7 @@ import com.gpt.geumpumtabackend.study.api.StudySessionApi; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; +import com.gpt.geumpumtabackend.study.dto.response.StudyEndResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import com.gpt.geumpumtabackend.study.service.StudySessionService; @@ -52,8 +53,10 @@ public ResponseEntity> startStudySession(@Valid @PostMapping("/end") @PreAuthorize("isAuthenticated() and hasRole('USER')") @AssignUserId - public ResponseEntity> endStudySession(@Valid @RequestBody StudyEndRequest request, Long userId){ - studySessionService.endStudySession(request, userId); - return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + public ResponseEntity> endStudySession(@Valid @RequestBody StudyEndRequest request, Long userId){ + StudyEndResponse response = StudyEndResponse.of( + studySessionService.endStudySession(request, userId) + ); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java index 486ae42..d7f79d9 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java @@ -1,6 +1,8 @@ package com.gpt.geumpumtabackend.study.service; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; import com.gpt.geumpumtabackend.study.domain.StudyStatus; @@ -19,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.ArrayList; import java.util.List; @@ -31,6 +34,8 @@ public class StudySessionService { private final UserRepository userRepository; private final CampusWiFiValidationService wifiValidationService; private final StudyProperties studyProperties; + private final BadgeService badgeService; + /* 메인 홈 */ @@ -56,11 +61,12 @@ public StudyStartResponse startStudySession(StudyStartRequest request, Long user 공부 종료 */ @Transactional - public void endStudySession(StudyEndRequest endRequest, Long userId) { + public List endStudySession(StudyEndRequest endRequest, Long userId) { StudySession studysession = studySessionRepository.findByIdAndUser_Id(endRequest.studySessionId(), userId) .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); LocalDateTime endTime = LocalDateTime.now(); studysession.endStudySession(endTime); + return badgeService.grantStudyAchievementBadges(userId); } private BusinessException mapWiFiValidationException(WiFiValidationResult result) { From f3b4e795f0c84451c4f2e4739ba0479a689a7919 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:41:23 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EA=B3=B5?= =?UTF-8?q?=EB=B6=80=20=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=84=9C=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 --- .../study/repository/StudySessionRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java index 94cf4d3..4992402 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java @@ -35,6 +35,13 @@ Long sumCompletedStudySessionByUserId( @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); + @Query(value = "SELECT COALESCE(SUM(s.total_millis), 0) " + + "FROM study_session s " + + "WHERE s.user_id = :userId " + + "AND s.end_time IS NOT NULL", nativeQuery = true) + Long sumTotalStudyMillisByUserId(@Param("userId") Long userId); + + @EntityGraph(attributePaths = {"user"}) List findAllByStatusAndStartTimeBefore(StudyStatus status, LocalDateTime now); From e77ea63654dda22a2e8e45c18fe81a219b00cd8c Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:42:02 +0900 Subject: [PATCH 18/32] =?UTF-8?q?feat:=20=EC=97=B0=EC=86=8D=20=EA=B3=B5?= =?UTF-8?q?=EB=B6=80=20=EC=9D=BC=EC=88=98=EB=A5=BC=20=EC=B8=A1=EC=A0=95?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StatisticsRepository.java | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java index fa11b4c..bde201e 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java @@ -265,6 +265,53 @@ MonthlyStatistics getMonthlyStatistics( @Param("userId") Long userId ); + @Query(value = """ + WITH RECURSIVE streak(day_date, day_millis) AS ( + SELECT DATE(:today) AS day_date, + CAST(COALESCE(( + SELECT SUM(GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, DATE(:today)), + LEAST(COALESCE(s.end_time, DATE(:today) + INTERVAL 1 DAY), DATE(:today) + INTERVAL 1 DAY) + ) / 1000 + )) + FROM study_session s + WHERE s.user_id = :userId + AND s.start_time < DATE(:today) + INTERVAL 1 DAY + AND s.end_time > DATE(:today) + ), 0) AS SIGNED) AS day_millis + UNION ALL + SELECT DATE_SUB(streak.day_date, INTERVAL 1 DAY) AS day_date, + CAST(COALESCE(( + SELECT SUM(GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, DATE_SUB(streak.day_date, INTERVAL 1 DAY)), + LEAST(COALESCE(s.end_time, DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY), + DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY) + ) / 1000 + )) + FROM study_session s + WHERE s.user_id = :userId + AND s.start_time < DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY + AND s.end_time > DATE_SUB(streak.day_date, INTERVAL 1 DAY) + ), 0) AS SIGNED) AS day_millis + FROM streak + WHERE streak.day_millis >= :thresholdMillis + ) + SELECT COUNT(*) + FROM streak + WHERE day_millis >= :thresholdMillis + """, nativeQuery = true) + Integer countCurrentConsecutiveStudyDays( + @Param("userId") Long userId, + @Param("today") LocalDate today, + @Param("thresholdMillis") Long thresholdMillis + ); + @Query(value = """ @@ -283,7 +330,7 @@ SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), DATE_ADD(d.day_start, INTERVAL 1 DAY), DATE_ADD(d.day_end, INTERVAL 1 DAY) FROM days d - JOIN bounds b ON d.day_date < b.end_at_exclusive + JOIN bounds b ON d.day_date < DATE_SUB(b.end_at_exclusive, INTERVAL 1 DAY) ), sessions_in_window AS ( SELECT s.user_id, s.start_time, s.end_time From 656cb8fa0d7277674463083e1c26c6ed97cf96b8 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:46:33 +0900 Subject: [PATCH 19/32] =?UTF-8?q?feat:=20=EC=8B=9C=EC=A6=8C=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=8A=A4=EB=83=85=EC=83=B7=201=C2=B72=C2=B73?= =?UTF-8?q?=EB=93=B1=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=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 --- .../rank/repository/SeasonRankingSnapshotRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java index d9a38b0..6cd8101 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Set; public interface SeasonRankingSnapshotRepository extends JpaRepository { @@ -25,6 +26,12 @@ List findBySeasonIdAndRankTypeAndDepartment( int countBySeasonId(Long seasonId); + List findBySeasonIdAndRankTypeAndFinalRankIn( + Long seasonId, + RankType rankType, + Set finalRanks + ); + @Query(value = """ SELECT s.department as department, From 4792c446bc03ada4c694ee6d0282f0161a4ada3f Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:48:48 +0900 Subject: [PATCH 20/32] =?UTF-8?q?feat:=20=EC=8B=9C=EC=A6=8C=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0=EC=A7=80?= =?UTF-8?q?=20=EC=A7=80=EA=B8=89=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=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 --- .../rank/scheduler/SeasonTransitionScheduler.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java index c77aade..5b9fdbc 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java @@ -1,5 +1,6 @@ package com.gpt.geumpumtabackend.rank.scheduler; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.rank.domain.Season; import com.gpt.geumpumtabackend.rank.service.SeasonService; import com.gpt.geumpumtabackend.rank.service.SeasonSnapshotService; @@ -14,10 +15,11 @@ @Component @RequiredArgsConstructor @Slf4j -public class SeasonTransitionScheduler { +public class SeasonTransitionScheduler { private final SeasonService seasonService; private final SeasonSnapshotService snapshotService; + private final BadgeService badgeService; private final CacheManager cacheManager; @@ -46,6 +48,9 @@ public void processSeasonTransition() { // 스냅샷 생성 int snapshotCount = snapshotService.createSeasonSnapshot(endedSeasonId); + int grantedBadgeCount = badgeService.grantSeasonRankingBadges(endedSeasonId); + log.info("[SEASON_TRANSITION] seasonId={}, snapshots={}, rankingBadges={}", + endedSeasonId, snapshotCount, grantedBadgeCount); } catch (Exception e) { log.error("[SEASON_TRANSITION_ERROR] Failed", e); // TODO: 슬랙/이메일 알림 From 061ef7a3f1588f37d7846dd1d4a4c47bc7406080 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:49:10 +0900 Subject: [PATCH 21/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EB=A0=88?= =?UTF-8?q?=ED=8C=8C=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/repository/BadgeRepository.java | 17 ++++++++ .../badge/repository/UserBadgeRepository.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java new file mode 100644 index 0000000..7b37c60 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java @@ -0,0 +1,17 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BadgeRepository extends JpaRepository { + Badge findByBadgeType(BadgeType badgeType); + + List findAllByBadgeType(BadgeType badgeType); + + Badge findByCode(String code); + + boolean existsByCode(String code); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java new file mode 100644 index 0000000..4a08501 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; + +public interface UserBadgeRepository extends JpaRepository { + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + order by ub.awardedAt desc + """) + List findMyBadges(Long userId); + + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + and ub.notifiedAt is null + order by ub.awardedAt desc + """) + List findUnnotifiedBadgeResponses(Long userId); + + boolean existsByUserIdAndBadgeId(Long userId, Long badgeId); + + boolean existsByBadgeId(Long badgeId); + + List findByUserId(Long userId); + + List findByUserIdAndNotifiedAtIsNull(Long userId); + +} From 6c3e380a97765d52dc9fd586f27636377adc5986 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Fri, 20 Feb 2026 15:49:15 +0900 Subject: [PATCH 22/32] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/service/BadgeService.java | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java new file mode 100644 index 0000000..894f77f --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java @@ -0,0 +1,270 @@ +package com.gpt.geumpumtabackend.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BadgeService { + + private final BadgeRepository badgeRepository; + private final UserBadgeRepository userBadgeRepository; + private final UserRepository userRepository; + private final StudySessionRepository studySessionRepository; + private final StatisticsRepository statisticsRepository; + private final SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + private static final long STREAK_MIN_MILLIS = 30L * 60L * 1000L; + + public List getMyBadges(Long userId) { + validateUserExists(userId); + + Map awardedAtByBadgeId = userBadgeRepository.findByUserId(userId).stream() + .collect(Collectors.toMap( + UserBadge::getBadgeId, + UserBadge::getAwardedAt, + (existing, ignored) -> existing + )); + + return badgeRepository.findAll().stream() + .sorted(Comparator.comparing(Badge::getId)) + .map(badge -> MyBadgeStatusResponse.from(badge, awardedAtByBadgeId.get(badge.getId()))) + .toList(); + } + + public List getAllBadges() { + return badgeRepository.findAll().stream() + .map(BadgeResponse::from) + .toList(); + } + + @Transactional + public BadgeCreateResponse createBadge(BadgeCreateRequest request) { + if (badgeRepository.existsByCode(request.code())) { + throw new BusinessException(ExceptionType.BADGE_CODE_ALREADY_EXISTS); + } + + Badge badge = Badge.builder() + .code(request.code()) + .name(request.name()) + .description(request.description()) + .iconUrl(request.iconUrl()) + .badgeType(request.badgeType()) + .thresholdValue(request.thresholdValue()) + .rank(request.rank()) + .build(); + + Badge saved = badgeRepository.save(badge); + return BadgeCreateResponse.from(saved); + } + + @Transactional + public void deleteBadge(Long badgeId) { + Badge badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + + if (userBadgeRepository.existsByBadgeId(badgeId)) { + throw new BusinessException(ExceptionType.BADGE_IN_USE); + } + + badgeRepository.delete(badge); + } + + @Transactional + public RepresentativeBadgeResponse setRepresentativeBadge(RepresentativeBadgeRequest request, Long userId) { + Badge badge = badgeRepository.findByCode(request.badgeCode()); + if (badge == null) { + throw new BusinessException(ExceptionType.BADGE_NOT_FOUND); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + if(!userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) + throw new BusinessException(ExceptionType.BADGE_NOT_OWNED); + + user.setRepresentativeBadge(badge.getId()); + return RepresentativeBadgeResponse.from(badge); + } + + @Transactional + public NewBadgeResponse grantWelcomeBadge(Long userId) { + validateUserExists(userId); + + Badge badge = badgeRepository.findByBadgeType(BadgeType.WELCOME); + if (badge == null) { + throw new BusinessException(ExceptionType.BADGE_NOT_FOUND); + } + if (userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + return NewBadgeResponse.from(badge); + } + LocalDateTime now = LocalDateTime.now(); + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + return NewBadgeResponse.from(badge); + } + + @Transactional + public List getUnnotifiedBadges(Long userId) { + validateUserExists(userId); + + List responses = userBadgeRepository.findUnnotifiedBadgeResponses(userId); + if (responses.isEmpty()) { + return Collections.emptyList(); + } + + markBadgesNotified(userId); + + return responses; + } + + private void markBadgesNotified(Long userId) { + List userBadges = userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId); + LocalDateTime now = LocalDateTime.now(); + for (UserBadge userBadge : userBadges) { + userBadge.markNotified(now); + } + userBadgeRepository.saveAll(userBadges); + } + + @Transactional + public List grantStudyAchievementBadges(Long userId) { + validateUserExists(userId); + + LocalDateTime now = LocalDateTime.now(); + List newlyGranted = new ArrayList<>(); + + grantTotalHoursBadge(userId, now, newlyGranted); + grantStreakBadge(userId, now, newlyGranted); + + return newlyGranted; + } + + @Transactional + public int grantSeasonRankingBadges(Long seasonId) { + return grantSeasonRankTypeBadges( + seasonId, + RankType.OVERALL, + BadgeType.SEASON_PERSONAL_RANK + ); + } + + private int grantSeasonRankTypeBadges(Long seasonId, RankType rankType, BadgeType badgeType) { + List badges = badgeRepository.findAllByBadgeType(badgeType); + if (badges.isEmpty()) { + return 0; + } + + Map badgeByRank = badges.stream() + .filter(badge -> badge.getRank() != null) + .collect(Collectors.toMap( + badge -> badge.getRank().intValue(), + badge -> badge, + (existing, ignored) -> existing + )); + if (badgeByRank.isEmpty()) { + return 0; + } + + Set targetRanks = badgeByRank.keySet(); + List snapshots = + seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, + rankType, + targetRanks + ); + + int grantedCount = 0; + LocalDateTime now = LocalDateTime.now(); + for (SeasonRankingSnapshot snapshot : snapshots) { + Badge badge = badgeByRank.get(snapshot.getFinalRank()); + if (badge == null) { + continue; + } + if (userBadgeRepository.existsByUserIdAndBadgeId(snapshot.getUserId(), badge.getId())) { + continue; + } + userBadgeRepository.save(new UserBadge(snapshot.getUserId(), badge.getId(), now, null)); + grantedCount++; + } + + return grantedCount; + } + + private void grantTotalHoursBadge(Long userId, LocalDateTime now, List newlyGranted) { + Long totalMillis = studySessionRepository.sumTotalStudyMillisByUserId(userId); + if (totalMillis == null) { + totalMillis = 0L; + } + + List hourBadges = badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS); + for (Badge badge : hourBadges) { + Long thresholdHours = badge.getThresholdValue(); + if (thresholdHours == null) { + continue; + } + long thresholdMillis = thresholdHours * 60L * 60L * 1000L; + if (totalMillis >= thresholdMillis + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void grantStreakBadge(Long userId, LocalDateTime now, List newlyGranted) { + int consecutiveDays = statisticsRepository.countCurrentConsecutiveStudyDays( + userId, LocalDate.now(), STREAK_MIN_MILLIS + ); + List streakBadges = badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS); + for (Badge badge : streakBadges) { + Long thresholdDays = badge.getThresholdValue(); + if (thresholdDays == null) { + continue; + } + if (consecutiveDays >= thresholdDays + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void validateUserExists(Long userId) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ExceptionType.USER_NOT_FOUND); + } + } +} From e5bef8db9a1aacccdfbdb91b1424f46019ceb06f Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 14:20:31 +0900 Subject: [PATCH 23/32] =?UTF-8?q?chore:=20userBadge=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=EC=9E=90=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 --- .../com/gpt/geumpumtabackend/badge/domain/UserBadge.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java index f7fb4cf..e0aa7ca 100644 --- a/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java @@ -16,13 +16,13 @@ public class UserBadge { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - Long id; + private Long id; @Column(name = "user_id") - Long userId; + private Long userId; @Column(name = "badge_id") - Long badgeId; + private Long badgeId; private LocalDateTime awardedAt; From 82e577bfff23bb9d665f73c66da571d94ef52855 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 14:31:32 +0900 Subject: [PATCH 24/32] =?UTF-8?q?chore:=20Spring=20Data=20JPA=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=9D=84=20Optional=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/repository/BadgeRepository.java | 5 +++-- .../geumpumtabackend/badge/service/BadgeService.java | 12 ++++-------- .../unit/badge/service/BadgeServiceTest.java | 4 ++-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java index 7b37c60..4462546 100644 --- a/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java @@ -5,13 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface BadgeRepository extends JpaRepository { - Badge findByBadgeType(BadgeType badgeType); + Optional findByBadgeType(BadgeType badgeType); List findAllByBadgeType(BadgeType badgeType); - Badge findByCode(String code); + Optional findByCode(String code); boolean existsByCode(String code); } diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java index 894f77f..74a6ad5 100644 --- a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java +++ b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java @@ -106,10 +106,8 @@ public void deleteBadge(Long badgeId) { @Transactional public RepresentativeBadgeResponse setRepresentativeBadge(RepresentativeBadgeRequest request, Long userId) { - Badge badge = badgeRepository.findByCode(request.badgeCode()); - if (badge == null) { - throw new BusinessException(ExceptionType.BADGE_NOT_FOUND); - } + Badge badge = badgeRepository.findByCode(request.badgeCode()) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); @@ -124,10 +122,8 @@ public RepresentativeBadgeResponse setRepresentativeBadge(RepresentativeBadgeReq public NewBadgeResponse grantWelcomeBadge(Long userId) { validateUserExists(userId); - Badge badge = badgeRepository.findByBadgeType(BadgeType.WELCOME); - if (badge == null) { - throw new BusinessException(ExceptionType.BADGE_NOT_FOUND); - } + Badge badge = badgeRepository.findByBadgeType(BadgeType.WELCOME) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); if (userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { return NewBadgeResponse.from(badge); } diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java index e886f1d..9f3dd32 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java @@ -205,7 +205,7 @@ class BadgeServiceTest { Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); when(userRepository.existsById(userId)).thenReturn(true); - when(badgeRepository.findByBadgeType(BadgeType.WELCOME)).thenReturn(badge); + when(badgeRepository.findByBadgeType(BadgeType.WELCOME)).thenReturn(Optional.of(badge)); when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -276,7 +276,7 @@ class BadgeServiceTest { Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); User user = mock(User.class); - when(badgeRepository.findByCode("WELCOME_001")).thenReturn(badge); + when(badgeRepository.findByCode("WELCOME_001")).thenReturn(Optional.of(badge)); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); From 2a5234c3bd8a3d8e30672dcf6db08aeaa540e1b5 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 14:37:04 +0900 Subject: [PATCH 25/32] =?UTF-8?q?chore:=20=EC=98=88=EC=99=B8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=91=EB=91=90=EC=82=AC=20=EC=B6=A9=EB=8F=8C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gpt/geumpumtabackend/global/exception/ExceptionType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java index 2009854..2e5ae4f 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java @@ -55,7 +55,7 @@ public enum ExceptionType { // board - BOARD_NOT_FOUND(BAD_REQUEST, "B001", "존재하지 않는 게시물입니다."), + BOARD_NOT_FOUND(BAD_REQUEST, "BD001", "존재하지 않는 게시물입니다."), // Season NO_ACTIVE_SEASON(NOT_FOUND, "SE001", "현재 진행중인 시즌이 없습니다"), @@ -70,7 +70,7 @@ public enum ExceptionType { FCM_TOKEN_NOT_FOUND(NOT_FOUND, "F003", "등록된 FCM 토큰이 없습니다."), // Badge - BADGE_NOT_FOUND(NOT_FOUND, "BOO1", "배지가 존재하지 않습니다"), + BADGE_NOT_FOUND(NOT_FOUND, "B001", "배지가 존재하지 않습니다"), BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다"), BADGE_CODE_ALREADY_EXISTS(CONFLICT, "B003", "이미 존재하는 배지 코드입니다"), BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다") From 4a9adbc652474fa6b3494d0a9116003abe3f6787 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 15:44:53 +0900 Subject: [PATCH 26/32] =?UTF-8?q?fix:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A2=85=EB=A3=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EA=B3=BC=20=EB=B0=B0=EC=A7=80=20=EC=A7=80=EA=B8=89=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/service/BadgeService.java | 3 +- .../study/service/StudySessionService.java | 9 ++- .../service/StudySessionServiceTest.java | 67 ++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java index 74a6ad5..4f80e82 100644 --- a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java +++ b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java @@ -24,6 +24,7 @@ import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -155,7 +156,7 @@ private void markBadgesNotified(Long userId) { userBadgeRepository.saveAll(userBadges); } - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public List grantStudyAchievementBadges(Long userId) { validateUserExists(userId); diff --git a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java index d7f79d9..a1b7bc7 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java @@ -21,7 +21,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.List; import java.util.ArrayList; import java.util.List; @@ -66,7 +65,13 @@ public List endStudySession(StudyEndRequest endRequest, Long u .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); LocalDateTime endTime = LocalDateTime.now(); studysession.endStudySession(endTime); - return badgeService.grantStudyAchievementBadges(userId); + + try { + return badgeService.grantStudyAchievementBadges(userId); + } catch (Exception e) { + log.warn("배지 지급 실패 - userId={}", userId, e); + return List.of(); + } } private BusinessException mapWiFiValidationException(WiFiValidationResult result) { diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java index f1676b3..2a35ccc 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java @@ -2,7 +2,11 @@ import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; @@ -21,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -42,6 +47,12 @@ class StudySessionServiceTest { @Mock private CampusWiFiValidationService wifiValidationService; + + @Mock + private StudyProperties studyProperties; + + @Mock + private BadgeService badgeService; @InjectMocks private StudySessionService studySessionService; @@ -236,6 +247,60 @@ class StudySessionCalculation { } } + @Nested + @DisplayName("공부 종료") + class EndStudySession { + + @Test + @DisplayName("공부 종료 시 세션을 종료하고 새 배지를 응답으로 반환한다") + void 공부종료시_세션종료후_새배지를_반환한다() { + // Given + Long userId = 1L; + Long sessionId = 10L; + User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); + StudySession session = new StudySession(); + session.startStudySession(LocalDateTime.now().minusHours(1), testUser); + List expected = List.of( + new NewBadgeResponse("TOTAL_HOURS_50", "50시간", "누적 50시간", "icon.png") + ); + + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.of(session)); + given(badgeService.grantStudyAchievementBadges(userId)).willReturn(expected); + + // When + List result = studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); + + // Then + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); + assertThat(result).isEqualTo(expected); + verify(badgeService).grantStudyAchievementBadges(userId); + } + + @Test + @DisplayName("배지 지급 중 예외가 발생해도 세션 종료는 유지되고 빈 리스트를 반환한다") + void 배지지급실패여도_세션종료는_유지된다() { + // Given + Long userId = 1L; + Long sessionId = 11L; + User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); + StudySession session = new StudySession(); + session.startStudySession(LocalDateTime.now().minusHours(1), testUser); + + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.of(session)); + given(badgeService.grantStudyAchievementBadges(userId)) + .willThrow(new RuntimeException("badge failed")); + + // When + List result = studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); + + // Then + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); + assertThat(result).isEmpty(); + } + } + // 테스트 데이터 생성 헬퍼 메서드 private User createTestUser(Long id, String name, Department department) { User user = User.builder() @@ -259,4 +324,4 @@ private User createTestUser(Long id, String name, Department department) { return user; } -} \ No newline at end of file +} From 1abe2669a65f10a85d8c7822b5d76041d66e33e3 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 16:09:49 +0900 Subject: [PATCH 27/32] =?UTF-8?q?refactor:=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EA=B3=B5=EB=B6=80=EC=9D=BC=EC=88=98=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EC=9D=BC=EB=B3=84=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84+=EC=9C=88=EB=8F=84=EC=9A=B0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StatisticsRepository.java | 110 ++++++++++++------ .../StatisticsControllerIntegrationTest.java | 4 +- 2 files changed, 74 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java index bde201e..d7f9904 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java @@ -266,45 +266,79 @@ MonthlyStatistics getMonthlyStatistics( ); @Query(value = """ - WITH RECURSIVE streak(day_date, day_millis) AS ( - SELECT DATE(:today) AS day_date, - CAST(COALESCE(( - SELECT SUM(GREATEST( - 0, - TIMESTAMPDIFF( - MICROSECOND, - GREATEST(s.start_time, DATE(:today)), - LEAST(COALESCE(s.end_time, DATE(:today) + INTERVAL 1 DAY), DATE(:today) + INTERVAL 1 DAY) - ) / 1000 - )) - FROM study_session s - WHERE s.user_id = :userId - AND s.start_time < DATE(:today) + INTERVAL 1 DAY - AND s.end_time > DATE(:today) - ), 0) AS SIGNED) AS day_millis - UNION ALL - SELECT DATE_SUB(streak.day_date, INTERVAL 1 DAY) AS day_date, - CAST(COALESCE(( - SELECT SUM(GREATEST( - 0, - TIMESTAMPDIFF( - MICROSECOND, - GREATEST(s.start_time, DATE_SUB(streak.day_date, INTERVAL 1 DAY)), - LEAST(COALESCE(s.end_time, DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY), - DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY) - ) / 1000 - )) - FROM study_session s - WHERE s.user_id = :userId - AND s.start_time < DATE_SUB(streak.day_date, INTERVAL 1 DAY) + INTERVAL 1 DAY - AND s.end_time > DATE_SUB(streak.day_date, INTERVAL 1 DAY) - ), 0) AS SIGNED) AS day_millis - FROM streak - WHERE streak.day_millis >= :thresholdMillis + WITH RECURSIVE + bounds AS ( + SELECT DATE_SUB(DATE(:today), INTERVAL 364 DAY) AS start_date, + DATE(:today) AS end_date + ), + days AS ( + SELECT b.start_date AS day_date, + CAST(b.start_date AS DATETIME) AS day_start, + CAST(DATE_ADD(b.start_date, INTERVAL 1 DAY) AS DATETIME) AS day_end + FROM bounds b + UNION ALL + SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), + DATE_ADD(d.day_start, INTERVAL 1 DAY), + DATE_ADD(d.day_end, INTERVAL 1 DAY) + FROM days d + JOIN bounds b ON d.day_date < b.end_date + ), + sessions_in_window AS ( + SELECT s.start_time, + COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) AS end_time + FROM study_session s + JOIN bounds b + ON s.user_id = :userId + AND s.start_time < CAST(DATE_ADD(b.end_date, INTERVAL 1 DAY) AS DATETIME) + AND COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) > CAST(b.start_date AS DATETIME) + ), + day_overlap AS ( + SELECT d.day_date, + GREATEST(s.start_time, d.day_start) AS seg_start, + LEAST(s.end_time, d.day_end) AS seg_end + FROM days d + JOIN sessions_in_window s + ON s.start_time < d.day_end + AND s.end_time > d.day_start + ), + per_day AS ( + SELECT d.day_date, + CAST(COALESCE(SUM( + GREATEST(TIMESTAMPDIFF(MICROSECOND, o.seg_start, o.seg_end), 0) + ) / 1000, 0) AS SIGNED) AS day_millis + FROM days d + LEFT JOIN day_overlap o ON o.day_date = d.day_date + GROUP BY d.day_date + ), + qualified AS ( + SELECT day_date + FROM per_day + WHERE day_millis >= :thresholdMillis + ), + numbered AS ( + SELECT day_date, + ROW_NUMBER() OVER (ORDER BY day_date) AS rn + FROM qualified + ), + islands AS ( + SELECT day_date, + DATE_SUB(day_date, INTERVAL rn DAY) AS grp_key + FROM numbered + ), + streaks AS ( + SELECT grp_key, + MIN(day_date) AS streak_start, + MAX(day_date) AS streak_end, + COUNT(*) AS streak_len + FROM islands + GROUP BY grp_key ) - SELECT COUNT(*) - FROM streak - WHERE day_millis >= :thresholdMillis + SELECT COALESCE(( + SELECT streak_len + FROM streaks + WHERE streak_end = DATE(:today) + LIMIT 1 + ), 0) """, nativeQuery = true) Integer countCurrentConsecutiveStudyDays( @Param("userId") Long userId, diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java index 830b9f2..aa3e930 100644 --- a/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java @@ -123,7 +123,7 @@ private StudySession createStudySession(User user, LocalDateTime startTime, Loca .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.data.weeklyStatistics.totalWeekMillis").value(ONE_HOUR_MILLIS * 2)) + .andExpect(jsonPath("$.data.weeklyStatistics.totalWeekMillis").value((int) (ONE_HOUR_MILLIS * 2))) .andExpect(jsonPath("$.data.weeklyStatistics.maxConsecutiveStudyDays").value(2)) .andExpect(jsonPath("$.data.weeklyStatistics.averageDailyMillis").value(1_028_571)); } @@ -141,7 +141,7 @@ private StudySession createStudySession(User user, LocalDateTime startTime, Loca .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.data.monthlyStatistics.totalMonthMillis").value(14_400_000L)) + .andExpect(jsonPath("$.data.monthlyStatistics.totalMonthMillis").value((int) 14_400_000L)) .andExpect(jsonPath("$.data.monthlyStatistics.averageDailyMillis").value(464_516)) .andExpect(jsonPath("$.data.monthlyStatistics.maxConsecutiveStudyDays").value(2)) .andExpect(jsonPath("$.data.monthlyStatistics.studiedDays").value(3)); From a14b0f144cbcf4a33813b9b837c0e5fa745fe938 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 17:19:01 +0900 Subject: [PATCH 28/32] =?UTF-8?q?fix:=20=EC=8B=9C=EC=A6=8C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0=EC=A7=80?= =?UTF-8?q?=20=EC=A7=80=EA=B8=89=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20try-catch?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rank/scheduler/SeasonTransitionScheduler.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java index 5b9fdbc..d05f415 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java @@ -48,7 +48,12 @@ public void processSeasonTransition() { // 스냅샷 생성 int snapshotCount = snapshotService.createSeasonSnapshot(endedSeasonId); - int grantedBadgeCount = badgeService.grantSeasonRankingBadges(endedSeasonId); + int grantedBadgeCount = 0; + try { + grantedBadgeCount = badgeService.grantSeasonRankingBadges(endedSeasonId); + } catch (Exception e) { + log.error("[SEASON_BADGE_GRANT_FAILED] seasonId={}", endedSeasonId, e); + } log.info("[SEASON_TRANSITION] seasonId={}, snapshots={}, rankingBadges={}", endedSeasonId, snapshotCount, grantedBadgeCount); } catch (Exception e) { From 61b97b3d9dd6d2e46304d5a788d0f61678de63f2 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 19:50:13 +0900 Subject: [PATCH 29/32] =?UTF-8?q?refactor:=20end=20API=EB=A5=BC=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=B0=EC=A7=80=20=EC=A7=80=EA=B8=89=EC=9D=84=20?= =?UTF-8?q?AFTER=5FCOMMIT=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudySessionController.java | 9 +++---- .../study/dto/response/StudyEndResponse.java | 13 ---------- .../event/StudyBadgeGrantEventListener.java | 25 +++++++++++++++++++ .../study/event/StudySessionEndedEvent.java | 4 +++ .../study/service/StudySessionService.java | 16 ++++-------- 5 files changed, 37 insertions(+), 30 deletions(-) delete mode 100644 src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java diff --git a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java index 82672c7..7176498 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java @@ -6,7 +6,6 @@ import com.gpt.geumpumtabackend.study.api.StudySessionApi; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; -import com.gpt.geumpumtabackend.study.dto.response.StudyEndResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import com.gpt.geumpumtabackend.study.service.StudySessionService; @@ -53,10 +52,8 @@ public ResponseEntity> startStudySession(@Valid @PostMapping("/end") @PreAuthorize("isAuthenticated() and hasRole('USER')") @AssignUserId - public ResponseEntity> endStudySession(@Valid @RequestBody StudyEndRequest request, Long userId){ - StudyEndResponse response = StudyEndResponse.of( - studySessionService.endStudySession(request, userId) - ); - return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); + public ResponseEntity> endStudySession(@Valid @RequestBody StudyEndRequest request, Long userId){ + studySessionService.endStudySession(request, userId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java deleted file mode 100644 index 6f1abff..0000000 --- a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudyEndResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.gpt.geumpumtabackend.study.dto.response; - -import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; - -import java.util.List; - -public record StudyEndResponse( - List newBadges -) { - public static StudyEndResponse of(List newBadges) { - return new StudyEndResponse(newBadges); - } -} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java new file mode 100644 index 0000000..b70637c --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.study.event; + +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StudyBadgeGrantEventListener { + + private final BadgeService badgeService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleStudySessionEnded(StudySessionEndedEvent event) { + try { + badgeService.grantStudyAchievementBadges(event.userId()); + } catch (Exception e) { + log.warn("배지 지급 실패 - userId={}", event.userId(), e); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java new file mode 100644 index 0000000..12eb793 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java @@ -0,0 +1,4 @@ +package com.gpt.geumpumtabackend.study.event; + +public record StudySessionEndedEvent(Long userId) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java index a1b7bc7..78cba78 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java @@ -1,8 +1,6 @@ package com.gpt.geumpumtabackend.study.service; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; -import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; -import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; import com.gpt.geumpumtabackend.study.domain.StudyStatus; @@ -10,6 +8,7 @@ import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; import com.gpt.geumpumtabackend.user.domain.User; import com.gpt.geumpumtabackend.user.repository.UserRepository; @@ -17,6 +16,7 @@ import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -33,7 +33,7 @@ public class StudySessionService { private final UserRepository userRepository; private final CampusWiFiValidationService wifiValidationService; private final StudyProperties studyProperties; - private final BadgeService badgeService; + private final ApplicationEventPublisher eventPublisher; /* 메인 홈 @@ -60,18 +60,12 @@ public StudyStartResponse startStudySession(StudyStartRequest request, Long user 공부 종료 */ @Transactional - public List endStudySession(StudyEndRequest endRequest, Long userId) { + public void endStudySession(StudyEndRequest endRequest, Long userId) { StudySession studysession = studySessionRepository.findByIdAndUser_Id(endRequest.studySessionId(), userId) .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); LocalDateTime endTime = LocalDateTime.now(); studysession.endStudySession(endTime); - - try { - return badgeService.grantStudyAchievementBadges(userId); - } catch (Exception e) { - log.warn("배지 지급 실패 - userId={}", userId, e); - return List.of(); - } + eventPublisher.publishEvent(new StudySessionEndedEvent(userId)); } private BusinessException mapWiFiValidationException(WiFiValidationResult result) { From df00557af0b4876d8f201c7cc664e7ea72ac1a38 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 19:50:28 +0900 Subject: [PATCH 30/32] =?UTF-8?q?test:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StudySessionServiceTest.java | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java index 2a35ccc..e570cc1 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java @@ -2,13 +2,12 @@ import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; -import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; -import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; import com.gpt.geumpumtabackend.study.service.StudySessionService; import com.gpt.geumpumtabackend.user.domain.Department; @@ -16,6 +15,7 @@ import com.gpt.geumpumtabackend.user.repository.UserRepository; import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; +import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -25,7 +25,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -52,7 +51,7 @@ class StudySessionServiceTest { private StudyProperties studyProperties; @Mock - private BadgeService badgeService; + private ApplicationEventPublisher eventPublisher; @InjectMocks private StudySessionService studySessionService; @@ -252,52 +251,40 @@ class StudySessionCalculation { class EndStudySession { @Test - @DisplayName("공부 종료 시 세션을 종료하고 새 배지를 응답으로 반환한다") - void 공부종료시_세션종료후_새배지를_반환한다() { + @DisplayName("공부 종료 시 세션을 종료하고 AFTER_COMMIT 이벤트를 발행한다") + void 공부종료시_세션종료후_이벤트를_발행한다() { // Given Long userId = 1L; Long sessionId = 10L; User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); StudySession session = new StudySession(); session.startStudySession(LocalDateTime.now().minusHours(1), testUser); - List expected = List.of( - new NewBadgeResponse("TOTAL_HOURS_50", "50시간", "누적 50시간", "icon.png") - ); given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) .willReturn(Optional.of(session)); - given(badgeService.grantStudyAchievementBadges(userId)).willReturn(expected); // When - List result = studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); + studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); // Then assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); - assertThat(result).isEqualTo(expected); - verify(badgeService).grantStudyAchievementBadges(userId); + verify(eventPublisher).publishEvent(new StudySessionEndedEvent(userId)); } @Test - @DisplayName("배지 지급 중 예외가 발생해도 세션 종료는 유지되고 빈 리스트를 반환한다") - void 배지지급실패여도_세션종료는_유지된다() { + @DisplayName("세션이 없으면 STUDY_SESSION_NOT_FOUND 예외가 발생한다") + void 세션이_없으면_예외가_발생한다() { // Given Long userId = 1L; Long sessionId = 11L; - User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); - StudySession session = new StudySession(); - session.startStudySession(LocalDateTime.now().minusHours(1), testUser); - given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) - .willReturn(Optional.of(session)); - given(badgeService.grantStudyAchievementBadges(userId)) - .willThrow(new RuntimeException("badge failed")); + .willReturn(Optional.empty()); // When - List result = studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); - - // Then - assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); - assertThat(result).isEmpty(); + assertThatThrownBy(() -> studySessionService.endStudySession(new StudyEndRequest(sessionId), userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.STUDY_SESSION_NOT_FOUND); + verify(eventPublisher, never()).publishEvent(any()); } } From 25872ce0da09c674ac36449553a0f54057673bd9 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 19:50:43 +0900 Subject: [PATCH 31/32] =?UTF-8?q?docs:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gpt/geumpumtabackend/study/api/StudySessionApi.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java index ea2f84c..8f3e2ff 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java @@ -8,7 +8,6 @@ import com.gpt.geumpumtabackend.global.response.ResponseBody; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; -import com.gpt.geumpumtabackend.study.dto.response.StudyEndResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import io.swagger.v3.oas.annotations.Operation; @@ -123,11 +122,15 @@ ResponseEntity> startStudySession( - 총 학습 시간 계산 및 저장 - 세션 상태를 FINISHED로 변경 - 랭킹 시스템에 반영 (다음 스케줄링 시) + - 배지 지급은 트랜잭션 커밋 이후 비동기적으로 처리 + + 🎖️ **배지 확인 방법:** + - 이 API 응답에는 배지 정보가 포함되지 않습니다. + - 종료 성공 후 `GET /api/v1/badge/unnotified`를 호출해 새 배지를 조회하세요. """ ) @SwaggerApiResponses( success = @SwaggerApiSuccessResponse( - response = StudyEndResponse.class, description = "학습 세션 종료 성공"), errors = { @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), @@ -138,7 +141,7 @@ ResponseEntity> startStudySession( @PostMapping("/end") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('USER')") - ResponseEntity> endStudySession( + ResponseEntity> endStudySession( @Valid @RequestBody StudyEndRequest request, @Parameter(hidden = true) Long userId ); From 043fbcbb36cc62c9e595ba4f7b65b8a6b93b2ab0 Mon Sep 17 00:00:00 2001 From: kon28289 Date: Sat, 21 Feb 2026 20:25:43 +0900 Subject: [PATCH 32/32] =?UTF-8?q?docs:=20=EA=B3=B5=EB=B6=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gpt/geumpumtabackend/study/api/StudySessionApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java index 8f3e2ff..9486c16 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java @@ -122,7 +122,7 @@ ResponseEntity> startStudySession( - 총 학습 시간 계산 및 저장 - 세션 상태를 FINISHED로 변경 - 랭킹 시스템에 반영 (다음 스케줄링 시) - - 배지 지급은 트랜잭션 커밋 이후 비동기적으로 처리 + - 배지 지급은 트랜잭션 커밋 이후 동기적으로 처리 🎖️ **배지 확인 방법:** - 이 API 응답에는 배지 정보가 포함되지 않습니다.