From 0160857754132344b2e2028f864cd4ae2fa2ab16 Mon Sep 17 00:00:00 2001 From: yejin Date: Wed, 1 Jul 2026 20:21:49 +0900 Subject: [PATCH 01/54] =?UTF-8?q?Feat:=20=ED=8C=9D=EC=97=85=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EA=B1=B4=EC=9D=98=20=EC=A2=85=EB=A5=98=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 --- .../com/teamcback/domain/suggestion/entity/SuggestionType.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java index e28ae98f..a4520272 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java @@ -16,6 +16,7 @@ public enum SuggestionType { FEATURE_SUGGESTION("추천 기능"), INCONVENIENCE("불편 사항"), QUESTION("질의 사항"), - OTHER("기타"); + OTHER("기타"), + POPUP("팝업 추가 답변"); private final String type; } From 16e372e694ee2cce16fd2d4d92af5c93506388b0 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 26 Jul 2026 16:24:35 +0900 Subject: [PATCH 02/54] feat: add pharmacy enum type --- .../devkor/com/teamcback/domain/place/entity/PlaceType.java | 3 ++- .../com/teamcback/domain/review/service/ReviewService.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java index cf15e2bc..26e2b787 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java +++ b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java @@ -41,7 +41,8 @@ public enum PlaceType { CAFE_TEMP("임시용 외부 카페", new String[] {}), CONV_TEMP("임시용 외부 편의점", new String[] {}), CAFT_TEMP("임시용 외부 식당", new String[] {}), - REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}),; + REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}), + PHARMACY("약국", new String[]{}),; private final String name; private final String[] nickname; diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java index 19e5df9a..6f284976 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -278,7 +278,8 @@ private Review findReviewById(Long reviewId) { private void checkReviewPlaceType(Place place) { // 식당, 카페만 조회 가능하도록 제한 if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE - && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP && place.getType() != PlaceType.CONV_TEMP) { + && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP + && place.getType() != PlaceType.CONV_TEMP && place.getType() != PlaceType.PHARMACY) { throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); } } From c9f640c67a320d494ba3ea383d07e5bd97f6a481 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Fri, 31 Jul 2026 20:20:57 +0900 Subject: [PATCH 03/54] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20installation?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushInstallationController.java | 125 ++++++++++++++++++ .../request/PushInstallationRegisterReq.java | 33 +++++ .../notification/entity/AppVariant.java | 29 ++++ .../notification/entity/PushInstallation.java | 93 +++++++++++++ .../PushInstallationRepository.java | 27 ++++ .../service/PushInstallationService.java | 120 +++++++++++++++++ .../domain/user/service/UserService.java | 3 + .../teamcback/global/config/TimeConfig.java | 16 +++ .../global/response/CommonResponse.java | 2 +- .../global/security/SecurityConfig.java | 29 ++-- 10 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java create mode 100644 src/main/java/devkor/com/teamcback/global/config/TimeConfig.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java new file mode 100644 index 00000000..50976ddd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java @@ -0,0 +1,125 @@ +package devkor.com.teamcback.domain.notification.controller; + + +import devkor.com.teamcback.domain.notification.dto.request.PushInstallationRegisterReq; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +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.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag( + name = "푸시 알림 설치 관리", + description = "로그인 사용자의 Expo Push installation 등록, 갱신 및 비활성화 API" +) +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/installations") +public class PushInstallationController { + + private final PushInstallationService pushInstallationService; + + @Operation( + summary = "푸시 알림 installation 등록 및 갱신", + description = """ + 현재 로그인 사용자의 Expo Push installation을 등록합니다. + 동일한 installationId로 다시 요청하면 새 행을 생성하지 않고 + 기존 installation의 토큰, 사용자 및 앱 환경 정보를 갱신합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "등록 또는 갱신 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청값", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = CommonResponse.class)) + ) + }) + @PutMapping("/{installationId}") + public ResponseEntity> register( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "앱 설치를 식별하는 고유 ID", + example = "11111111-2222-4333-8444-555555555555", + required = true + ) + @PathVariable + @NotBlank + @Size(max = 64) + String installationId, + + @Valid + @RequestBody + PushInstallationRegisterReq request + ) { + Long userId = userDetail.getUser().getUserId(); + + pushInstallationService.register( + userId, + installationId, + request.expoPushToken(), + request.appVariant() + ); + + return ResponseEntity.ok(CommonResponse.success()); + } + + @Operation( + summary = "푸시 알림 installation 비활성화", + description = """ + 현재 로그인 사용자가 소유한 installation을 비활성화합니다. + 대상이 없거나 이미 비활성화된 경우에도 성공으로 처리합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비활성화 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = CommonResponse.class)) + ) + }) + @DeleteMapping("/{installationId}") + public ResponseEntity> deactivate( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "비활성화할 앱 설치 식별자", + example = "11111111-2222-4333-8444-555555555555", + required = true + ) + @PathVariable + @NotBlank + @Size(max = 64) + String installationId + ) { + Long userId = userDetail.getUser().getUserId(); + + pushInstallationService.deactivate( + userId, + installationId + ); + + return ResponseEntity.ok(CommonResponse.success()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java new file mode 100644 index 00000000..3ef82f08 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java @@ -0,0 +1,33 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Expo Push installation 등록 요청") +public record PushInstallationRegisterReq( + + @Schema(description = "요청 스키마 버전", example = "1", allowableValues = {"1"}) + @NotNull + @Min(1) + @Max(1) + Integer schemaVersion, + + @Schema(description = "Expo에서 발급된 Push Token", example = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]") + @NotBlank + String expoPushToken, + + @Schema(description = "토큰이 발급된 앱 빌드 환경", example = "dev", + allowableValues = { + "dev", + "preview", + "production" + } + ) + @NotNull + AppVariant appVariant +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java new file mode 100644 index 00000000..a191b9f5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.notification.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Locale; + +public enum AppVariant { + + DEV, + PREVIEW, + PRODUCTION; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AppVariant from(String value) { + if (value == null) { + return null; + } + + return AppVariant.valueOf( + value.trim().toUpperCase(Locale.ROOT) + ); + } + + @JsonValue + public String toValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java new file mode 100644 index 00000000..1115485c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java @@ -0,0 +1,93 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "tb_push_installation", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_push_installation_installation_id", + columnNames = "installation_id" + ), + @UniqueConstraint( + name = "uk_push_installation_expo_push_token", + columnNames = "expo_push_token" + ) + }, + indexes = { + @Index( + name = "idx_push_installation_user_variant_active", + columnList = "user_id, app_variant, active" + ) + } +) +@NoArgsConstructor +@Getter +public class PushInstallation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_installation_id") + private Long pushInstallationId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "installation_id", nullable = false, length = 64) + private String installationId; + + @Column(name = "expo_push_token", nullable = false, length = 255) + private String expoPushToken; + + @Enumerated(EnumType.STRING) + @Column(name = "app_variant", nullable = false, length = 20) + private AppVariant appVariant; + + @Column(name = "active", nullable = false) + private boolean active = true; + + @Column(name = "deactivated_at") + private LocalDateTime deactivatedAt; + + public PushInstallation( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + this.userId = userId; + this.installationId = installationId; + this.expoPushToken = expoPushToken; + this.appVariant = appVariant; + this.active = true; + } + + public void register( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + this.userId = userId; + this.installationId = installationId; + this.expoPushToken = expoPushToken; + this.appVariant = appVariant; + this.active = true; + this.deactivatedAt = null; + } + + public void deactivate(LocalDateTime now) { + if (!active) { + return; + } + + this.active = false; + this.deactivatedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java new file mode 100644 index 00000000..e5c136f1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -0,0 +1,27 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PushInstallationRepository extends JpaRepository { + + Optional findByInstallationId( + String installationId + ); + + Optional findByExpoPushToken( + String expoPushToken + ); + + Optional findByInstallationIdAndUserId( + String installationId, + Long userId + ); + + List findAllByUserIdAndActiveTrue( + Long userId + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java new file mode 100644 index 00000000..dfa35f2a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java @@ -0,0 +1,120 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PushInstallationService { + + private final PushInstallationRepository repository; + private final Clock clock; + + @Transactional + public void register( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + Optional installationMatch = repository.findByInstallationId(installationId); + + Optional tokenMatch = repository.findByExpoPushToken(expoPushToken); + + if (installationMatch.isEmpty() && tokenMatch.isEmpty()) { + repository.save( + new PushInstallation( + userId, + installationId, + expoPushToken, + appVariant + ) + ); + return; + } + + if (installationMatch.isPresent() && tokenMatch.isEmpty()) { + installationMatch.get().register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + if (installationMatch.isEmpty()) { + tokenMatch.get().register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + PushInstallation installationEntity = installationMatch.get(); + + PushInstallation tokenEntity = tokenMatch.get(); + + if (installationEntity == tokenEntity + || installationEntity.getPushInstallationId() + .equals(tokenEntity.getPushInstallationId())) { + + installationEntity.register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + repository.delete(tokenEntity); + repository.flush(); + + installationEntity.register( + userId, + installationId, + expoPushToken, + appVariant + ); + } + + @Transactional + public void deactivate( + Long userId, + String installationId + ) { + LocalDateTime now = LocalDateTime.now(clock); + + repository.findByInstallationIdAndUserId( + installationId, + userId + ) + .ifPresent(installation -> + installation.deactivate(now) + ); + } + + @Transactional + public void deactivateAll( + Long userId + ) { + LocalDateTime now = LocalDateTime.now(clock); + + repository.findAllByUserIdAndActiveTrue(userId) + .forEach(installation -> + installation.deactivate(now) + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 4b5831a5..9c8eb49f 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; @@ -55,6 +56,7 @@ public class UserService { private final GoogleValidator googleValidator; private final AppleValidator appleValidator; private final PasswordEncoder passwordEncoder; + private final PushInstallationService pushInstallationService; private static final String DEFAULT_NAME = "호랑이"; private static final String DEFAULT_CATEGORY = "내 장소"; @@ -183,6 +185,7 @@ public DeleteUserRes deleteUser(Long userId) { } userBookmarkLogRepository.deleteAll(userBookmarkLogRepository.findByUser(user)); + pushInstallationService.deactivateAll(user.getUserId()); // suggestionRepository.deleteAll(suggestionRepository.findByUser(user)); userRepository.delete(user); diff --git a/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java b/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java new file mode 100644 index 00000000..1d7057ac --- /dev/null +++ b/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} diff --git a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java index ee250761..cf9b1072 100644 --- a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java +++ b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java @@ -33,5 +33,5 @@ public CommonResponse(ResultCode resultCode, T data) { public static CommonResponse success(T data) { return new CommonResponse<>(ResultCode.SUCCESS, data); } - + public static CommonResponse success() {return new CommonResponse<>(ResultCode.SUCCESS);} } diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 77695172..ea2fd9a4 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -81,21 +81,22 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); - http.authorizeHttpRequests((authorizeHttpRequests) -> - authorizeHttpRequests - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 - .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() - .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 - .requestMatchers("/api/users/**").authenticated() - .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 - .requestMatchers("/api/categories/**").authenticated() - .requestMatchers("/api/bookmarks/**").authenticated() - .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 - .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 - .anyRequest().permitAll() + http.authorizeHttpRequests(authorizeHttpRequests -> + authorizeHttpRequests + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() + .requestMatchers("/api/users/login/**").permitAll() + .requestMatchers("/api/users/**").authenticated() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/categories/**").authenticated() + .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() + .requestMatchers("/api/reports/status").authenticated() + .requestMatchers("/api/notifications/installations/**").authenticated() + .anyRequest().permitAll() ).exceptionHandling(ex -> ex - .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 - .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 + .accessDeniedHandler(customAccessDeniedHandler()) + .authenticationEntryPoint(customAuthenticationEntryPoint()) ); http.logout( From fa6bb9a6a27c1a050d111242389b6f8c776f5a4d Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Fri, 31 Jul 2026 20:33:59 +0900 Subject: [PATCH 04/54] docs: restore SecurityConfig comments --- .../global/security/SecurityConfig.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index ea2fd9a4..50bc866a 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -81,22 +81,22 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); - http.authorizeHttpRequests(authorizeHttpRequests -> + http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() - .requestMatchers("/api/users/login/**").permitAll() + .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 .requestMatchers("/api/users/**").authenticated() - .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 .requestMatchers("/api/categories/**").authenticated() .requestMatchers("/api/bookmarks/**").authenticated() - .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() - .requestMatchers("/api/reports/status").authenticated() - .requestMatchers("/api/notifications/installations/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 + .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 + .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 .anyRequest().permitAll() ).exceptionHandling(ex -> ex - .accessDeniedHandler(customAccessDeniedHandler()) - .authenticationEntryPoint(customAuthenticationEntryPoint()) + .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 + .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 ); http.logout( From 2cac9a27c4b7ca5862acdbb82dc52e8a09761dad Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 15:34:23 +0900 Subject: [PATCH 05/54] docs: standardize push installation Swagger descriptions --- .../notification/controller/PushInstallationController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java index 50976ddd..2d9925e0 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java @@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -26,10 +25,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +/* @Tag( name = "푸시 알림 설치 관리", description = "로그인 사용자의 Expo Push installation 등록, 갱신 및 비활성화 API" ) + */ @Validated @RestController @RequiredArgsConstructor From 5604a6a08127e02d6f8d8f216bde49c3078ffbb9 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 16:10:01 +0900 Subject: [PATCH 06/54] feat: add push dispatch and message models --- .../notification/dto/payload/PushPayload.java | 17 +++ .../dto/request/PushDispatchCommand.java | 23 ++++ .../dto/response/PushDispatchEnqueueRes.java | 21 +++ .../notification/entity/NotificationType.java | 5 + .../notification/entity/PushActionType.java | 11 ++ .../notification/entity/PushDispatch.java | 127 ++++++++++++++++++ .../entity/PushDispatchStatus.java | 9 ++ .../notification/entity/PushMessage.java | 114 ++++++++++++++++ .../entity/PushMessageStatus.java | 10 ++ .../domain/notification/entity/PushMode.java | 6 + .../notification/entity/PushTargetType.java | 7 + .../repository/PushDispatchRepository.java | 12 ++ .../PushInstallationRepository.java | 11 ++ .../repository/PushMessageRepository.java | 13 ++ .../service/PushActionValidator.java | 105 +++++++++++++++ .../service/PushDispatchService.java | 122 +++++++++++++++++ .../service/PushPayloadFactory.java | 78 +++++++++++ .../service/PushTargetResolver.java | 95 +++++++++++++ 18 files changed, 786 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java new file mode 100644 index 00000000..9232a321 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.notification.dto.payload; + +import java.util.Map; + +public record PushPayload( + String title, + String body, + PushPayloadData data +) { + + public record PushPayloadData( + int schemaVersion, + String actionType, + Map actionParams + ) { + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java new file mode 100644 index 00000000..26e384f6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.NotificationType; +import devkor.com.teamcback.domain.notification.entity.PushActionType; +import devkor.com.teamcback.domain.notification.entity.PushMode; +import devkor.com.teamcback.domain.notification.entity.PushTargetType; +import java.util.Map; + +public record PushDispatchCommand( + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + Map actionParams, + String idempotencyKey, + Long createdBy +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java new file mode 100644 index 00000000..f17d96af --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushDispatchStatus; +import lombok.Getter; + +@Getter +public class PushDispatchEnqueueRes { + + private final Long dispatchId; + private final int recipientCount; + private final PushDispatchStatus status; + private final String idempotencyKey; + + public PushDispatchEnqueueRes(PushDispatch dispatch) { + this.dispatchId = dispatch.getPushDispatchId(); + this.recipientCount = dispatch.getRecipientCount(); + this.status = dispatch.getStatus(); + this.idempotencyKey = dispatch.getIdempotencyKey(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java new file mode 100644 index 00000000..867b0d98 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum NotificationType { + GENERAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java new file mode 100644 index 00000000..33ccbed1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum PushActionType { + HOME, + NOTICE, + MY_PAGE, + BUS_STOP, + BUILDING_DETAIL, + PLACE_DETAIL, + TEST +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java new file mode 100644 index 00000000..64c2d3cc --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java @@ -0,0 +1,127 @@ +package devkor.com.teamcback.domain.notification.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_push_dispatch", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_push_dispatch_idempotency_key", + columnNames = "idempotency_key" + ) + }, + indexes = { + @Index( + name = "idx_push_dispatch_status_created_at", + columnList = "status, created_at" + ) + } +) +@NoArgsConstructor +@Getter +public class PushDispatch { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_dispatch_id") + private Long pushDispatchId; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 40) + private NotificationType notificationType; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 20) + private PushMode mode; + + @Enumerated(EnumType.STRING) + @Column(name = "app_variant", nullable = false, length = 20) + private AppVariant appVariant; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 30) + private PushTargetType targetType; + + @Column(name = "target_value", nullable = false, length = 128) + private String targetValue; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "body", nullable = false, length = 1024) + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false, length = 40) + private PushActionType actionType; + + @Column(name = "action_params", nullable = false, length = 2048) + private String actionParams; + + @Column(name = "recipient_count", nullable = false) + private int recipientCount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private PushDispatchStatus status; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @Column(name = "created_by", nullable = false) + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + public PushDispatch( + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + String actionParams, + String idempotencyKey, + Long createdBy, + LocalDateTime createdAt + ) { + this.notificationType = notificationType; + this.mode = mode; + this.appVariant = appVariant; + this.targetType = targetType; + this.targetValue = targetValue; + this.title = title; + this.body = body; + this.actionType = actionType; + this.actionParams = actionParams; + this.recipientCount = 0; + this.status = PushDispatchStatus.QUEUED; + this.idempotencyKey = idempotencyKey; + this.createdBy = createdBy; + this.createdAt = createdAt; + this.completedAt = null; + } + + public void updateRecipientCount(int recipientCount) { + this.recipientCount = recipientCount; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java new file mode 100644 index 00000000..3095f3a2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum PushDispatchStatus { + QUEUED, + PROCESSING, + COMPLETED, + PARTIAL_FAILED, + FAILED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java new file mode 100644 index 00000000..31d869ab --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -0,0 +1,114 @@ +package devkor.com.teamcback.domain.notification.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_push_message", + indexes = { + @Index( + name = "idx_push_message_dispatch", + columnList = "push_dispatch_id" + ), + @Index( + name = "idx_push_message_status_next_retry_at", + columnList = "status, next_retry_at" + ) + } +) +@NoArgsConstructor +@Getter +public class PushMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_message_id") + private Long pushMessageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "push_dispatch_id", nullable = false) + private PushDispatch dispatch; + + @Column(name = "push_installation_id", nullable = false) + private Long pushInstallationId; + + @Column(name = "installation_id", nullable = false, length = 64) + private String installationId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private PushMessageStatus status; + + @Column(name = "expo_ticket_id", length = 255) + private String expoTicketId; + + @Column(name = "ticket_status", length = 40) + private String ticketStatus; + + @Column(name = "ticket_error", length = 1024) + private String ticketError; + + @Column(name = "receipt_status", length = 40) + private String receiptStatus; + + @Column(name = "receipt_error", length = 1024) + private String receiptError; + + @Column(name = "send_attempts", nullable = false) + private int sendAttempts; + + @Column(name = "receipt_attempts", nullable = false) + private int receiptAttempts; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "receipt_checked_at") + private LocalDateTime receiptCheckedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public PushMessage( + PushDispatch dispatch, + PushInstallation installation, + LocalDateTime now + ) { + this.dispatch = dispatch; + this.pushInstallationId = installation.getPushInstallationId(); + this.installationId = installation.getInstallationId(); + this.status = PushMessageStatus.QUEUED; + this.expoTicketId = null; + this.ticketStatus = null; + this.ticketError = null; + this.receiptStatus = null; + this.receiptError = null; + this.sendAttempts = 0; + this.receiptAttempts = 0; + this.nextRetryAt = null; + this.sentAt = null; + this.receiptCheckedAt = null; + this.createdAt = now; + this.updatedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java new file mode 100644 index 00000000..30761762 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum PushMessageStatus { + QUEUED, + SENDING, + TICKET_RECEIVED, + RECEIPT_PENDING, + DELIVERED, + FAILED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java new file mode 100644 index 00000000..65874c3c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum PushMode { + TEST, + ACTUAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java new file mode 100644 index 00000000..1e7c9cf1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.notification.entity; + +public enum PushTargetType { + INSTALLATION, + USER, + USER_GROUP +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java new file mode 100644 index 00000000..f818becb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PushDispatchRepository extends JpaRepository { + + Optional findByIdempotencyKey( + String idempotencyKey + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index e5c136f1..b78ca1be 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -1,5 +1,6 @@ package devkor.com.teamcback.domain.notification.repository; +import devkor.com.teamcback.domain.notification.entity.AppVariant; import devkor.com.teamcback.domain.notification.entity.PushInstallation; import org.springframework.data.jpa.repository.JpaRepository; @@ -24,4 +25,14 @@ Optional findByInstallationIdAndUserId( List findAllByUserIdAndActiveTrue( Long userId ); + + Optional findByInstallationIdAndAppVariantAndActiveTrue( + String installationId, + AppVariant appVariant + ); + + List findAllByUserIdAndAppVariantAndActiveTrue( + Long userId, + AppVariant appVariant + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java new file mode 100644 index 00000000..594350d3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PushMessageRepository extends JpaRepository { + + List findAllByDispatch( + PushDispatch dispatch + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java new file mode 100644 index 00000000..abef7dda --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java @@ -0,0 +1,105 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushActionType; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Component +public class PushActionValidator { + + private static final Set ACTIONS_WITHOUT_PARAMS = Set.of( + PushActionType.HOME, + PushActionType.NOTICE, + PushActionType.MY_PAGE, + PushActionType.TEST + ); + + public Map validateAndNormalize( + PushActionType actionType, + AppVariant appVariant, + Map params + ) { + if (actionType == null || appVariant == null) { + throw new GlobalException(INVALID_INPUT); + } + + Map safeParams = params == null ? Collections.emptyMap() : params; + + if (PushActionType.TEST.equals(actionType) + && AppVariant.PRODUCTION.equals(appVariant)) { + throw new GlobalException(INVALID_INPUT); + } + + if (ACTIONS_WITHOUT_PARAMS.contains(actionType)) { + validateNoParams(safeParams); + return Collections.emptyMap(); + } + + return switch (actionType) { + case BUS_STOP -> validateSinglePositiveLong(safeParams, "stopId"); + case BUILDING_DETAIL -> validateSinglePositiveLong(safeParams, "buildingId"); + case PLACE_DETAIL -> validateSinglePositiveLong(safeParams, "placeId"); + default -> throw new GlobalException(INVALID_INPUT); + }; + } + + private void validateNoParams(Map params) { + if (!params.isEmpty()) { + throw new GlobalException(INVALID_INPUT); + } + } + + private Map validateSinglePositiveLong( + Map params, + String requiredKey + ) { + if (params.size() != 1 || !params.containsKey(requiredKey)) { + throw new GlobalException(INVALID_INPUT); + } + + Long value = parsePositiveLong(params.get(requiredKey)); + + Map normalized = new LinkedHashMap<>(); + normalized.put(requiredKey, value); + return normalized; + } + + private Long parsePositiveLong(Object value) { + Long parsed; + + if (value instanceof Integer integerValue) { + parsed = integerValue.longValue(); + } else if (value instanceof Long longValue) { + parsed = longValue; + } else if (value instanceof String stringValue) { + parsed = parseString(stringValue); + } else { + throw new GlobalException(INVALID_INPUT); + } + + if (parsed <= 0) { + throw new GlobalException(INVALID_INPUT); + } + + return parsed; + } + + private Long parseString(String value) { + if (value == null || value.isBlank()) { + throw new GlobalException(INVALID_INPUT); + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java new file mode 100644 index 00000000..5b2e0768 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java @@ -0,0 +1,122 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PushDispatchService { + + private static final int MAX_TITLE_LENGTH = 200; + private static final int MAX_BODY_LENGTH = 1024; + private static final int MAX_TARGET_VALUE_LENGTH = 128; + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; + + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushPayloadFactory pushPayloadFactory; + private final PushTargetResolver pushTargetResolver; + private final Clock clock; + + @Transactional + public PushDispatchEnqueueRes enqueue(PushDispatchCommand command) { + validateCommand(command); + + PushPayload payload = pushPayloadFactory.create( + command.title(), + command.body(), + command.actionType(), + command.actionParams(), + command.appVariant() + ); + + return pushDispatchRepository.findByIdempotencyKey(command.idempotencyKey()) + .map(PushDispatchEnqueueRes::new) + .orElseGet(() -> createDispatch(command, payload)); + } + + private PushDispatchEnqueueRes createDispatch( + PushDispatchCommand command, + PushPayload payload + ) { + List installations = pushTargetResolver.resolve( + command.targetType(), + command.targetValue(), + command.appVariant() + ); + + LocalDateTime now = LocalDateTime.now(clock); + + PushDispatch dispatch = pushDispatchRepository.save( + new PushDispatch( + command.notificationType(), + command.mode(), + command.appVariant(), + command.targetType(), + command.targetValue(), + command.title(), + command.body(), + command.actionType(), + pushPayloadFactory.serializeActionParams(payload.data().actionParams()), + command.idempotencyKey(), + command.createdBy(), + now + ) + ); + + List messages = installations.stream() + .map(installation -> new PushMessage( + dispatch, + installation, + now + )) + .toList(); + + pushMessageRepository.saveAll(messages); + dispatch.updateRecipientCount(messages.size()); + + return new PushDispatchEnqueueRes(dispatch); + } + + private void validateCommand(PushDispatchCommand command) { + if (command == null + || command.notificationType() == null + || command.mode() == null + || command.appVariant() == null + || command.targetType() == null + || command.actionType() == null + || command.createdBy() == null) { + throw new GlobalException(INVALID_INPUT); + } + + validateText(command.targetValue(), MAX_TARGET_VALUE_LENGTH); + validateText(command.title(), MAX_TITLE_LENGTH); + validateText(command.body(), MAX_BODY_LENGTH); + validateText(command.idempotencyKey(), MAX_IDEMPOTENCY_KEY_LENGTH); + } + + private void validateText( + String value, + int maxLength + ) { + if (value == null || value.isBlank() || value.length() > maxLength) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java new file mode 100644 index 00000000..5ad69b8e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java @@ -0,0 +1,78 @@ +package devkor.com.teamcback.domain.notification.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushActionType; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Component +@RequiredArgsConstructor +public class PushPayloadFactory { + + private static final int SCHEMA_VERSION = 1; + private static final int MAX_PAYLOAD_BYTES = 4096; + + private final PushActionValidator actionValidator; + private final ObjectMapper objectMapper; + + public PushPayload create( + String title, + String body, + PushActionType actionType, + Map actionParams, + AppVariant appVariant + ) { + validateText(title); + validateText(body); + + Map normalizedActionParams = actionValidator.validateAndNormalize( + actionType, + appVariant, + actionParams + ); + + PushPayload payload = new PushPayload( + title, + body, + new PushPayload.PushPayloadData( + SCHEMA_VERSION, + actionType.name(), + normalizedActionParams + ) + ); + + validatePayloadSize(payload); + return payload; + } + + public String serializeActionParams(Map actionParams) { + try { + return objectMapper.writeValueAsString(actionParams); + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateText(String value) { + if (value == null || value.isBlank()) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validatePayloadSize(PushPayload payload) { + try { + if (objectMapper.writeValueAsBytes(payload).length > MAX_PAYLOAD_BYTES) { + throw new GlobalException(INVALID_INPUT); + } + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java new file mode 100644 index 00000000..b9acb2d5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java @@ -0,0 +1,95 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; + +@Component +@RequiredArgsConstructor +public class PushTargetResolver { + + private final PushInstallationRepository pushInstallationRepository; + + public List resolve( + PushTargetType targetType, + String targetValue, + AppVariant appVariant + ) { + if (targetType == null || targetValue == null || targetValue.isBlank() || appVariant == null) { + throw new GlobalException(INVALID_INPUT); + } + + List installations = switch (targetType) { + case INSTALLATION -> resolveInstallation(targetValue, appVariant); + case USER -> resolveUser(targetValue, appVariant); + case USER_GROUP -> throw new GlobalException(UNSUPPORTED_REQUEST); + }; + + List distinctInstallations = distinctByInstallation(installations); + + if (distinctInstallations.isEmpty()) { + throw new GlobalException(INVALID_INPUT); + } + + return distinctInstallations; + } + + private List resolveInstallation( + String installationId, + AppVariant appVariant + ) { + return pushInstallationRepository.findByInstallationIdAndAppVariantAndActiveTrue( + installationId, + appVariant + ) + .map(List::of) + .orElseGet(List::of); + } + + private List resolveUser( + String targetValue, + AppVariant appVariant + ) { + Long userId = parsePositiveLong(targetValue); + + return pushInstallationRepository.findAllByUserIdAndAppVariantAndActiveTrue( + userId, + appVariant + ); + } + + private List distinctByInstallation(List installations) { + Map distinct = new LinkedHashMap<>(); + + installations.forEach(installation -> + distinct.putIfAbsent( + installation.getInstallationId(), + installation + ) + ); + + return distinct.values().stream().toList(); + } + + private Long parsePositiveLong(String value) { + try { + Long parsed = Long.parseLong(value); + if (parsed <= 0) { + throw new GlobalException(INVALID_INPUT); + } + return parsed; + } catch (NumberFormatException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} From 676e6d8c99564c0176f821b5a0499a3485f2ea00 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 16:17:12 +0900 Subject: [PATCH 07/54] refactor: organize push domain entities and types --- .../notification/dto/request/PushDispatchCommand.java | 10 +++++----- .../dto/request/PushInstallationRegisterReq.java | 2 +- .../dto/response/PushDispatchEnqueueRes.java | 2 +- .../domain/notification/entity/NotificationType.java | 5 ----- .../domain/notification/entity/PushDispatch.java | 1 + .../domain/notification/entity/PushInstallation.java | 1 + .../domain/notification/entity/PushMessage.java | 1 + .../teamcback/domain/notification/entity/PushMode.java | 6 ------ .../notification/entity/{ => type}/AppVariant.java | 2 +- .../notification/entity/type/NotificationType.java | 5 +++++ .../notification/entity/{ => type}/PushActionType.java | 2 +- .../entity/{ => type}/PushDispatchStatus.java | 2 +- .../entity/{ => type}/PushMessageStatus.java | 2 +- .../domain/notification/entity/type/PushMode.java | 6 ++++++ .../notification/entity/{ => type}/PushTargetType.java | 2 +- .../repository/PushInstallationRepository.java | 2 +- .../notification/service/PushActionValidator.java | 4 ++-- .../notification/service/PushInstallationService.java | 2 +- .../notification/service/PushPayloadFactory.java | 4 ++-- .../notification/service/PushTargetResolver.java | 4 ++-- 20 files changed, 34 insertions(+), 31 deletions(-) delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java rename src/main/java/devkor/com/teamcback/domain/notification/entity/{ => type}/AppVariant.java (90%) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java rename src/main/java/devkor/com/teamcback/domain/notification/entity/{ => type}/PushActionType.java (67%) rename src/main/java/devkor/com/teamcback/domain/notification/entity/{ => type}/PushDispatchStatus.java (63%) rename src/main/java/devkor/com/teamcback/domain/notification/entity/{ => type}/PushMessageStatus.java (67%) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java rename src/main/java/devkor/com/teamcback/domain/notification/entity/{ => type}/PushTargetType.java (54%) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java index 26e384f6..3eab2f55 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java @@ -1,10 +1,10 @@ package devkor.com.teamcback.domain.notification.dto.request; -import devkor.com.teamcback.domain.notification.entity.AppVariant; -import devkor.com.teamcback.domain.notification.entity.NotificationType; -import devkor.com.teamcback.domain.notification.entity.PushActionType; -import devkor.com.teamcback.domain.notification.entity.PushMode; -import devkor.com.teamcback.domain.notification.entity.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import java.util.Map; public record PushDispatchCommand( diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java index 3ef82f08..cafce71d 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java @@ -1,6 +1,6 @@ package devkor.com.teamcback.domain.notification.dto.request; -import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java index f17d96af..37923008 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java @@ -1,7 +1,7 @@ package devkor.com.teamcback.domain.notification.dto.response; import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; import lombok.Getter; @Getter diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java deleted file mode 100644 index 867b0d98..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/NotificationType.java +++ /dev/null @@ -1,5 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -public enum NotificationType { - GENERAL -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java index 64c2d3cc..6c8da70a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java @@ -1,5 +1,6 @@ package devkor.com.teamcback.domain.notification.entity; +import devkor.com.teamcback.domain.notification.entity.type.*; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java index 1115485c..83d751ff 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.notification.entity; import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java index 31d869ab..df6c5331 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -1,5 +1,6 @@ package devkor.com.teamcback.domain.notification.entity; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java deleted file mode 100644 index 65874c3c..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -public enum PushMode { - TEST, - ACTUAL -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java similarity index 90% rename from src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java rename to src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java index a191b9f5..d228d6f3 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/AppVariant.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.entity; +package devkor.com.teamcback.domain.notification.entity.type; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java new file mode 100644 index 00000000..8ea93bef --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum NotificationType { + GENERAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java similarity index 67% rename from src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java rename to src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java index 33ccbed1..c24f13e5 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushActionType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.entity; +package devkor.com.teamcback.domain.notification.entity.type; public enum PushActionType { HOME, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java similarity index 63% rename from src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java rename to src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java index 3095f3a2..91110c5e 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatchStatus.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.entity; +package devkor.com.teamcback.domain.notification.entity.type; public enum PushDispatchStatus { QUEUED, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java similarity index 67% rename from src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java rename to src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java index 30761762..7e750938 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessageStatus.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.entity; +package devkor.com.teamcback.domain.notification.entity.type; public enum PushMessageStatus { QUEUED, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java new file mode 100644 index 00000000..7068edf0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushMode { + TEST, + ACTUAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java similarity index 54% rename from src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java rename to src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java index 1e7c9cf1..01c41341 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushTargetType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.entity; +package devkor.com.teamcback.domain.notification.entity.type; public enum PushTargetType { INSTALLATION, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index b78ca1be..319e7210 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -1,6 +1,6 @@ package devkor.com.teamcback.domain.notification.repository; -import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.PushInstallation; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java index abef7dda..89ece3e8 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java @@ -1,7 +1,7 @@ package devkor.com.teamcback.domain.notification.service; -import devkor.com.teamcback.domain.notification.entity.AppVariant; -import devkor.com.teamcback.domain.notification.entity.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Collections; import java.util.LinkedHashMap; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java index dfa35f2a..8a37340a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java @@ -1,6 +1,6 @@ package devkor.com.teamcback.domain.notification.service; -import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.PushInstallation; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java index 5ad69b8e..845b2986 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.entity.AppVariant; -import devkor.com.teamcback.domain.notification.entity.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Map; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java index b9acb2d5..9a379570 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java @@ -1,8 +1,8 @@ package devkor.com.teamcback.domain.notification.service; -import devkor.com.teamcback.domain.notification.entity.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.LinkedHashMap; From 032520446536b396da8a1428b963b17be4fdd4d1 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 16:27:40 +0900 Subject: [PATCH 08/54] refactor: organize push dispatch components by responsibility --- .../notification/{service => factory}/PushPayloadFactory.java | 3 ++- .../notification/{service => resolver}/PushTargetResolver.java | 2 +- .../domain/notification/service/PushDispatchService.java | 2 ++ .../{service => validation}/PushActionValidator.java | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) rename src/main/java/devkor/com/teamcback/domain/notification/{service => factory}/PushPayloadFactory.java (94%) rename src/main/java/devkor/com/teamcback/domain/notification/{service => resolver}/PushTargetResolver.java (98%) rename src/main/java/devkor/com/teamcback/domain/notification/{service => validation}/PushActionValidator.java (98%) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java similarity index 94% rename from src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java rename to src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java index 845b2986..55a41e3d 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushPayloadFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java @@ -1,10 +1,11 @@ -package devkor.com.teamcback.domain.notification.service; +package devkor.com.teamcback.domain.notification.factory; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.validation.PushActionValidator; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Map; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java similarity index 98% rename from src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java rename to src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java index 9a379570..b0acba42 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushTargetResolver.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.service; +package devkor.com.teamcback.domain.notification.resolver; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.PushInstallation; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java index 5b2e0768..ef9fa46a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java @@ -6,8 +6,10 @@ import devkor.com.teamcback.domain.notification.entity.PushDispatch; import devkor.com.teamcback.domain.notification.entity.PushInstallation; import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.time.Clock; import java.time.LocalDateTime; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java similarity index 98% rename from src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java rename to src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java index 89ece3e8..dd57bc0d 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushActionValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.notification.service; +package devkor.com.teamcback.domain.notification.validation; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; From d7b8a20edcf234a499e6f354e4bd555d0a728b77 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 17:51:25 +0900 Subject: [PATCH 09/54] fix: align push payload contract and TEST action validation --- .../notification/dto/payload/PushPayload.java | 12 ++++-- .../factory/PushPayloadFactory.java | 41 ++++++++++++++++--- .../service/PushDispatchService.java | 9 ++-- .../validation/PushActionValidator.java | 10 +++-- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java index 9232a321..deeac8a8 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java @@ -9,9 +9,15 @@ public record PushPayload( ) { public record PushPayloadData( - int schemaVersion, - String actionType, - Map actionParams + int version, + String notificationId, + PushPayloadAction action + ) { + } + + public record PushPayloadAction( + String type, + Map params ) { } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java index 55a41e3d..37717bff 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java @@ -5,6 +5,7 @@ import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.domain.notification.validation.PushActionValidator; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Map; @@ -17,24 +18,29 @@ @RequiredArgsConstructor public class PushPayloadFactory { - private static final int SCHEMA_VERSION = 1; + private static final int PAYLOAD_VERSION = 1; private static final int MAX_PAYLOAD_BYTES = 4096; + private static final String SIZE_VALIDATION_NOTIFICATION_ID = "00000000-0000-4000-8000-000000000000"; private final PushActionValidator actionValidator; private final ObjectMapper objectMapper; public PushPayload create( + String notificationId, String title, String body, + PushMode mode, + AppVariant appVariant, PushActionType actionType, - Map actionParams, - AppVariant appVariant + Map actionParams ) { + validateText(notificationId); validateText(title); validateText(body); Map normalizedActionParams = actionValidator.validateAndNormalize( actionType, + mode, appVariant, actionParams ); @@ -43,9 +49,12 @@ public PushPayload create( title, body, new PushPayload.PushPayloadData( - SCHEMA_VERSION, - actionType.name(), - normalizedActionParams + PAYLOAD_VERSION, + notificationId, + new PushPayload.PushPayloadAction( + actionType.name(), + normalizedActionParams + ) ) ); @@ -53,6 +62,26 @@ public PushPayload create( return payload; } + public PushPayload createForPreDispatchValidation( + String title, + String body, + PushMode mode, + AppVariant appVariant, + PushActionType actionType, + Map actionParams + ) { + // PushMessage uses an identity Long, so the worker must create the final payload with the real message id and re-check size. + return create( + SIZE_VALIDATION_NOTIFICATION_ID, + title, + body, + mode, + appVariant, + actionType, + actionParams + ); + } + public String serializeActionParams(Map actionParams) { try { return objectMapper.writeValueAsString(actionParams); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java index ef9fa46a..e0127478 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java @@ -40,12 +40,13 @@ public class PushDispatchService { public PushDispatchEnqueueRes enqueue(PushDispatchCommand command) { validateCommand(command); - PushPayload payload = pushPayloadFactory.create( + PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( command.title(), command.body(), + command.mode(), + command.appVariant(), command.actionType(), - command.actionParams(), - command.appVariant() + command.actionParams() ); return pushDispatchRepository.findByIdempotencyKey(command.idempotencyKey()) @@ -75,7 +76,7 @@ private PushDispatchEnqueueRes createDispatch( command.title(), command.body(), command.actionType(), - pushPayloadFactory.serializeActionParams(payload.data().actionParams()), + pushPayloadFactory.serializeActionParams(payload.data().action().params()), command.idempotencyKey(), command.createdBy(), now diff --git a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java index dd57bc0d..2e22c2bd 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java @@ -2,6 +2,7 @@ import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Collections; import java.util.LinkedHashMap; @@ -23,17 +24,20 @@ public class PushActionValidator { public Map validateAndNormalize( PushActionType actionType, + PushMode mode, AppVariant appVariant, Map params ) { - if (actionType == null || appVariant == null) { + if (actionType == null || mode == null || appVariant == null) { throw new GlobalException(INVALID_INPUT); } Map safeParams = params == null ? Collections.emptyMap() : params; - if (PushActionType.TEST.equals(actionType) - && AppVariant.PRODUCTION.equals(appVariant)) { + if (PushActionType.TEST.equals(actionType) && ( + AppVariant.PRODUCTION.equals(appVariant) + || PushMode.ACTUAL.equals(mode) + )) { throw new GlobalException(INVALID_INPUT); } From e5f2ed6d93bd02cc1f0cbc1b3f600ab0c25188eb Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 2 Aug 2026 18:17:22 +0900 Subject: [PATCH 10/54] =?UTF-8?q?chore:=20=EB=A1=9C=EC=BB=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 9b084d21..4055adb4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ out/ ###claude### .claude .serena + +# Local-only config +/src/main/resources/application-local.yml +/.env.local +/docker-compose.local.yml \ No newline at end of file From ea185942b1e216282c51306e651d9cde23c72be1 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:57:34 +0900 Subject: [PATCH 11/54] =?UTF-8?q?Feat:=20=EC=9C=A0=EC=A0=80=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EB=A0=88=EB=B2=A8=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamcback/domain/user/entity/Level.java | 30 +++- .../teamcback/domain/user/entity/User.java | 15 ++ .../domain/user/service/UserService.java | 17 +-- .../global/aop/UpdateScoreAspect.java | 21 +-- .../global/aop/UpdateScoreAspectTest.java | 142 ------------------ 5 files changed, 49 insertions(+), 176 deletions(-) delete mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java index 996f80cf..8fa2d4d5 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java @@ -6,31 +6,31 @@ @Getter @AllArgsConstructor public enum Level { - LEVEL1(0, 1, ProfileImage.getUrlByLevel(1)) { // 0~4점 + LEVEL1(0, 1) { // 0~4점 @Override public Level getNextLevel() { return LEVEL2; } }, - LEVEL2(5, 2, ProfileImage.getUrlByLevel(2)) { // 5~19점 + LEVEL2(5, 2) { // 5~19점 @Override public Level getNextLevel() { return LEVEL3; } }, - LEVEL3(20, 3, ProfileImage.getUrlByLevel(3)) { //20~39점 + LEVEL3(20, 3) { //20~39점 @Override public Level getNextLevel() { return LEVEL4; } }, - LEVEL4(40, 4, ProfileImage.getUrlByLevel(4)) { //40~59점 + LEVEL4(40, 4) { //40~59점 @Override public Level getNextLevel() { return LEVEL5; } }, - LEVEL5(60, 5, ProfileImage.getUrlByLevel(5)) { //60점 이상 + LEVEL5(60, 5) { //60점 이상 @Override public Level getNextLevel() { return null; @@ -38,7 +38,23 @@ public Level getNextLevel() { }, ; private final int minScore; private final int levelNumber; - private final String profileImage; public abstract Level getNextLevel(); -} \ No newline at end of file + + // 엔티티 필드로 사용되는 enum이 Hibernate 메타데이터 빌드 시점에 초기화되므로, + // ProfileImage 빈 생성 전에 접근하지 않도록 이미지 URL은 호출 시점에 조회한다. + public String getProfileImage() { + return ProfileImage.getUrlByLevel(levelNumber); + } + + public static Level fromScore(long score) { + // score >= minScore 인 경우 중 가장 높은 레벨 반환 + Level result = LEVEL1; + for (Level level : values()) { + if (score >= level.getMinScore() && level.getMinScore() >= result.getMinScore()) { + result = level; + } + } + return result; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 52bb55ed..44950b89 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -12,6 +12,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Getter @@ -38,6 +41,13 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; + // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.VARCHAR) + @ColumnDefault("'LEVEL1'") + @Column(nullable = false) + private Level level = Level.LEVEL1; + @Column(nullable = false) private boolean isUpgraded = false; @@ -60,6 +70,7 @@ public void updateUsername(String username) { public void updateScore(Long score, boolean isUpgraded) { this.score = score; + this.level = Level.fromScore(score); // score와 level이 발산하지 않도록 단일 지점에서 갱신 this.isUpgraded = isUpgraded; } @@ -67,4 +78,8 @@ public void updateUpgraded(boolean isUpgraded) { this.isUpgraded = isUpgraded; } + public void syncLevel() { + this.level = Level.fromScore(this.score); + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 9c8eb49f..e0a447fb 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -37,9 +37,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Arrays; -import java.util.Comparator; - import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @@ -71,7 +68,11 @@ public class UserService { @Transactional public GetUserInfoRes getUserInfo(Long userId) { User user = findUser(userId); - Level level = getLevel(user.getScore()); + Level level = user.getLevel(); + if(level == null) { // 백필 전 레거시 행 방어 (백필 완료 후엔 도달하지 않음) + user.syncLevel(); + level = user.getLevel(); + } Level nextLevel = level.getNextLevel(); Long remainScoreToNextLevel = nextLevel == null ? 0 : nextLevel.getMinScore() - user.getScore(); int percent = 100; @@ -217,14 +218,6 @@ private void checkUsernameAvailability(User user, String username) { } } - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); - } - private User findUser(Long userId) { return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index c0ba2a22..a00d77aa 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -27,8 +27,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Comparator; import static devkor.com.teamcback.global.response.ResultCode.*; @@ -336,8 +334,8 @@ private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolea public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(user.getScore()); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; @@ -348,22 +346,15 @@ public void increaseScore(User user, int addScore) { * 점수 차이만큼 업데이트 (증가 또는 감소) */ private void updateScoreWithDiff(User user, int scoreDiff) { - long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 + long oldScore = user.getScore(); + long newScore = Math.max(0, oldScore + scoreDiff); // 최소 0점 // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(oldScore); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); } - - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); - } } diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java deleted file mode 100644 index fee85b3c..00000000 --- a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package devkor.com.teamcback.global.aop; - -import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; -import devkor.com.teamcback.domain.bookmark.service.BookmarkService; -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; -import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; -import devkor.com.teamcback.domain.suggestion.service.SuggestionService; -import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.service.UserService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import lombok.extern.slf4j.Slf4j; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import java.util.ArrayList; -import java.util.List; - -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; - -@Slf4j -@Disabled -@ExtendWith(SpringExtension.class) -@SpringBootTest -public class UpdateScoreAspectTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private SuggestionService suggestionService; - - @Autowired - private BookmarkService bookmarkService; - - @Test - public void suggestionScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - User user = findUser(userId); - CreateSuggestionReq req = new CreateSuggestionReq("Test제목", SuggestionType.INCONVENIENCE, "내용", "이메일"); - - // 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("건의함 작성 전 Score : " + beforeScore); - suggestionService.createSuggestion(userId, req, null); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("건의함 작성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+3).isEqualTo(afterScore); - - // 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - } - - @Test - public void bookmarkScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - List list = new ArrayList<>(); - - list.add(96L); // 카테고리 ID : 새 로그 검사용 - LocationType type = LocationType.PLACE; - long placeId = 66L; - String memo = "memo"; - - User user = findUser(userId); - CreateBookmarkReq req = new CreateBookmarkReq(list, type, placeId, memo); - - // 1. 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("북마크 생성 전 Score : " + beforeScore); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("북마크 생성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+1).isEqualTo(afterScore); - - // 2. 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - - // 3. 다른 카테고리에 동일 장소 북마크 추가 : 점수 증가X - log.info("북마크 재생성 전 Score : " + afterScore); - list.clear(); - list.add(99L); - req = new CreateBookmarkReq(list, type, placeId, memo); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long againScore = user.getScore(); - log.info("북마크 재생성 후 Score : " + againScore); - Assertions.assertThat(againScore).isEqualTo(afterScore); - - // 4. 동일 북마크 다시 넣었을 때 점수 증가 X - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long dupScore = user.getScore(); - log.info("중복 북마크 생성 후 Score (저장X) : " + dupScore); - Assertions.assertThat(dupScore).isEqualTo(againScore); - } - - private User findUser(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - } -} From 9cec8b7ca32e8c4ee4eb921c6a6d0f58dc881202 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:03 +0900 Subject: [PATCH 12/54] =?UTF-8?q?Feat:=20=EC=83=81=EC=A0=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/response/GetUserInfoRes.java | 3 + .../teamcback/domain/user/entity/User.java | 12 ++++ .../user/repository/UserRepository.java | 12 ++++ .../global/aop/UpdateScoreAspect.java | 3 + .../domain/user/entity/LevelTest.java | 63 +++++++++++++++++ .../global/aop/UpdateScoreLevelTest.java | 68 +++++++++++++++++++ 6 files changed, 161 insertions(+) create mode 100644 src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java create mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java index 2c7ea2d1..27bddead 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java @@ -21,6 +21,8 @@ public class GetUserInfoRes { private int level; @Schema(description = "score", example = "15") private Long score; + @Schema(description = "보유 포인트 (스토어 재화)", example = "15") + private Long point; @Schema(description = "remainScoreToNextLevel", example = "10") private Long remainScoreToNextLevel; @Schema(description = "percent", example = "75") @@ -38,6 +40,7 @@ public GetUserInfoRes(User user, Long categoryCount, int level, Long remainScore this.role = user.getRole(); this.level = level; this.score = user.getScore(); + this.point = user.getPoint(); this.remainScoreToNextLevel = remainScoreToNextLevel; this.percent = percent; this.isUpgraded = isUpgraded; diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 44950b89..41618832 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -41,6 +41,10 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; + // 스토어에서 사용하는 차감형 재화. score(레벨용 누적치)와 같은 양으로 적립되고 구매 시에만 차감된다. + // 컬럼을 nullable로 두는 이유: 기존 행은 NULL로 생성되고, NULL 여부가 "백필 전" 표식이 되어 백필이 영원히 멱등해진다. + private Long point = 0L; + // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.VARCHAR) @@ -82,4 +86,12 @@ public void syncLevel() { this.level = Level.fromScore(this.score); } + public Long getPoint() { // 백필 전 NULL 방어 + return point == null ? 0L : point; + } + + public void addPoint(long amount) { + this.point = Math.max(0, getPoint() + amount); + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 820a9bc0..14139621 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -3,6 +3,10 @@ import devkor.com.teamcback.domain.user.entity.Provider; import devkor.com.teamcback.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; public interface UserRepository extends JpaRepository { boolean existsByUsernameAndUserIdNot(String username, Long id); @@ -12,4 +16,12 @@ public interface UserRepository extends JpaRepository { User findByEmailAndProvider(String email, Provider provider); User findByUserId(long userId); + + /** + * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. + * @return 1이면 차감 성공, 0이면 잔액 부족 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update User u set u.point = u.point - :price where u.userId = :userId and u.point >= :price") + int deductPoint(@Param("userId") Long userId, @Param("price") int price); } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index a00d77aa..5e8c6071 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -340,6 +340,7 @@ public void increaseScore(User user, int addScore) { // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); + user.addPoint(addScore); // 스토어 재화는 score와 같은 양으로 적립 } /** @@ -356,5 +357,7 @@ private void updateScoreWithDiff(User user, int scoreDiff) { // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); + // 리뷰 삭제 후 재작성으로 포인트를 무한 적립하는 것을 막기 위해 차감도 동일하게 반영 (최소 0) + user.addPoint(newScore - oldScore); } } diff --git a/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java new file mode 100644 index 00000000..e164bfa9 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java @@ -0,0 +1,63 @@ +package devkor.com.teamcback.domain.user.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LevelTest { + + @DisplayName("점수 구간별 레벨 계산 (0/5/20/40/60)") + @ParameterizedTest + @CsvSource({ + "0, LEVEL1", "4, LEVEL1", + "5, LEVEL2", "19, LEVEL2", + "20, LEVEL3", "39, LEVEL3", + "40, LEVEL4", "59, LEVEL4", + "60, LEVEL5", "999, LEVEL5", + }) + void fromScore(long score, Level expected) { + assertEquals(expected, Level.fromScore(score)); + } + + @DisplayName("다음 레벨 체인") + @Test + void getNextLevel() { + assertEquals(Level.LEVEL2, Level.LEVEL1.getNextLevel()); + assertEquals(Level.LEVEL3, Level.LEVEL2.getNextLevel()); + assertEquals(Level.LEVEL4, Level.LEVEL3.getNextLevel()); + assertEquals(Level.LEVEL5, Level.LEVEL4.getNextLevel()); + assertNull(Level.LEVEL5.getNextLevel()); + } + + @DisplayName("updateScore 시 score와 level이 함께 갱신") + @Test + void updateScoreSyncsLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(Level.LEVEL1, user.getLevel()); + + user.updateScore(10L, true); + assertEquals(Level.LEVEL2, user.getLevel()); + assertEquals(10L, user.getScore()); + + // 리뷰 삭제 등으로 점수가 내려가면 레벨도 함께 하락 + user.updateScore(3L, true); + assertEquals(Level.LEVEL1, user.getLevel()); + } + + @DisplayName("포인트 적립/차감은 0 미만으로 내려가지 않음") + @Test + void addPointFloorsAtZero() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(0L, user.getPoint()); + + user.addPoint(10); + assertEquals(10L, user.getPoint()); + + user.addPoint(-15); // 리뷰 삭제 등으로 적립분보다 큰 차감이 와도 0에서 멈춤 + assertEquals(0L, user.getPoint()); + } +} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java new file mode 100644 index 00000000..710fe33f --- /dev/null +++ b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java @@ -0,0 +1,68 @@ +package devkor.com.teamcback.global.aop; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UpdateScoreLevelTest { + @InjectMocks + UpdateScoreAspect updateScoreAspect; + + @Mock + UserRepository userRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + VoteRecordRepository voteRecordRepository; + @Mock + ReviewRepository reviewRepository; + @Mock + PlaceRepository placeRepository; + @Mock + FileRepository fileRepository; + + @DisplayName("점수 적립 시 레벨업 판정과 level 컬럼 갱신 + 포인트 동일 적립") + @Test + void increaseScoreUpdatesLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + + // 건의 작성 +10점 → LEVEL1(0) → LEVEL2(5~) 레벨업, 포인트도 +10 + updateScoreAspect.increaseScore(user, 10); + assertEquals(10L, user.getScore()); + assertEquals(10L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertTrue(user.isUpgraded()); + + // 같은 레벨 내 적립은 레벨업 아님 + updateScoreAspect.increaseScore(user, 5); + assertEquals(15L, user.getScore()); + assertEquals(15L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertFalse(user.isUpgraded()); + + // 리뷰 최대 점수(+13)로 임계값을 건너뛰어도 레벨과 컬럼이 일치 + updateScoreAspect.increaseScore(user, 13); + assertEquals(28L, user.getScore()); + assertEquals(28L, user.getPoint()); + assertEquals(Level.LEVEL3, user.getLevel()); + assertTrue(user.isUpgraded()); + } +} From 6f80547070fa0fec39280c89a62e0f418f440b64 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:39 +0900 Subject: [PATCH 13/54] =?UTF-8?q?Feat:=20=EA=B8=B0=EC=A1=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EB=A0=88=EB=B2=A8/=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=84=B8=ED=8C=85=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserRepository.java | 25 +++++ .../scheduler/UserLevelBackfillRunner.java | 45 ++++++++ .../UserRepositoryBackfillTest.java | 91 ++++++++++++++++ src/test/resources/application-test.yml | 101 ++++++++++++++++++ src/test/resources/logback-spring.xml | 13 +++ 5 files changed, 275 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/logback-spring.xml diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 14139621..3db5c675 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -17,6 +17,31 @@ public interface UserRepository extends JpaRepository { User findByUserId(long userId); + /** + * score 기준으로 level 컬럼 백필 (레벨 구간: 0/5/20/40/60, Level enum과 동기화 유지) + * level이 null이거나 score와 불일치하는 행만 갱신하므로 멱등 + */ + @Transactional + @Modifying + @Query(value = """ + UPDATE tb_user SET level = + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + WHERE level IS NULL OR level <> + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + """, nativeQuery = true) + int backfillLevels(); + + /** + * point 컬럼 백필: 기존 사용자는 지금까지 적립한 score만큼 포인트를 보유한 것으로 초기화. + * NULL(백필 전 표식)인 행만 갱신하므로 재실행해도 이미 사용한 포인트가 복구되지 않는다 (멱등) + */ + @Transactional + @Modifying + @Query(value = "UPDATE tb_user SET point = score WHERE point IS NULL", nativeQuery = true) + int backfillPoints(); + /** * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. * @return 1이면 차감 성공, 0이면 잔액 부족 diff --git a/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java new file mode 100644 index 00000000..183e0ded --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.user.scheduler; + +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.redis.RedisLockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * level/point 컬럼 도입에 따른 기존 사용자 백필. + * - level: score와 불일치하는 행만 교정 (정상 상태에서는 0건 갱신) + * - point: NULL인 행만 score 값으로 초기화 (사용 후 0이 된 잔액은 다시 채워지지 않음) + * 매 부팅 시 실행되지만 두 쿼리 모두 멱등. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserLevelBackfillRunner { + private final UserRepository userRepository; + private final RedisLockUtil redisLockUtil; + + @Value("${metrics.environment}") + private String env; + + // 트랜잭션 경계는 UserRepository.backfillLevels()에 둔다. 리스너 메서드를 @Transactional로 감싸면 + // UPDATE 실패를 catch해도 rollback-only 커밋 예외가 리스너 밖으로 나가 부팅이 실패한다. + @EventListener(ApplicationReadyEvent.class) + public void backfillUserLevels() { + if(env.equals("test")) return; // H2 테스트 환경은 create-drop이라 백필 불필요 + Redis 미기동 + + try { + redisLockUtil.executeWithLock("user_level_backfill_lock", 1, 300, () -> { + int levelUpdated = userRepository.backfillLevels(); + int pointUpdated = userRepository.backfillPoints(); + log.info("사용자 백필 완료: level {}건, point {}건 갱신", levelUpdated, pointUpdated); + return null; + }); + } catch (Exception e) { + log.error("backfillUserLevels() 작업 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java new file mode 100644 index 00000000..f2a5feb7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java @@ -0,0 +1,91 @@ +package devkor.com.teamcback.domain.user.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.EntityManager; +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.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserRepositoryBackfillTest { + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager em; + + @DisplayName("score와 불일치하는 level만 백필하고, 재실행 시 0건 (멱등)") + @Test + void backfillLevels() { + User staleUser = userRepository.save(new User("stale", "stale@test.com", Role.USER, Provider.KAKAO)); + staleUser.updateScore(60L, false); // level = LEVEL5 + User freshUser = userRepository.save(new User("fresh", "fresh@test.com", Role.USER, Provider.KAKAO)); + freshUser.updateScore(10L, false); // level = LEVEL2 + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(레벨 불일치)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET level = 'LEVEL1' WHERE username = 'stale'").executeUpdate(); + em.clear(); + + int updated = userRepository.backfillLevels(); + em.clear(); + + assertEquals(1, updated); + assertEquals(Level.LEVEL5, userRepository.findByUserId(staleUser.getUserId()).getLevel()); + assertEquals(Level.LEVEL2, userRepository.findByUserId(freshUser.getUserId()).getLevel()); + + // 두 번째 실행은 아무것도 갱신하지 않음 + assertEquals(0, userRepository.backfillLevels()); + } + + @DisplayName("point가 NULL인 레거시 행만 score 값으로 백필 (사용 후 0이 된 잔액은 재적립 안 됨)") + @Test + void backfillPoints() { + User legacyUser = userRepository.save(new User("legacy", "legacy@test.com", Role.USER, Provider.KAKAO)); + legacyUser.updateScore(30L, false); + User spentUser = userRepository.save(new User("spent", "spent@test.com", Role.USER, Provider.KAKAO)); + spentUser.updateScore(30L, false); // point는 자바 초기값 0 (이미 초기화된 사용자로 간주) + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(point NULL)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET point = NULL WHERE username = 'legacy'").executeUpdate(); + em.clear(); + + assertEquals(1, userRepository.backfillPoints()); + em.clear(); + + assertEquals(30L, userRepository.findByUserId(legacyUser.getUserId()).getPoint()); + assertEquals(0L, userRepository.findByUserId(spentUser.getUserId()).getPoint()); // NULL 아니면 미변경 + + // 두 번째 실행은 아무것도 갱신하지 않음 (멱등) + assertEquals(0, userRepository.backfillPoints()); + } + + @DisplayName("포인트 차감: 잔액이 충분할 때만 원자적으로 차감") + @Test + void deductPoint() { + User user = userRepository.save(new User("buyer", "buyer@test.com", Role.USER, Provider.KAKAO)); + user.addPoint(10); + em.flush(); + + assertEquals(1, userRepository.deductPoint(user.getUserId(), 7)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + + // 잔액(3)보다 큰 금액은 차감 실패 + assertEquals(0, userRepository.deductPoint(user.getUserId(), 4)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..ed69394e --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,101 @@ +spring: + cache: + type: none + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + show-sql: true + properties: + hibernate: + format_sql: true + defer-datasource-initialization: false + sql: + init: + mode: never + + data: + redis: + host: localhost + port: 6379 + password: + + mail: + host: localhost + port: 25 + username: test + password: test + +jwt: + secret: + key: test-jwt-secret-key-for-testing-purposes-only-must-be-at-least-256-bits + social: + kakao: + iss: test + aud: test + google: + iss: test + aud: test + apple: + iss: test + aud: test + dev-aud: test + admin: + token: test-admin-token + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +profile: + image: + lv1-url: http://localhost/profile/lv1.jpg + lv2-url: http://localhost/profile/lv2.jpg + lv3-url: http://localhost/profile/lv3.jpg + lv4-url: http://localhost/profile/lv4.jpg + lv5-url: http://localhost/profile/lv5.jpg + +place: + default-image: + cafe: http://localhost/place/CAFE.jpg + cafeteria: http://localhost/place/CAFETERIA.jpg + convenience-store: http://localhost/place/CONVENIENCE_STORE.jpg + gym: http://localhost/place/GYM.jpg + lounge: http://localhost/place/LOUNGE.jpg + reading-room: http://localhost/place/READING_ROOM.jpg + shower-room: http://localhost/place/SHOWER_ROOM.jpg + sleeping-room: http://localhost/place/SLEEPING_ROOM.jpg + study-room: http://localhost/place/STUDY_ROOM.jpg + +date: + api: + holiday: + end-point: http://localhost/api/holiday + encoded-key: test + decoded-key: test + +metrics: + environment: test + +management: + endpoints: + web: + exposure: + include: health + +staff: + emails: test@test.com diff --git a/src/test/resources/logback-spring.xml b/src/test/resources/logback-spring.xml new file mode 100644 index 00000000..8e82bcbe --- /dev/null +++ b/src/test/resources/logback-spring.xml @@ -0,0 +1,13 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + From b3f983872d03ebe787138a5833e6672e131d9d82 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:48 +0900 Subject: [PATCH 14/54] =?UTF-8?q?Feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/CreateCharacterReq.java | 35 ++++++++ .../dto/request/ModifyCharacterReq.java | 35 ++++++++ .../domain/character/entity/KoCharacter.java | 79 +++++++++++++++++ .../character/entity/PurchaseStatus.java | 15 ++++ .../character/entity/UserCharacter.java | 40 +++++++++ .../repository/CharacterRepository.java | 16 ++++ .../repository/UserCharacterRepository.java | 19 ++++ .../teamcback/global/response/ResultCode.java | 11 ++- .../com/teamcback/infra/s3/FilePath.java | 3 +- .../repository/CharacterRepositoryTest.java | 65 ++++++++++++++ .../UserCharacterRepositoryTest.java | 86 +++++++++++++++++++ 11 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java new file mode 100644 index 00000000..2b4f2e27 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "저장할 캐릭터 정보") +@Getter +@Setter +public class CreateCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java new file mode 100644 index 00000000..9b74723c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "수정할 캐릭터 정보") +@Getter +@Setter +public class ModifyCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지 (미첨부 시 기존 이미지 유지)") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java new file mode 100644 index 00000000..e89a95f7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +// java.lang.Character와의 충돌을 피하기 위해 KoCharacter로 명명 (koyeon의 Ko- 접두어 선례) +@Entity +@Getter +@Table(name = "tb_character") +@NoArgsConstructor +public class KoCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long characterId; + + @Column(nullable = false, unique = true) + private String name; + + @Column(length = 500) + private String description; + + @Column(length = 200) + private String quote; // 캐릭터 클릭 시 보여줄 대사 + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private Integer price; // 구매 가격 (포인트) + + // 해금 레벨: 사용자 레벨이 이 값 이상이어야 구매 가능 (1이면 제한 없음) + @ColumnDefault("1") + @Column(nullable = false) + private Integer requiredLevel = 1; + + @Column(nullable = false) + private Integer displayOrder = 0; + + @Column(nullable = false) + private boolean isActive = true; + + public KoCharacter(String name, String description, String quote, String imageUrl, + Integer price, Integer requiredLevel, Integer displayOrder, boolean isActive) { + this.name = name; + this.description = description; + this.quote = quote; + this.imageUrl = imageUrl; + this.price = price; + this.requiredLevel = requiredLevel; + this.displayOrder = displayOrder; + this.isActive = isActive; + } + + public KoCharacter(CreateCharacterReq req, String imageUrl) { + this(req.getName(), req.getDescription(), req.getQuote(), imageUrl, req.getPrice(), + req.getRequiredLevel(), req.getDisplayOrder(), req.isActive()); + } + + public void update(ModifyCharacterReq req, String imageUrl) { + this.name = req.getName(); + this.description = req.getDescription(); + this.quote = req.getQuote(); + this.imageUrl = imageUrl; + this.price = req.getPrice(); + this.requiredLevel = req.getRequiredLevel(); + this.displayOrder = req.getDisplayOrder(); + this.isActive = req.isActive(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java new file mode 100644 index 00000000..8e2d7bd1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PurchaseStatus { + OWNED("보유"), + PURCHASABLE("구매 가능"), + LOCKED("미해금"), // 해금 레벨 미달 (포인트와 무관하게 구매 불가) + NOT_ENOUGH_POINT("포인트 부족"); + + private final String name; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java new file mode 100644 index 00000000..47f153b2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java @@ -0,0 +1,40 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// createdAt(BaseEntity)이 획득일. UNIQUE(user_id, character_id)가 동시 중복 해금의 최종 방어선 +@Entity +@Getter +@Table(name = "tb_user_character", + uniqueConstraints = @UniqueConstraint(name = "uk_user_character", columnNames = {"user_id", "character_id"})) +@NoArgsConstructor +public class UserCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userCharacterId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id", nullable = false) + private KoCharacter character; + + public UserCharacter(User user, KoCharacter character) { + this.user = user; + this.character = character; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java new file mode 100644 index 00000000..6d206087 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CharacterRepository extends JpaRepository { + List findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + List findAllByOrderByDisplayOrderAsc(); + + boolean existsByName(String name); + + Optional findByName(String name); +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java new file mode 100644 index 00000000..13045b54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.User; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserCharacterRepository extends JpaRepository { + @EntityGraph(attributePaths = "character") + List findAllByUser(User user); + + boolean existsByUserAndCharacter(User user, KoCharacter character); + + boolean existsByCharacter(KoCharacter character); + + void deleteAllByUser(User user); +} diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index bad5cd71..87034145 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -102,7 +102,16 @@ public enum ResultCode { COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 - NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."), + + // 캐릭터 17000번대 + NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 17000, "캐릭터를 찾을 수 없습니다."), + ALREADY_OWNED_CHARACTER(HttpStatus.CONFLICT, 17001, "이미 보유한 캐릭터입니다."), + INSUFFICIENT_POINT(HttpStatus.BAD_REQUEST, 17002, "포인트가 부족합니다."), + NOT_OWNED_CHARACTER(HttpStatus.BAD_REQUEST, 17003, "보유하지 않은 캐릭터입니다."), + CHARACTER_IN_USE(HttpStatus.CONFLICT, 17004, "사용자가 보유 중인 캐릭터는 삭제할 수 없습니다."), + INACTIVE_CHARACTER(HttpStatus.BAD_REQUEST, 17005, "비활성화된 캐릭터입니다."), + INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 17006, "레벨이 부족합니다."); private final HttpStatus status; private final int code; diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 4c75cbbe..8e3fab61 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -11,7 +11,8 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 PLACE("place/"), BUILDING_IMAGE("buildingImage/"), SUGGESTION("suggestion/"), - REVIEW("review/"); + REVIEW("review/"), + CHARACTER("character/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java new file mode 100644 index 00000000..bca6aedd --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +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.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class CharacterRepositoryTest { + @Autowired + CharacterRepository characterRepository; + + @BeforeEach + void setUp() { + characterRepository.save(new KoCharacter("두번째", null, null, "url2", 20, 2, 2, true)); + characterRepository.save(new KoCharacter("첫번째", null, null, "url1", 10, 1, 1, true)); + characterRepository.save(new KoCharacter("비활성", null, null, "url3", 30, 3, 3, false)); + } + + @DisplayName("활성 캐릭터만 정렬 순서대로 조회") + @Test + void findAllByIsActiveTrueOrderByDisplayOrderAsc() { + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + assertEquals(2, characters.size()); + assertEquals("첫번째", characters.get(0).getName()); + assertEquals("두번째", characters.get(1).getName()); + } + + @DisplayName("관리자 조회는 비활성 포함") + @Test + void findAllByOrderByDisplayOrderAsc() { + assertEquals(3, characterRepository.findAllByOrderByDisplayOrderAsc().size()); + } + + @DisplayName("이름 존재 여부 확인 (시더 멱등 처리용)") + @Test + void existsByName() { + assertTrue(characterRepository.existsByName("첫번째")); + assertFalse(characterRepository.existsByName("없는이름")); + } + + @DisplayName("이름 중복 저장 시 제약 위반 (멀티 인스턴스 시드 레이스 방어)") + @Test + void duplicateNameThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + characterRepository.saveAndFlush(new KoCharacter("첫번째", null, null, "url", 10, 1, 9, true))); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java new file mode 100644 index 00000000..eff13a19 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java @@ -0,0 +1,86 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.List; +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.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserCharacterRepositoryTest { + @Autowired + UserCharacterRepository userCharacterRepository; + + @Autowired + CharacterRepository characterRepository; + + @Autowired + UserRepository userRepository; + + User user1; + User user2; + KoCharacter character; + + @BeforeEach + void setUp() { + user1 = userRepository.save(new User("user1", "user1@test.com", Role.USER, Provider.KAKAO)); + user2 = userRepository.save(new User("user2", "user2@test.com", Role.USER, Provider.KAKAO)); + character = characterRepository.save(new KoCharacter("캐릭터", null, null, "url", 10, 1, 1, true)); + + userCharacterRepository.save(new UserCharacter(user1, character)); + } + + @DisplayName("같은 사용자-캐릭터 중복 저장 시 제약 위반 (동시 해금 방어선)") + @Test + void duplicateClaimThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + userCharacterRepository.saveAndFlush(new UserCharacter(user1, character))); + } + + @DisplayName("사용자별 보유 캐릭터만 조회") + @Test + void findAllByUser() { + List owned = userCharacterRepository.findAllByUser(user1); + + assertEquals(1, owned.size()); + assertEquals(character.getCharacterId(), owned.get(0).getCharacter().getCharacterId()); + assertTrue(userCharacterRepository.findAllByUser(user2).isEmpty()); + } + + @DisplayName("보유 여부 확인") + @Test + void existsByUserAndCharacter() { + assertTrue(userCharacterRepository.existsByUserAndCharacter(user1, character)); + assertTrue(userCharacterRepository.existsByCharacter(character)); + } + + @DisplayName("회원 탈퇴 시 본인 보유 이력만 삭제") + @Test + void deleteAllByUser() { + userCharacterRepository.save(new UserCharacter(user2, character)); + + userCharacterRepository.deleteAllByUser(user1); + + assertTrue(userCharacterRepository.findAllByUser(user1).isEmpty()); + assertEquals(1, userCharacterRepository.findAllByUser(user2).size()); + } +} From daf4a27e469fe61c66b2c282c196a0d3719059ed Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:02 +0900 Subject: [PATCH 15/54] =?UTF-8?q?Feat:=20=EC=83=81=EC=A0=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4/=EC=9E=A5=EC=B0=A9=20=EA=B8=B0=EB=8A=A5=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 --- .../character/controller/StoreController.java | 129 ++++++++ .../dto/response/EquipCharacterRes.java | 15 + .../dto/response/GetMyCharacterListRes.java | 22 ++ .../dto/response/GetMyCharacterRes.java | 32 ++ .../dto/response/GetStoreCharacterRes.java | 46 +++ .../character/dto/response/GetStoreRes.java | 19 ++ .../dto/response/PurchaseCharacterRes.java | 35 ++ .../dto/response/UnequipCharacterRes.java | 7 + .../character/service/StoreService.java | 203 ++++++++++++ .../teamcback/domain/user/entity/User.java | 7 + .../domain/user/service/UserService.java | 3 + .../global/security/SecurityConfig.java | 1 + .../character/service/StoreServiceTest.java | 298 ++++++++++++++++++ .../service/UserServiceDeleteUserTest.java | 80 +++++ .../service/UserServiceGetUserInfoTest.java | 80 +++++ 15 files changed, 977 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java new file mode 100644 index 00000000..d745a92c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java @@ -0,0 +1,129 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.service.StoreService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +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.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/store") +public class StoreController { + private final StoreService storeService; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * @param userDetail 사용자 정보 + */ + @Operation(summary = "스토어 조회", description = "보유 포인트와 전체 캐릭터 목록(보유/구매 가능/포인트 부족) 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("") + public CommonResponse getStore( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getStore(userDetail.getUser().getUserId())); + } + + /** + * 내 보유 캐릭터 목록 조회 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "보유 캐릭터 목록 조회", description = "보유 포인트, 구매한 캐릭터, 대표 장착 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/my") + public CommonResponse getMyCharacters( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getMyCharacters(userDetail.getUser().getUserId())); + } + + /** + * 캐릭터 구매 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 구매", description = "보유 포인트를 차감하여 캐릭터 구매") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "포인트 부족 또는 비활성 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/purchase") + public CommonResponse purchaseCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.purchaseCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "대표 캐릭터 장착", description = "구매한 캐릭터를 대표 캐릭터로 장착") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "보유하지 않은 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping("/{characterId}/equip") + public CommonResponse equipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.equipCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 해제 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "대표 캐릭터 장착 해제", description = "대표 캐릭터 장착 해제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/equip") + public CommonResponse unequipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.unequipCharacter(userDetail.getUser().getUserId())); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java new file mode 100644 index 00000000..604091b7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "대표 캐릭터 장착 결과") +@Getter +public class EquipCharacterRes { + @Schema(description = "장착한 캐릭터 ID", example = "1") + private Long characterId; + + public EquipCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java new file mode 100644 index 00000000..912995a8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 목록") +@Getter +public class GetMyCharacterListRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "대표 장착 캐릭터 ID (미장착 시 null)", example = "1") + private Long equippedCharacterId; + @Schema(description = "보유 캐릭터 목록") + private List characterList; + + public GetMyCharacterListRes(Long point, Long equippedCharacterId, List characterList) { + this.point = point; + this.equippedCharacterId = equippedCharacterId; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java new file mode 100644 index 00000000..cfe4fbcd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 정보") +@Getter +public class GetMyCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "true") + private boolean isEquipped; + + public GetMyCharacterRes(UserCharacter userCharacter, boolean isEquipped) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.purchasedAt = userCharacter.getCreatedAt(); + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java new file mode 100644 index 00000000..6396151f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java @@ -0,0 +1,46 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "스토어 캐릭터 정보") +@Getter +public class GetStoreCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "구매 상태 (OWNED/PURCHASABLE/LOCKED/NOT_ENOUGH_POINT)", example = "PURCHASABLE") + private PurchaseStatus status; + @Schema(description = "구매 일시 (미보유 시 null)") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "false") + private boolean isEquipped; + + public GetStoreCharacterRes(KoCharacter character, PurchaseStatus status, + LocalDateTime purchasedAt, boolean isEquipped) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.status = status; + this.purchasedAt = purchasedAt; + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java new file mode 100644 index 00000000..8501940e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "스토어 정보 (보유 포인트 + 캐릭터 목록)") +@Getter +public class GetStoreRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetStoreRes(Long point, List characterList) { + this.point = point; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java new file mode 100644 index 00000000..287453b8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "캐릭터 구매 결과") +@Getter +public class PurchaseCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "지불한 포인트", example = "10") + private Integer price; + @Schema(description = "구매 후 잔여 포인트", example = "15") + private Long remainingPoint; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + + public PurchaseCharacterRes(UserCharacter userCharacter, Long remainingPoint) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.price = userCharacter.getCharacter().getPrice(); + this.remainingPoint = remainingPoint; + this.purchasedAt = userCharacter.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java new file mode 100644 index 00000000..429bc45b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "대표 캐릭터 장착 해제 결과") +public class UnequipCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java new file mode 100644 index 00000000..578393e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java @@ -0,0 +1,203 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_LEVEL; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_POINT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_OWNED_CHARACTER; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * 정렬: 1순위 보유(해금 레벨순→가격순) → 2순위 구매 가능(가격 낮은순) + * → 3순위 미해금·레벨 미달(해금 레벨순) → 4순위 포인트 부족(가격 낮은순) + */ + @Transactional(readOnly = true) + public GetStoreRes getStore(Long userId) { + User user = findUser(userId); + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + Map ownedMap = userCharacterRepository.findAllByUser(user).stream() + .collect(Collectors.toMap(uc -> uc.getCharacter().getCharacterId(), Function.identity())); + + long point = user.getPoint(); + int userLevel = levelNumberOf(user); + Map statusMap = characters.stream() + .collect(Collectors.toMap(KoCharacter::getCharacterId, + c -> getPurchaseStatus(c, ownedMap.containsKey(c.getCharacterId()), userLevel, point))); + + List characterList = characters.stream() + .sorted(storeOrder(statusMap)) + .map(character -> { + UserCharacter owned = ownedMap.get(character.getCharacterId()); + boolean isEquipped = character.getCharacterId().equals(user.getEquippedCharacterId()); + return new GetStoreCharacterRes(character, statusMap.get(character.getCharacterId()), + owned == null ? null : owned.getCreatedAt(), isEquipped); + }) + .toList(); + + return new GetStoreRes(user.getPoint(), characterList); + } + + /** + * 내 보유 캐릭터 목록 조회 + */ + @Transactional(readOnly = true) + public GetMyCharacterListRes getMyCharacters(Long userId) { + User user = findUser(userId); + + List characterList = userCharacterRepository.findAllByUser(user).stream() + .map(uc -> new GetMyCharacterRes(uc, + uc.getCharacter().getCharacterId().equals(user.getEquippedCharacterId()))) + .toList(); + + return new GetMyCharacterListRes(user.getPoint(), user.getEquippedCharacterId(), characterList); + } + + /** + * 캐릭터 구매 (포인트 차감) + */ + @Transactional + public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!character.isActive()) throw new GlobalException(INACTIVE_CHARACTER); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + // 해금 레벨 미달이면 포인트와 무관하게 구매 불가 + if(levelNumberOf(user) < character.getRequiredLevel()) { + throw new GlobalException(INSUFFICIENT_LEVEL); + } + + // 잔액 검증과 차감을 단일 조건부 UPDATE로 수행 (동시 구매 시 이중 차감 방지) + if(userRepository.deductPoint(userId, character.getPrice()) == 0) { + throw new GlobalException(INSUFFICIENT_POINT); + } + + // deductPoint가 영속성 컨텍스트를 비우므로 차감이 반영된 상태로 재조회 + user = findUser(userId); + character = findCharacter(characterId); + + try { + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + return new PurchaseCharacterRes(userCharacter, user.getPoint()); + } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + } + + /** + * 대표 캐릭터 장착 + */ + @Transactional + public EquipCharacterRes equipCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(NOT_OWNED_CHARACTER); + } + + user.updateEquippedCharacter(character.getCharacterId()); + + return new EquipCharacterRes(character.getCharacterId()); + } + + /** + * 대표 캐릭터 장착 해제 + */ + @Transactional + public UnequipCharacterRes unequipCharacter(Long userId) { + User user = findUser(userId); + user.updateEquippedCharacter(null); + + return new UnequipCharacterRes(); + } + + private PurchaseStatus getPurchaseStatus(KoCharacter character, boolean owned, int userLevel, long point) { + if(owned) return PurchaseStatus.OWNED; + if(userLevel < character.getRequiredLevel()) return PurchaseStatus.LOCKED; + return point >= character.getPrice() ? PurchaseStatus.PURCHASABLE : PurchaseStatus.NOT_ENOUGH_POINT; + } + + private Comparator storeOrder(Map statusMap) { + return Comparator + .comparingInt((KoCharacter c) -> statusTier(statusMap.get(c.getCharacterId()))) + .thenComparingInt(c -> primarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparingInt(c -> secondarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparing(KoCharacter::getDisplayOrder) + .thenComparing(KoCharacter::getCharacterId); + } + + private int statusTier(PurchaseStatus status) { + return switch (status) { + case OWNED -> 0; + case PURCHASABLE -> 1; + case LOCKED -> 2; + case NOT_ENOUGH_POINT -> 3; + }; + } + + private int primarySortKey(PurchaseStatus status, KoCharacter character) { + return switch (status) { + case OWNED, LOCKED -> character.getRequiredLevel(); // 레벨순 + case PURCHASABLE, NOT_ENOUGH_POINT -> character.getPrice(); // 포인트 낮은순 + }; + } + + private int secondarySortKey(PurchaseStatus status, KoCharacter character) { + return status == PurchaseStatus.OWNED ? character.getPrice() : 0; // 보유는 레벨순 다음 포인트순 + } + + private int levelNumberOf(User user) { + // 백필 전 레거시 행 방어: level 컬럼이 비어 있으면 score로 계산 + Level level = user.getLevel() != null ? user.getLevel() : Level.fromScore(user.getScore()); + return level.getLevelNumber(); + } + + private User findUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 41618832..aa33455c 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -55,6 +55,9 @@ public class User extends BaseEntity { @Column(nullable = false) private boolean isUpgraded = false; + // 대표 캐릭터 (tb_character 논리 참조, 미장착 시 null) + private Long equippedCharacterId; + @Setter @Column(unique = true) private String code; @@ -94,4 +97,8 @@ public void addPoint(long amount) { this.point = Math.max(0, getPoint() + amount); } + public void updateEquippedCharacter(Long characterId) { + this.equippedCharacterId = characterId; + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index e0a447fb..68101dde 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; @@ -48,6 +49,7 @@ public class UserService { private final BookmarkRepository bookmarkRepository; private final UserBookmarkLogRepository userBookmarkLogRepository; private final SuggestionRepository suggestionRepository; + private final UserCharacterRepository userCharacterRepository; private final JwtUtil jwtUtil; private final KakaoValidator kakaoValidator; private final GoogleValidator googleValidator; @@ -188,6 +190,7 @@ public DeleteUserRes deleteUser(Long userId) { userBookmarkLogRepository.deleteAll(userBookmarkLogRepository.findByUser(user)); pushInstallationService.deactivateAll(user.getUserId()); // suggestionRepository.deleteAll(suggestionRepository.findByUser(user)); + userCharacterRepository.deleteAllByUser(user); userRepository.delete(user); return new DeleteUserRes(); diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 50bc866a..c5e79cca 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -93,6 +93,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 + .requestMatchers("/api/store/**").authenticated() // 캐릭터 스토어는 로그인 필요 .anyRequest().permitAll() ).exceptionHandling(ex -> ex .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java new file mode 100644 index 00000000..658db82e --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -0,0 +1,298 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class StoreServiceTest { + @InjectMocks + StoreService storeService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + + static final Long USER_ID = 1L; + static final Long CHARACTER_ID = 10L; + + User user; + KoCharacter character; // 해금 레벨 1, 가격 10 + + @BeforeEach + void setUp() { + user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", USER_ID); + + character = newCharacter(CHARACTER_ID, "아기 호랑이", 10, 1, 1, true); + } + + private KoCharacter newCharacter(Long id, String name, int price, int requiredLevel, int order, boolean active) { + KoCharacter c = new KoCharacter(name, null, name + " 대사", "url", price, requiredLevel, order, active); + ReflectionTestUtils.setField(c, "characterId", id); + return c; + } + + @DisplayName("레벨 충족 + 포인트 충분이면 구매 성공") + @Test + void purchaseCharacter() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); + + assertEquals(CHARACTER_ID, res.getCharacterId()); + assertEquals(10, res.getPrice()); + verify(userRepository).deductPoint(USER_ID, 10); + } + + @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") + @Test + void purchaseInsufficientLevel() { + KoCharacter level3Character = newCharacter(CHARACTER_ID, "청년 호랑이", 10, 3, 3, true); + user.addPoint(100); // 포인트는 충분 + // 유저는 score 0 → LEVEL1 + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(level3Character)); + when(userCharacterRepository.existsByUserAndCharacter(user, level3Character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_LEVEL, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + + // 레벨 3 도달 시 구매 가능 + user.updateScore(20L, false); // LEVEL3 + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + assertEquals(CHARACTER_ID, storeService.purchaseCharacter(USER_ID, CHARACTER_ID).getCharacterId()); + } + + @DisplayName("포인트 부족 시 구매 실패") + @Test + void purchaseInsufficientPoint() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(0); // 잔액 부족 + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_POINT, e.getResultCode()); + verify(userCharacterRepository, never()).saveAndFlush(any()); + } + + @DisplayName("이미 보유한 캐릭터 구매 시 예외 (차감 없음)") + @Test + void purchaseAlreadyOwned() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + } + + @DisplayName("비활성 캐릭터는 구매 불가") + @Test + void purchaseInactiveRejected() { + KoCharacter inactive = newCharacter(CHARACTER_ID, "숨김 캐릭터", 10, 1, 9, false); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(inactive)); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INACTIVE_CHARACTER, e.getResultCode()); + } + + @DisplayName("동시 중복 구매 시 제약 위반을 이미 보유로 매핑 (롤백으로 차감 복구)") + @Test + void purchaseRaceMappedToAlreadyOwned() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenThrow(new DataIntegrityViolationException("uk_user_character")); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + } + + @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") + @Test + void equipCharacter() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.equipCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.NOT_OWNED_CHARACTER, e.getResultCode()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + storeService.equipCharacter(USER_ID, CHARACTER_ID); + assertEquals(CHARACTER_ID, user.getEquippedCharacterId()); + + storeService.unequipCharacter(USER_ID); + assertNull(user.getEquippedCharacterId()); + } + + @DisplayName("스토어 목록: 4단계 상태 판정 (보유/구매가능/미해금/포인트부족)") + @Test + void getStoreStatuses() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(10); + user.updateEquippedCharacter(CHARACTER_ID); + + KoCharacter ownedCharacter = character; // 보유 + 장착 + KoCharacter purchasableCharacter = newCharacter(11L, "학생 호랑이", 10, 2, 2, true); // 레벨·포인트 충족 + KoCharacter lockedCharacter = newCharacter(12L, "청년 호랑이", 5, 3, 3, true); // 레벨 미달 (포인트는 충분해도 LOCKED) + KoCharacter expensiveCharacter = newCharacter(13L, "비싼 호랑이", 999, 1, 4, true); // 레벨 충족, 포인트 부족 + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedCharacter, purchasableCharacter, lockedCharacter, expensiveCharacter)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedCharacter))); + + GetStoreRes res = storeService.getStore(USER_ID); + List list = res.getCharacterList(); + + assertEquals(10L, res.getPoint()); + assertEquals(PurchaseStatus.OWNED, statusOf(list, CHARACTER_ID)); + assertEquals(PurchaseStatus.PURCHASABLE, statusOf(list, 11L)); + assertEquals(PurchaseStatus.LOCKED, statusOf(list, 12L)); + assertEquals(PurchaseStatus.NOT_ENOUGH_POINT, statusOf(list, 13L)); + } + + @DisplayName("스토어 정렬: 보유(레벨→포인트) → 구매가능(포인트) → 미해금(레벨) → 포인트부족(포인트)") + @Test + void getStoreSorting() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(20); + + // displayOrder는 전부 역순(9~1)으로 줘서 정렬이 displayOrder가 아닌 스펙 기준임을 증명 + KoCharacter ownedHighLevel = newCharacter(21L, "보유-레벨2", 5, 2, 9, true); + KoCharacter ownedLowLevel = newCharacter(22L, "보유-레벨1", 10, 1, 8, true); + KoCharacter purchasableExpensive = newCharacter(23L, "구매가능-20p", 20, 1, 7, true); + KoCharacter purchasableCheap = newCharacter(24L, "구매가능-5p", 5, 2, 6, true); + KoCharacter lockedLevel5 = newCharacter(25L, "미해금-레벨5", 0, 5, 5, true); + KoCharacter lockedLevel3 = newCharacter(26L, "미해금-레벨3", 0, 3, 4, true); + KoCharacter poorExpensive = newCharacter(27L, "포인트부족-99p", 99, 1, 3, true); + KoCharacter poorCheap = newCharacter(28L, "포인트부족-30p", 30, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedHighLevel, ownedLowLevel, purchasableExpensive, purchasableCheap, + lockedLevel5, lockedLevel3, poorExpensive, poorCheap)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedHighLevel), new UserCharacter(user, ownedLowLevel))); + + List list = storeService.getStore(USER_ID).getCharacterList(); + List orderedIds = list.stream().map(GetStoreCharacterRes::getCharacterId).toList(); + + assertEquals(List.of( + 22L, 21L, // 1순위 보유: 레벨1(가격10) → 레벨2(가격5) + 24L, 23L, // 2순위 구매가능: 5p → 20p + 26L, 25L, // 3순위 미해금: 레벨3 → 레벨5 + 28L, 27L // 4순위 포인트부족: 30p → 99p + ), orderedIds); + } + + @DisplayName("지급받아 보유 중인 캐릭터는 레벨 미달이어도 '이미 보유' 응답 (검증 순서)") + @Test + void purchaseOwnedTakesPrecedenceOverLevel() { + // 운영자 지급으로 레벨 5 캐릭터를 보유한 레벨 1 사용자 + KoCharacter grantedCharacter = newCharacter(CHARACTER_ID, "어른호랑이", 30, 5, 5, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(grantedCharacter)); + when(userCharacterRepository.existsByUserAndCharacter(user, grantedCharacter)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); // INSUFFICIENT_LEVEL이 아니어야 함 + } + + @DisplayName("가격 0 캐릭터는 포인트 0이어도 구매 가능 상태 (경계값)") + @Test + void freeCharacterPurchasableWithZeroPoint() { + // 신규 사용자: score 0, point 0, LEVEL1 + KoCharacter freeCharacter = newCharacter(CHARACTER_ID, "애기호랑이", 0, 1, 1, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of(freeCharacter)); + when(userCharacterRepository.findAllByUser(user)).thenReturn(List.of()); + + GetStoreRes res = storeService.getStore(USER_ID); + + assertEquals(PurchaseStatus.PURCHASABLE, res.getCharacterList().get(0).getStatus()); + } + + @DisplayName("내 보유 캐릭터: 포인트·대표 캐릭터·장착 플래그·대사 반환") + @Test + void getMyCharacters() { + user.addPoint(7); + user.updateEquippedCharacter(CHARACTER_ID); + KoCharacter otherCharacter = newCharacter(11L, "꼬마호랑이", 15, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userCharacterRepository.findAllByUser(user)).thenReturn( + List.of(new UserCharacter(user, character), new UserCharacter(user, otherCharacter))); + + var res = storeService.getMyCharacters(USER_ID); + + assertEquals(7L, res.getPoint()); + assertEquals(CHARACTER_ID, res.getEquippedCharacterId()); + assertEquals(2, res.getCharacterList().size()); + assertEquals(true, res.getCharacterList().get(0).isEquipped()); // 장착한 캐릭터 + assertEquals(false, res.getCharacterList().get(1).isEquipped()); // 미장착 보유 캐릭터 + assertEquals("아기 호랑이 대사", res.getCharacterList().get(0).getQuote()); + } + + private PurchaseStatus statusOf(List list, Long characterId) { + return list.stream().filter(c -> c.getCharacterId().equals(characterId)).findFirst().orElseThrow().getStatus(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java new file mode 100644 index 00000000..2062522b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceDeleteUserTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + PushInstallationService pushInstallationService; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("회원 탈퇴 시 캐릭터 보유 이력을 사용자 삭제 전에 정리 (FK 제약)") + @Test + void deleteUserCleansUpOwnedCharacters() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.findByUser(user)).thenReturn(List.of()); + when(suggestionRepository.findByUser(user)).thenReturn(List.of()); + when(userBookmarkLogRepository.findByUser(user)).thenReturn(List.of()); + + userService.deleteUser(1L); + + InOrder inOrder = Mockito.inOrder(userCharacterRepository, userRepository); + inOrder.verify(userCharacterRepository).deleteAllByUser(user); // 소유 이력 먼저 + inOrder.verify(userRepository).delete(user); // 그 다음 사용자 삭제 + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java new file mode 100644 index 00000000..053eb5ae --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceGetUserInfoTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("마이페이지: 영속화된 level 컬럼 기반 응답 + isUpgraded 리셋") + @Test + void getUserInfo() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + user.updateScore(10L, true); // LEVEL2, 레벨업 직후 상태 + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.countAllByUser(user)).thenReturn(1L); + + GetUserInfoRes res = userService.getUserInfo(1L); + + assertEquals(2, res.getLevel()); + assertEquals(10L, res.getScore()); + assertEquals(0L, res.getPoint()); // updateScore는 포인트를 건드리지 않음 (적립은 aspect에서) + assertEquals(10L, res.getRemainScoreToNextLevel()); // LEVEL3 시작(20) - 10 + assertEquals(33, res.getPercent()); // 100 * (10-5) / (20-5) + assertTrue(res.isUpgraded()); + assertFalse(user.isUpgraded()); // 조회 후 리셋 + } +} From 13ae479e7556f89258b6764fa29d8f77fca83c20 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:19 +0900 Subject: [PATCH 16/54] =?UTF-8?q?Feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminStoreController.java | 124 +++++++++++ .../dto/response/CreateCharacterRes.java | 15 ++ .../dto/response/DeleteCharacterRes.java | 7 + .../response/GetAdminCharacterListRes.java | 16 ++ .../dto/response/GetAdminCharacterRes.java | 44 ++++ .../dto/response/GrantCharacterRes.java | 15 ++ .../dto/response/ModifyCharacterRes.java | 7 + .../character/service/AdminStoreService.java | 146 +++++++++++++ .../service/AdminStoreServiceTest.java | 196 ++++++++++++++++++ 9 files changed, 570 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java new file mode 100644 index 00000000..87a76219 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java @@ -0,0 +1,124 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.service.AdminStoreService; +import devkor.com.teamcback.global.response.CommonResponse; +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.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/store") +public class AdminStoreController { + private final AdminStoreService adminStoreService; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Operation(summary = "관리자 캐릭터 목록 조회", description = "비활성 캐릭터를 포함한 전체 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + }) + @GetMapping("") + public CommonResponse getCharacterList() { + return CommonResponse.success(adminStoreService.getCharacterList()); + } + + /** + * 캐릭터 생성 + * @param req 캐릭터 정보 (이미지, 가격 포함) + */ + @Operation(summary = "캐릭터 생성", description = "캐릭터 생성 (이미지 필수, 가격은 0 이상의 포인트)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 입력", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse createCharacter( + @Parameter(description = "캐릭터 정보") + @ModelAttribute CreateCharacterReq req) { + return CommonResponse.success(adminStoreService.createCharacter(req)); + } + + /** + * 캐릭터 수정 + * @param characterId 캐릭터 ID + * @param req 캐릭터 정보 (이미지 미첨부 시 기존 이미지 유지) + */ + @Operation(summary = "캐릭터 수정", description = "캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping(value = "/{characterId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse modifyCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "캐릭터 정보") + @ModelAttribute ModifyCharacterReq req) { + return CommonResponse.success(adminStoreService.modifyCharacter(characterId, req)); + } + + /** + * 캐릭터 삭제 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 삭제", description = "캐릭터 삭제 (구매한 사용자가 있으면 삭제 불가, isActive=false로 숨김 처리 권장)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "구매한 사용자가 있는 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/{characterId}") + public CommonResponse deleteCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(adminStoreService.deleteCharacter(characterId)); + } + + /** + * 캐릭터 수동 지급 + * @param characterId 캐릭터 ID + * @param userId 사용자 ID + */ + @Operation(summary = "캐릭터 수동 지급", description = "특정 사용자에게 캐릭터 지급 (이벤트 보상용, 포인트 차감 없음)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/grant/{userId}") + public CommonResponse grantCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "사용자 ID", example = "1") + @PathVariable(name = "userId") Long userId) { + return CommonResponse.success(adminStoreService.grantCharacter(characterId, userId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java new file mode 100644 index 00000000..07d3aca0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 생성 결과") +@Getter +public class CreateCharacterRes { + @Schema(description = "생성된 캐릭터 ID", example = "1") + private Long characterId; + + public CreateCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java new file mode 100644 index 00000000..fb5df029 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 삭제 결과") +public class DeleteCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java new file mode 100644 index 00000000..5715e38d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 목록") +@Getter +public class GetAdminCharacterListRes { + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetAdminCharacterListRes(List characterList) { + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java new file mode 100644 index 00000000..a4195d80 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 정보") +@Getter +public class GetAdminCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명") + private String description; + @Schema(description = "캐릭터 대사") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder; + @Schema(description = "노출 여부", example = "true") + private boolean isActive; + @Schema(description = "생성 일시") + private LocalDateTime createdAt; + + public GetAdminCharacterRes(KoCharacter character) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.displayOrder = character.getDisplayOrder(); + this.isActive = character.isActive(); + this.createdAt = character.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java new file mode 100644 index 00000000..3f196fce --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 수동 지급 결과") +@Getter +public class GrantCharacterRes { + @Schema(description = "지급된 사용자 캐릭터 ID", example = "1") + private Long userCharacterId; + + public GrantCharacterRes(Long userCharacterId) { + this.userCharacterId = userCharacterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java new file mode 100644 index 00000000..560da964 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 수정 결과") +public class ModifyCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java new file mode 100644 index 00000000..033241d2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java @@ -0,0 +1,146 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.CHARACTER_IN_USE; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class AdminStoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + private final S3Util s3Util; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Transactional(readOnly = true) + public GetAdminCharacterListRes getCharacterList() { + List characterList = characterRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(GetAdminCharacterRes::new) + .toList(); + + return new GetAdminCharacterListRes(characterList); + } + + /** + * 캐릭터 생성 + */ + @Transactional + public CreateCharacterRes createCharacter(CreateCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) throw new GlobalException(INVALID_INPUT); // 이미지 필수 + + KoCharacter character = characterRepository.save(new KoCharacter(req, imageUrl)); + + return new CreateCharacterRes(character.getCharacterId()); + } + + /** + * 캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지) + */ + @Transactional + public ModifyCharacterRes modifyCharacter(Long characterId, ModifyCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + KoCharacter character = findCharacter(characterId); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) { + imageUrl = character.getImageUrl(); + } else if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + + character.update(req, imageUrl); + + return new ModifyCharacterRes(); + } + + /** + * 캐릭터 삭제 (구매한 사용자가 있으면 불가 - isActive=false로 숨김 처리 안내) + */ + @Transactional + public DeleteCharacterRes deleteCharacter(Long characterId) { + KoCharacter character = findCharacter(characterId); + + if(userCharacterRepository.existsByCharacter(character)) { + throw new GlobalException(CHARACTER_IN_USE); + } + + if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + characterRepository.delete(character); + + return new DeleteCharacterRes(); + } + + /** + * 캐릭터 수동 지급 (이벤트 보상 등, 포인트 차감 없음) + */ + @Transactional + public GrantCharacterRes grantCharacter(Long characterId, Long userId) { + KoCharacter character = findCharacter(characterId); + User user = userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + UserCharacter userCharacter = userCharacterRepository.save(new UserCharacter(user, character)); + + return new GrantCharacterRes(userCharacter.getUserCharacterId()); + } + + private void validatePrice(Integer price) { + if(price == null || price < 0) throw new GlobalException(INVALID_INPUT); + } + + private void validateRequiredLevel(Integer requiredLevel) { + int maxLevel = Level.values()[Level.values().length - 1].getLevelNumber(); + if(requiredLevel == null || requiredLevel < 1 || requiredLevel > maxLevel) { + throw new GlobalException(INVALID_INPUT); + } + } + + private String uploadImage(MultipartFile image) { + if(image == null || image.isEmpty()) return null; + return s3Util.uploadFile(image, FilePath.CHARACTER); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java new file mode 100644 index 00000000..6d6998f8 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -0,0 +1,196 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminStoreServiceTest { + @InjectMocks + AdminStoreService adminStoreService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + @Mock + S3Util s3Util; + + @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") + @Test + void createCharacter() { + MockMultipartFile image = new MockMultipartFile("image", "tiger.png", "image/png", new byte[] {1}); + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setDisplayOrder(1); + req.setImage(image); + + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("https://s3/character/tiger.png"); + when(characterRepository.save(any(KoCharacter.class))).thenAnswer(invocation -> { + KoCharacter character = invocation.getArgument(0); + ReflectionTestUtils.setField(character, "characterId", 1L); + return character; + }); + + CreateCharacterRes res = adminStoreService.createCharacter(req); + + assertEquals(1L, res.getCharacterId()); + verify(s3Util).uploadFile(image, FilePath.CHARACTER); + } + + @DisplayName("이미지 없이 캐릭터 생성 시 예외") + @Test + void createCharacterWithoutImage() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("가격이 없거나 음수면 생성 불가") + @Test + void createCharacterInvalidPrice() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(-1); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setPrice(null); + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + } + + @DisplayName("해금 레벨이 1~5 범위를 벗어나면 생성 불가") + @Test + void createCharacterInvalidRequiredLevel() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setRequiredLevel(0); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setRequiredLevel(6); // Level enum 최대(5) 초과 + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("캐릭터 수정: 이미지 미첨부 시 기존 이미지 유지, 첨부 시 교체 후 기존 파일 삭제") + @Test + void modifyCharacter() { + KoCharacter character = new KoCharacter("애기호랑이", null, "옛 대사", "old-url", 0, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + + devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq req = + new devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq(); + req.setName("애기호랑이"); + req.setQuote("나 호랑이 맞아요?"); + req.setPrice(0); + req.setRequiredLevel(1); + req.setDisplayOrder(1); + + // 이미지 미첨부 → 기존 URL 유지, S3 접근 없음 + adminStoreService.modifyCharacter(1L, req); + assertEquals("old-url", character.getImageUrl()); + assertEquals("나 호랑이 맞아요?", character.getQuote()); + verify(s3Util, never()).deleteFile(any(String.class), any(FilePath.class)); + + // 이미지 첨부 → 새 URL로 교체 + 기존 S3 파일 삭제 + MockMultipartFile image = new MockMultipartFile("image", "new.png", "image/png", new byte[] {1}); + req.setImage(image); + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("new-url"); + + adminStoreService.modifyCharacter(1L, req); + assertEquals("new-url", character.getImageUrl()); + verify(s3Util).deleteFile(eq("old-url"), eq(FilePath.CHARACTER)); + } + + @DisplayName("구매한 사용자가 있는 캐릭터 삭제 시 예외") + @Test + void deleteOwnedCharacterRejected() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.deleteCharacter(1L)); + assertEquals(ResultCode.CHARACTER_IN_USE, e.getResultCode()); + verify(characterRepository, never()).delete(any()); + } + + @DisplayName("구매자가 없으면 삭제 성공 (S3 이미지도 삭제)") + @Test + void deleteCharacter() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(false); + + adminStoreService.deleteCharacter(1L); + + verify(s3Util).deleteFile(eq("url"), eq(FilePath.CHARACTER)); + verify(characterRepository).delete(character); + } + + @DisplayName("수동 지급: 이미 보유 시 예외, 미보유 시 무료 지급") + @Test + void grantCharacter() { + KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.grantCharacter(1L, 2L)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userCharacterRepository.save(any(UserCharacter.class))).thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); + return userCharacter; + }); + + assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); + assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 + } +} From 1540a26e5c18e8d7fb0b213284d9854e2d824189 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:19 +0900 Subject: [PATCH 17/54] =?UTF-8?q?Feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=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 --- .../service/CharacterDataSeeder.java | 96 +++++++++++++++++++ src/main/resources/application.yml | 4 + .../service/CharacterDataSeederTest.java | 92 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java new file mode 100644 index 00000000..eb28f367 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java @@ -0,0 +1,96 @@ +package devkor.com.teamcback.domain.character.service; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +/** + * 캐릭터 확정 데이터 시더 (기획 문서 "포인트 상점 기능 > 캐릭터 DB 확정" 기준, 2026-07-29). + * 이름 존재 여부로 행 단위 멱등 처리, 멀티 인스턴스 동시 부팅은 name UNIQUE 제약이 방어. + * 기획 확정 전에 시드했던 구 플레이스홀더 5종은 보유자가 없을 때만 정리한다. + */ +@Slf4j +@Component +@Profile("!test") +@RequiredArgsConstructor +public class CharacterDataSeeder implements ApplicationRunner { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + + // 기획 확정 전 플레이스홀더 시드 이름 (확정 DB와 이름이 달라 공존하게 되므로 제거 대상) + private static final List LEGACY_SEED_NAMES = + List.of("아기 호랑이", "학생 호랑이", "청년 호랑이", "석사 호랑이", "호랑이 대장"); + + @Value("${character.image.base-url}") + private String imageBaseUrl; + + // 메서드 전체를 @Transactional로 묶으면 중복 키 예외를 잡아도 트랜잭션이 rollback-only로 오염되어 + // 커밋 시 UnexpectedRollbackException으로 부팅이 실패한다. save()별 개별 트랜잭션으로 둔다. + @Override + public void run(ApplicationArguments args) { + removeLegacySeeds(); + + List seeds = List.of( + new KoCharacter("애기호랑이", "기본 아바타", + "나 호랑이 맞아요?", imageUrl("01_aegi"), 0, 1, 1, true), + new KoCharacter("꼬마호랑이", "레벨 2 달성 시 해금", + "엄마가 발이 크면 키 크는 거래요. 저 발 엄청 커요!", imageUrl("02_kkoma"), 15, 2, 2, true), + new KoCharacter("포동호랑이", "레벨 3 달성 시 해금", + "어디든 가고 싶어요! 이따 어디 갈까요? 미래관? 과도?", imageUrl("03_podong"), 20, 3, 3, true), + new KoCharacter("학생호랑이", "레벨 4 달성 시 해금", + "안경 쓰면 똑똑해보이잖아요. 머릿속에 들어오는 건 없어요.", imageUrl("04_haksaeng"), 25, 4, 4, true), + new KoCharacter("어른호랑이", "레벨 5 달성 시 해금", + "뭐든 다 할 수 있을 것 같아요. 제가 못할 리 없죠!", imageUrl("05_eoreun"), 30, 5, 5, true), + new KoCharacter("피곤 호랑이", "포인트로 바로 구매 가능", + "대체 며칠째 밤샘인지.. 근데 오늘이 무슨 요일이죠?", imageUrl("06_pigon"), 35, 1, 6, true), + new KoCharacter("과잠 호랑이", "포인트로 바로 구매 가능", + "대학생, 원래 이렇게 힘든 거였어요? 살려주세요…", imageUrl("07_gwajam"), 45, 1, 7, true), + new KoCharacter("로봇호랑이", "포인트로 바로 구매 가능", + "다치기 싫어서 강철 슈트 입었어요. 강해보이죠?", imageUrl("08_robot"), 50, 1, 8, true), + new KoCharacter("천사 호랑이", "포인트로 바로 구매 가능", + "하루에 한번은 좋은 일이 생길 거예요!", imageUrl("09_cheonsa"), 80, 1, 9, true), + new KoCharacter("악마 호랑이", "포인트로 바로 구매 가능", + "화가 난 것 같지만 사실 부끄러움을 숨기고 있는 거에요…", imageUrl("10_akma"), 80, 1, 10, true) + ); + + int saved = 0; + for (KoCharacter seed : seeds) { + if(characterRepository.existsByName(seed.getName())) continue; + try { + characterRepository.save(seed); + saved++; + } catch (DataIntegrityViolationException e) { // 다른 인스턴스가 먼저 적재한 경우 + log.info("캐릭터 시드 중복 감지, 건너뜀: {}", seed.getName()); + } + } + if(saved > 0) { + log.info("캐릭터 시드 데이터 {}건 적재 완료", saved); + } + } + + private void removeLegacySeeds() { + for (String legacyName : LEGACY_SEED_NAMES) { + characterRepository.findByName(legacyName).ifPresent(legacy -> { + if(userCharacterRepository.existsByCharacter(legacy)) { // 보유자가 있으면 수동 정리 필요 + log.warn("구 플레이스홀더 캐릭터에 보유자가 있어 삭제하지 않음: {}", legacyName); + return; + } + characterRepository.delete(legacy); + log.info("구 플레이스홀더 캐릭터 삭제: {}", legacyName); + }); + } + } + + private String imageUrl(String fileName) { + return imageBaseUrl + "/" + fileName + ".png"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64b76d02..96aba826 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -119,6 +119,10 @@ profile: lv4-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv4.jpg lv5-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv5.jpg +character: + image: # 캐릭터 이미지 (피그마 확정 이미지를 이 경로 규칙대로 S3에 업로드: {base-url}/{NN_slug}.png) + base-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/character + place: default-image: cafe: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/place/default_image/CAFE.jpg diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java new file mode 100644 index 00000000..b88946b7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java @@ -0,0 +1,92 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CharacterDataSeederTest { + @InjectMocks + CharacterDataSeeder seeder; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(seeder, "imageBaseUrl", "https://s3/character"); + } + + @DisplayName("첫 실행 시 확정 캐릭터 10건 적재") + @Test + void seedOnFirstRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + + seeder.run(null); + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("이미 적재된 경우 저장하지 않음 (멱등)") + @Test + void skipOnSecondRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(true); + + seeder.run(null); + + verify(characterRepository, never()).save(any(KoCharacter.class)); + } + + @DisplayName("동시 부팅 레이스: 중복 키 예외가 나도 나머지 시드를 계속 적재") + @Test + void continueSeedingAfterDuplicateKeyRace() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + // 첫 번째 save는 다른 인스턴스가 먼저 적재해 UNIQUE 위반, 이후는 성공 + when(characterRepository.save(any(KoCharacter.class))) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException("uk name")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + seeder.run(null); // 예외가 전파되면 부팅 실패 → 테스트 실패 + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("구 플레이스홀더는 보유자가 없으면 삭제, 있으면 유지") + @Test + void removeLegacySeeds() { + KoCharacter orphanLegacy = new KoCharacter("아기 호랑이", null, null, "url", 0, 1, 1, true); + KoCharacter ownedLegacy = new KoCharacter("호랑이 대장", null, null, "url", 60, 5, 5, true); + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.findByName("아기 호랑이")).thenReturn(Optional.of(orphanLegacy)); + when(characterRepository.findByName("호랑이 대장")).thenReturn(Optional.of(ownedLegacy)); + when(userCharacterRepository.existsByCharacter(orphanLegacy)).thenReturn(false); + when(userCharacterRepository.existsByCharacter(ownedLegacy)).thenReturn(true); + when(characterRepository.existsByName(anyString())).thenReturn(true); // 시드 자체는 스킵 + + seeder.run(null); + + verify(characterRepository).delete(orphanLegacy); + verify(characterRepository, never()).delete(ownedLegacy); + } +} From 4610ac96490524931284e485de070aa5dda501db Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Mon, 3 Aug 2026 17:48:20 +0900 Subject: [PATCH 18/54] feat: add Expo push API client --- .../notification/client/ExpoPushApi.java | 25 ++++ .../notification/client/ExpoPushClient.java | 138 ++++++++++++++++++ .../client/ExpoPushClientException.java | 20 +++ .../config/ExpoPushFeignConfig.java | 37 +++++ .../config/ExpoPushProperties.java | 28 ++++ .../config/ExpoPushPropertiesConfig.java | 9 ++ .../dto/expo/ExpoPushErrorDetails.java | 9 ++ .../dto/expo/ExpoPushReceipt.java | 11 ++ .../dto/expo/ExpoPushRequest.java | 13 ++ .../dto/expo/ExpoPushResponse.java | 10 ++ .../notification/dto/expo/ExpoPushTicket.java | 12 ++ .../dto/expo/ExpoReceiptRequest.java | 8 + .../dto/expo/ExpoReceiptResponse.java | 10 ++ src/main/resources/application.yml | 7 + 14 files changed, 337 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java new file mode 100644 index 00000000..9c67ed8b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java @@ -0,0 +1,25 @@ +package devkor.com.teamcback.domain.notification.client; + +import devkor.com.teamcback.domain.notification.config.ExpoPushFeignConfig; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "expoPushApi", + url = "${push.expo.base-url}", + configuration = ExpoPushFeignConfig.class +) +public interface ExpoPushApi { + + @PostMapping("/send") + ExpoPushResponse send(@RequestBody List requests); + + @PostMapping("/getReceipts") + ExpoReceiptResponse getReceipts(@RequestBody ExpoReceiptRequest request); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java new file mode 100644 index 00000000..a5aecd48 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java @@ -0,0 +1,138 @@ +package devkor.com.teamcback.domain.notification.client; + +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import feign.FeignException; +import feign.RetryableException; +import feign.codec.DecodeException; +import feign.codec.EncodeException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExpoPushClient { + + private static final int MAX_SEND_REQUEST_COUNT = 100; + private static final int MAX_RECEIPT_ID_COUNT = 1000; + + private final ExpoPushApi expoPushApi; + + public ExpoPushResponse send(List requests) { + validateSendRequests(requests); + + try { + ExpoPushResponse response = expoPushApi.send(requests); + if (response == null) { + throw parsingFailed(); + } + return response; + } catch (ExpoPushClientException e) { + throw e; + } catch (RetryableException e) { + throw requestFailed(null, true); + } catch (DecodeException e) { + throw parsingFailed(); + } catch (EncodeException e) { + throw invalidInput(); + } catch (FeignException e) { + throw requestFailed(e.status(), isRetryable(e.status())); + } + } + + public ExpoReceiptResponse getReceipts(List ticketIds) { + validateReceiptIds(ticketIds); + + try { + ExpoReceiptResponse response = expoPushApi.getReceipts(new ExpoReceiptRequest(ticketIds)); + if (response == null) { + throw parsingFailed(); + } + return response; + } catch (ExpoPushClientException e) { + throw e; + } catch (RetryableException e) { + throw requestFailed(null, true); + } catch (DecodeException e) { + throw parsingFailed(); + } catch (EncodeException e) { + throw invalidInput(); + } catch (FeignException e) { + throw requestFailed(e.status(), isRetryable(e.status())); + } + } + + private void validateSendRequests(List requests) { + if (requests == null || requests.isEmpty()) { + throw invalidInput(); + } + + if (requests.size() > MAX_SEND_REQUEST_COUNT) { + throw invalidInput(); + } + + boolean hasInvalidRequest = requests.stream() + .anyMatch(request -> request == null || !hasText(request.to())); + + if (hasInvalidRequest) { + throw invalidInput(); + } + } + + private void validateReceiptIds(List ticketIds) { + if (ticketIds == null || ticketIds.isEmpty()) { + throw invalidInput(); + } + + if (ticketIds.size() > MAX_RECEIPT_ID_COUNT) { + throw invalidInput(); + } + + boolean hasBlankTicketId = ticketIds.stream() + .anyMatch(ticketId -> !hasText(ticketId)); + + if (hasBlankTicketId) { + throw invalidInput(); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private boolean isRetryable(int status) { + return status == HttpStatus.TOO_MANY_REQUESTS.value() + || (status >= 500 && status < 600); + } + + private ExpoPushClientException invalidInput() { + return new ExpoPushClientException( + "Invalid Expo push client request", + HttpStatus.BAD_REQUEST.value(), + false + ); + } + + private ExpoPushClientException parsingFailed() { + return new ExpoPushClientException( + "Failed to parse Expo push response", + null, + false + ); + } + + private ExpoPushClientException requestFailed( + Integer httpStatus, + boolean retryable + ) { + return new ExpoPushClientException( + "Expo push request failed", + httpStatus, + retryable + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java new file mode 100644 index 00000000..f8508c9a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.client; + +import lombok.Getter; + +@Getter +public class ExpoPushClientException extends RuntimeException { + + private final Integer httpStatus; + private final boolean retryable; + + public ExpoPushClientException( + String message, + Integer httpStatus, + boolean retryable + ) { + super(message); + this.httpStatus = httpStatus; + this.retryable = retryable; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java new file mode 100644 index 00000000..267b1f85 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java @@ -0,0 +1,37 @@ +package devkor.com.teamcback.domain.notification.config; + +import feign.Request; +import feign.RequestInterceptor; +import feign.Retryer; +import java.util.concurrent.TimeUnit; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; + +public class ExpoPushFeignConfig { + + @Bean + public Request.Options expoPushRequestOptions(ExpoPushProperties properties) { + return new Request.Options( + properties.connectTimeout().toMillis(), + TimeUnit.MILLISECONDS, + properties.readTimeout().toMillis(), + TimeUnit.MILLISECONDS, + true + ); + } + + @Bean + public RequestInterceptor expoPushAuthorizationInterceptor(ExpoPushProperties properties) { + return template -> { + if (StringUtils.hasText(properties.accessToken())) { + template.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.accessToken()); + } + }; + } + + @Bean + public Retryer expoPushRetryer() { + return Retryer.NEVER_RETRY; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java new file mode 100644 index 00000000..089a0248 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java @@ -0,0 +1,28 @@ +package devkor.com.teamcback.domain.notification.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.expo") +public record ExpoPushProperties( + String baseUrl, + String accessToken, + Duration connectTimeout, + Duration readTimeout +) { + + public ExpoPushProperties { + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "https://exp.host/--/api/v2/push"; + } + if (accessToken == null) { + accessToken = ""; + } + if (connectTimeout == null) { + connectTimeout = Duration.ofSeconds(3); + } + if (readTimeout == null) { + readTimeout = Duration.ofSeconds(10); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java new file mode 100644 index 00000000..27c37e68 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(ExpoPushProperties.class) +public class ExpoPushPropertiesConfig { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java new file mode 100644 index 00000000..7fe319fe --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushErrorDetails( + String error +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java new file mode 100644 index 00000000..2a4cdec9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushReceipt( + String status, + String message, + ExpoPushErrorDetails details +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java new file mode 100644 index 00000000..9fbd6d15 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushRequest( + String to, + String title, + String body, + Map data +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java new file mode 100644 index 00000000..25649876 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushResponse( + List data +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java new file mode 100644 index 00000000..71bdae8b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushTicket( + String status, + String id, + String message, + ExpoPushErrorDetails details +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java new file mode 100644 index 00000000..11e0f82f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import java.util.List; + +public record ExpoReceiptRequest( + List ids +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java new file mode 100644 index 00000000..e1dd3769 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoReceiptResponse( + Map data +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64b76d02..b808ee08 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -152,3 +152,10 @@ management: staff: emails: leeyejin113@gmail.com,pingdoll3110@naver.com,ku.kodaero@gmail.com + +push: + expo: + base-url: https://exp.host/--/api/v2/push + access-token: ${EXPO_ACCESS_TOKEN:} + connect-timeout: 3s + read-timeout: 10s From dc38a537429022fcaaabd6296f17fe1ff4e00667 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Mon, 3 Aug 2026 18:43:49 +0900 Subject: [PATCH 19/54] =?UTF-8?q?fix:=20Expo=20Push=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/client/ExpoPushClient.java | 53 ++++++++++++++----- .../client/ExpoPushClientException.java | 11 ++++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java index a5aecd48..1fc0ba38 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java @@ -27,20 +27,18 @@ public ExpoPushResponse send(List requests) { try { ExpoPushResponse response = expoPushApi.send(requests); - if (response == null) { - throw parsingFailed(); - } + validateSendResponse(response, requests.size()); return response; } catch (ExpoPushClientException e) { throw e; } catch (RetryableException e) { - throw requestFailed(null, true); + throw requestFailed(null, true, e); } catch (DecodeException e) { - throw parsingFailed(); + throw parsingFailed(e); } catch (EncodeException e) { - throw invalidInput(); + throw invalidInput(e); } catch (FeignException e) { - throw requestFailed(e.status(), isRetryable(e.status())); + throw requestFailed(e.status(), isRetryable(e.status()), e); } } @@ -56,13 +54,22 @@ public ExpoReceiptResponse getReceipts(List ticketIds) { } catch (ExpoPushClientException e) { throw e; } catch (RetryableException e) { - throw requestFailed(null, true); + throw requestFailed(null, true, e); } catch (DecodeException e) { - throw parsingFailed(); + throw parsingFailed(e); } catch (EncodeException e) { - throw invalidInput(); + throw invalidInput(e); } catch (FeignException e) { - throw requestFailed(e.status(), isRetryable(e.status())); + throw requestFailed(e.status(), isRetryable(e.status()), e); + } + } + + private void validateSendResponse( + ExpoPushResponse response, + int requestCount + ) { + if (response == null || response.data() == null || response.data().size() != requestCount) { + throw parsingFailed(); } } @@ -117,6 +124,15 @@ private ExpoPushClientException invalidInput() { ); } + private ExpoPushClientException invalidInput(Throwable cause) { + return new ExpoPushClientException( + "Invalid Expo push client request", + HttpStatus.BAD_REQUEST.value(), + false, + cause + ); + } + private ExpoPushClientException parsingFailed() { return new ExpoPushClientException( "Failed to parse Expo push response", @@ -125,14 +141,25 @@ private ExpoPushClientException parsingFailed() { ); } + private ExpoPushClientException parsingFailed(Throwable cause) { + return new ExpoPushClientException( + "Failed to parse Expo push response", + null, + false, + cause + ); + } + private ExpoPushClientException requestFailed( Integer httpStatus, - boolean retryable + boolean retryable, + Throwable cause ) { return new ExpoPushClientException( "Expo push request failed", httpStatus, - retryable + retryable, + cause ); } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java index f8508c9a..09856dc0 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java @@ -17,4 +17,15 @@ public ExpoPushClientException( this.httpStatus = httpStatus; this.retryable = retryable; } + + public ExpoPushClientException( + String message, + Integer httpStatus, + boolean retryable, + Throwable cause + ) { + super(message, cause); + this.httpStatus = httpStatus; + this.retryable = retryable; + } } From c2f2fbfb4d63757a7b1f16f1418ada4f261f025f Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Mon, 3 Aug 2026 20:32:33 +0900 Subject: [PATCH 20/54] =?UTF-8?q?docs:=20=ED=91=B8=EC=8B=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=84=A4=EB=AA=85=20=EB=B0=8F=20Swa?= =?UTF-8?q?gger=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationTestController.java | 71 +++++ .../dto/expo/ExpoPushRequest.java | 4 +- .../dto/request/NotificationTestReq.java | 19 ++ .../dto/response/NotificationTestRes.java | 12 + .../notification/entity/PushMessage.java | 28 ++ .../service/NotificationTestService.java | 274 ++++++++++++++++++ .../global/jwt/JwtAuthorizationFilter.java | 1 - .../teamcback/global/response/ResultCode.java | 13 +- .../global/security/SecurityConfig.java | 1 + 9 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java new file mode 100644 index 00000000..9ff41f88 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java @@ -0,0 +1,71 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.service.NotificationTestService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationTestController { + + private final NotificationTestService notificationTestService; + + /** + * DEV/PREVIEW 환경에서 실제 기기 푸시 수신을 확인하는 API입니다. + * 테스트 코드가 아닌 애플리케이션 기능입니다. + */ + @Operation( + summary = "푸시 알림 테스트 발송", + description = """ + 인증된 사용자의 본인 DEV/PREVIEW installation 한 대에 + 실제 테스트 푸시를 발송합니다. + PRODUCTION installation은 사용할 수 없습니다. + """ + ) + @PostMapping("/test") + public ResponseEntity> sendTest( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "중복 발송 방지를 위한 멱등성 키", + required = true, + example = "7b347ad7-6138-4cb7-af7d-f5201703a596" + ) + @RequestHeader(value = "Idempotency-Key", required = false) + String idempotencyKey, + + @Valid @RequestBody NotificationTestReq request + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + Long userId = userDetail.getUser().getUserId(); + + return ResponseEntity.ok(CommonResponse.success( + notificationTestService.sendTest( + userId, + idempotencyKey, + request + ) + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java index 9fbd6d15..98b19656 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java @@ -1,13 +1,13 @@ package devkor.com.teamcback.domain.notification.dto.expo; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) public record ExpoPushRequest( String to, String title, String body, - Map data + String sound, + Object data ) { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java new file mode 100644 index 00000000..cf482998 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record NotificationTestReq( + @NotNull + @Min(1) + @Max(1) + Integer schemaVersion, + + @NotBlank + @Size(max = 64) + String installationId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java new file mode 100644 index 00000000..d31cd561 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; + +public record NotificationTestRes( + String notificationId, + String installationId, + AppVariant appVariant, + String ticketStatus, + String ticketId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java index df6c5331..07c28053 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -112,4 +112,32 @@ public PushMessage( this.createdAt = now; this.updatedAt = now; } + + public void recordTicket( + String ticketStatus, + String expoTicketId, + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = expoTicketId; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.sentAt = now; + this.updatedAt = now; + this.status = "ok".equals(ticketStatus) + ? PushMessageStatus.RECEIPT_PENDING + : PushMessageStatus.FAILED; + } + + public void recordClientError( + boolean retryable, + LocalDateTime now + ) { + this.ticketStatus = "client_error"; + this.ticketError = retryable ? "retryable" : "non_retryable"; + this.sendAttempts += 1; + this.updatedAt = now; + this.status = PushMessageStatus.FAILED; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java new file mode 100644 index 00000000..cab6c024 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java @@ -0,0 +1,274 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_NON_RETRYABLE_ERROR; +import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_RETRYABLE_ERROR; +import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_TICKET_ERROR; +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_PUSH_INSTALLATION_VARIANT; + +@Service +@RequiredArgsConstructor +public class NotificationTestService { + + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; + private static final int SCHEMA_VERSION = 1; + private static final String TEST_TITLE = "고대로 테스트 알림"; + private static final String TEST_BODY = "푸시 알림 연결이 정상적으로 동작합니다."; + private static final String DEFAULT_SOUND = "default"; + private static final String TICKET_STATUS_OK = "ok"; + private static final String CLIENT_ERROR_STATUS = "client_error"; + private static final String RETRYABLE_ERROR = "retryable"; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushPayloadFactory pushPayloadFactory; + private final ExpoPushClient expoPushClient; + private final Clock clock; + + public NotificationTestRes sendTest( + Long userId, + String idempotencyKey, + NotificationTestReq request + ) { + validateRequest(userId, idempotencyKey, request); + + PushInstallation installation = findAndValidateInstallation( + userId, + request.installationId() + ); + + return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) + .map(dispatch -> responseFromExistingDispatch(dispatch, installation)) + .orElseGet(() -> createAndSend( + userId, + idempotencyKey, + installation + )); + } + + private NotificationTestRes createAndSend( + Long userId, + String idempotencyKey, + PushInstallation installation + ) { + String notificationId = UUID.randomUUID().toString(); + LocalDateTime now = LocalDateTime.now(clock); + + PushDispatch dispatch; + PushMessage message; + + try { + dispatch = pushDispatchRepository.saveAndFlush(new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + installation.getAppVariant(), + PushTargetType.INSTALLATION, + installation.getInstallationId(), + TEST_TITLE, + TEST_BODY, + PushActionType.TEST, + notificationId, + idempotencyKey, + userId, + now + )); + + message = pushMessageRepository.saveAndFlush(new PushMessage( + dispatch, + installation, + now + )); + + dispatch.updateRecipientCount(1); + pushDispatchRepository.save(dispatch); + } catch (DataIntegrityViolationException e) { + return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) + .map(dispatchFromRace -> responseFromExistingDispatch(dispatchFromRace, installation)) + .orElseThrow(() -> new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR)); + } + + try { + ExpoPushResponse response = expoPushClient.send(List.of(new ExpoPushRequest( + installation.getExpoPushToken(), + TEST_TITLE, + TEST_BODY, + DEFAULT_SOUND, + pushPayloadFactory.create( + notificationId, + TEST_TITLE, + TEST_BODY, + PushMode.TEST, + installation.getAppVariant(), + PushActionType.TEST, + Collections.emptyMap() + ).data() + ))); + + ExpoPushTicket ticket = response.data().get(0); + message.recordTicket( + ticket.status(), + ticket.id(), + ticket.details() == null ? null : ticket.details().error(), + LocalDateTime.now(clock) + ); + pushMessageRepository.save(message); + + if (!TICKET_STATUS_OK.equals(ticket.status())) { + throw new GlobalException(EXPO_PUSH_TICKET_ERROR); + } + + return new NotificationTestRes( + notificationId, + installation.getInstallationId(), + installation.getAppVariant(), + ticket.status(), + ticket.id() + ); + } catch (ExpoPushClientException e) { + message.recordClientError( + e.isRetryable(), + LocalDateTime.now(clock) + ); + pushMessageRepository.save(message); + throw new GlobalException(e.isRetryable() + ? EXPO_PUSH_RETRYABLE_ERROR + : EXPO_PUSH_NON_RETRYABLE_ERROR); + } + } + + private NotificationTestRes responseFromExistingDispatch( + PushDispatch dispatch, + PushInstallation installation + ) { + List messages = pushMessageRepository.findAllByDispatch(dispatch); + + if (messages.size() != 1) { + throw new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR); + } + + PushMessage message = messages.get(0); + + if (!installation.getInstallationId().equals(message.getInstallationId())) { + throw new GlobalException(INVALID_INPUT); + } + + if (message.getTicketStatus() == null) { + throw new GlobalException(EXPO_PUSH_RETRYABLE_ERROR); + } + + if (CLIENT_ERROR_STATUS.equals(message.getTicketStatus())) { + throw new GlobalException(RETRYABLE_ERROR.equals(message.getTicketError()) + ? EXPO_PUSH_RETRYABLE_ERROR + : EXPO_PUSH_NON_RETRYABLE_ERROR); + } + + if (!TICKET_STATUS_OK.equals(message.getTicketStatus())) { + throw new GlobalException(EXPO_PUSH_TICKET_ERROR); + } + + return new NotificationTestRes( + dispatch.getActionParams(), + message.getInstallationId(), + dispatch.getAppVariant(), + message.getTicketStatus(), + message.getExpoTicketId() + ); + } + + private PushInstallation findAndValidateInstallation( + Long userId, + String installationId + ) { + PushInstallation installation = pushInstallationRepository.findByInstallationId(installationId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_PUSH_INSTALLATION)); + + if (!userId.equals(installation.getUserId())) { + throw new GlobalException(FORBIDDEN_PUSH_INSTALLATION); + } + + if (!installation.isActive()) { + throw new GlobalException(INACTIVE_PUSH_INSTALLATION); + } + + if (AppVariant.PRODUCTION.equals(installation.getAppVariant())) { + throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); + } + + if (!AppVariant.DEV.equals(installation.getAppVariant()) + && !AppVariant.PREVIEW.equals(installation.getAppVariant())) { + throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); + } + + if (!hasText(installation.getExpoPushToken())) { + throw new GlobalException(INVALID_INPUT); + } + + return installation; + } + + private void validateRequest( + Long userId, + String idempotencyKey, + NotificationTestReq request + ) { + if (userId == null || request == null || request.schemaVersion() == null + || request.schemaVersion() != SCHEMA_VERSION) { + throw new GlobalException(INVALID_INPUT); + } + + validateText(request.installationId(), 64); + validateText(idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); + + try { + UUID.fromString(idempotencyKey); + } catch (IllegalArgumentException e) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateText( + String value, + int maxLength + ) { + if (!hasText(value) || value.length() > maxLength) { + throw new GlobalException(INVALID_INPUT); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java index 434654cf..72080a15 100644 --- a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java +++ b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java @@ -53,7 +53,6 @@ protected void doFilterInternal( throws ServletException, IOException { String accessToken = jwtUtil.getAccessTokenFromHeader(request); - log.info("Access Token: {}", accessToken); // access token 비어있으면 인증 미처리 if (!StringUtils.hasText(accessToken)) { diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index bad5cd71..6f0b13d7 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -102,9 +102,18 @@ public enum ResultCode { COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 - NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."), + + // notification 17000 + NOT_FOUND_PUSH_INSTALLATION(HttpStatus.NOT_FOUND, 17000, "Push installation not found."), + FORBIDDEN_PUSH_INSTALLATION(HttpStatus.FORBIDDEN, 17001, "Push installation is not owned by the current user."), + INACTIVE_PUSH_INSTALLATION(HttpStatus.BAD_REQUEST, 17002, "Push installation is inactive."), + UNSUPPORTED_PUSH_INSTALLATION_VARIANT(HttpStatus.BAD_REQUEST, 17003, "Push installation variant is not supported for test push."), + EXPO_PUSH_RETRYABLE_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 17004, "Expo push request failed with retryable error."), + EXPO_PUSH_NON_RETRYABLE_ERROR(HttpStatus.BAD_GATEWAY, 17005, "Expo push request failed with non-retryable error."), + EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."); private final HttpStatus status; private final int code; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 50bc866a..a5bcfecf 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -93,6 +93,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 + .requestMatchers(HttpMethod.POST, "/api/notifications/test").authenticated() .anyRequest().permitAll() ).exceptionHandling(ex -> ex .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 From 210ec000fb9b56203bcdde4060fe6d812f100457 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 14:05:12 +0900 Subject: [PATCH 21/54] =?UTF-8?q?feat:=20Expo=20push=20ticket=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20worker=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/ExpoPushPropertiesConfig.java | 5 +- .../config/PushWorkerProperties.java | 26 ++ .../notification/dto/worker/PushSendItem.java | 10 + .../dto/worker/PushWorkerDispatchResult.java | 11 + .../notification/entity/PushDispatch.java | 28 ++ .../notification/entity/PushMessage.java | 63 ++++ .../factory/PushPayloadFactory.java | 14 + .../PushInstallationRepository.java | 6 + .../repository/PushMessageRepository.java | 48 +++ .../scheduler/PushMessageWorkerScheduler.java | 20 ++ .../service/PushMessageClaimService.java | 282 ++++++++++++++++++ .../service/PushMessageDispatchWorker.java | 47 +++ src/main/resources/application.yml | 6 + 13 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java index 27c37e68..a72123a7 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java @@ -4,6 +4,9 @@ import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(ExpoPushProperties.class) +@EnableConfigurationProperties({ + ExpoPushProperties.class, + PushWorkerProperties.class +}) public class ExpoPushPropertiesConfig { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java new file mode 100644 index 00000000..3845cf4e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.worker") +public record PushWorkerProperties( + boolean enabled, + int batchSize, + int maxSendAttempts, + long retryDelayMs +) { + + private static final int MAX_EXPO_BATCH_SIZE = 100; + + public PushWorkerProperties { + if (batchSize <= 0 || batchSize > MAX_EXPO_BATCH_SIZE) { + batchSize = MAX_EXPO_BATCH_SIZE; + } + if (maxSendAttempts <= 0) { + maxSendAttempts = 3; + } + if (retryDelayMs <= 0) { + retryDelayMs = 30_000L; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java new file mode 100644 index 00000000..8a861bb5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; + +public record PushSendItem( + Long pushMessageId, + Long pushDispatchId, + ExpoPushRequest request +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java new file mode 100644 index 00000000..dccff995 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushWorkerDispatchResult( + int claimedCount, + int sentCount +) { + + public static PushWorkerDispatchResult empty() { + return new PushWorkerDispatchResult(0, 0); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java index 6c8da70a..1a6a8468 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java @@ -125,4 +125,32 @@ public PushDispatch( public void updateRecipientCount(int recipientCount) { this.recipientCount = recipientCount; } + + public void updateStatusFromMessageSummary( + long queuedCount, + long sendingCount, + long successCount, + long failedCount, + LocalDateTime now + ) { + if (queuedCount > 0 || sendingCount > 0) { + this.status = PushDispatchStatus.PROCESSING; + return; + } + + if (failedCount == 0 && successCount == recipientCount) { + this.status = PushDispatchStatus.COMPLETED; + this.completedAt = now; + return; + } + + if (failedCount == recipientCount) { + this.status = PushDispatchStatus.FAILED; + this.completedAt = now; + return; + } + + this.status = PushDispatchStatus.PARTIAL_FAILED; + this.completedAt = now; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java index 07c28053..b37c17f9 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -128,6 +128,34 @@ public void recordTicket( this.status = "ok".equals(ticketStatus) ? PushMessageStatus.RECEIPT_PENDING : PushMessageStatus.FAILED; + this.nextRetryAt = null; + } + + public void markSending(LocalDateTime now) { + this.status = PushMessageStatus.SENDING; + this.updatedAt = now; + } + + public void recordRetryableTicketError( + String ticketStatus, + String ticketError, + int maxSendAttempts, + LocalDateTime nextRetryAt, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = null; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.sentAt = now; + this.updatedAt = now; + if (sendAttempts < maxSendAttempts) { + this.status = PushMessageStatus.QUEUED; + this.nextRetryAt = nextRetryAt; + return; + } + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; } public void recordClientError( @@ -139,5 +167,40 @@ public void recordClientError( this.sendAttempts += 1; this.updatedAt = now; this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + } + + public void recordClientError( + boolean retryable, + String ticketError, + int maxSendAttempts, + LocalDateTime nextRetryAt, + LocalDateTime now + ) { + this.ticketStatus = "client_error"; + this.expoTicketId = null; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.updatedAt = now; + if (retryable && sendAttempts < maxSendAttempts) { + this.status = PushMessageStatus.QUEUED; + this.nextRetryAt = nextRetryAt; + return; + } + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + } + + public void recordSkipped( + String ticketStatus, + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = null; + this.ticketError = ticketError; + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.updatedAt = now; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java index 37717bff..fb0d7e0c 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.notification.factory; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; @@ -90,6 +91,19 @@ public String serializeActionParams(Map actionParams) { } } + public Map deserializeActionParams(String actionParams) { + if (actionParams == null || actionParams.isBlank()) { + return Map.of(); + } + + try { + return objectMapper.readValue(actionParams, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } + private void validateText(String value) { if (value == null || value.isBlank()) { throw new GlobalException(INVALID_INPUT); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 319e7210..828af896 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -35,4 +35,10 @@ List findAllByUserIdAndAppVariantAndActiveTrue( Long userId, AppVariant appVariant ); + + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( + Long pushInstallationId, + String installationId, + AppVariant appVariant + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java index 594350d3..f0660c15 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java @@ -2,12 +2,60 @@ import devkor.com.teamcback.domain.notification.entity.PushDispatch; import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import java.time.LocalDateTime; import java.util.List; +import java.util.Collection; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PushMessageRepository extends JpaRepository { List findAllByDispatch( PushDispatch dispatch ); + + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'QUEUED' + AND (next_retry_at IS NULL OR next_retry_at <= :now) + ORDER BY created_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDueQueuedForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + @EntityGraph(attributePaths = "dispatch") + List findAllByPushMessageIdIn( + Collection pushMessageIds + ); + + @Query(""" + SELECT m.dispatch.pushDispatchId AS dispatchId, + m.status AS status, + COUNT(m) AS count + FROM PushMessage m + WHERE m.dispatch.pushDispatchId IN :dispatchIds + GROUP BY m.dispatch.pushDispatchId, m.status + """) + List countStatusesByDispatchIds( + @Param("dispatchIds") Collection dispatchIds + ); + + interface PushDispatchMessageStatusCount { + Long getDispatchId(); + + PushMessageStatus getStatus(); + + long getCount(); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java new file mode 100644 index 00000000..9b2067d8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.worker", name = "enabled", havingValue = "true") +public class PushMessageWorkerScheduler { + + private final PushMessageDispatchWorker pushMessageDispatchWorker; + + @Scheduled(fixedDelayString = "${push.worker.fixed-delay-ms:5000}") + public void dispatchQueuedMessages() { + pushMessageDispatchWorker.dispatchPending(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java new file mode 100644 index 00000000..962c1087 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java @@ -0,0 +1,282 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushMessageClaimService { + + private static final String DEFAULT_SOUND = "default"; + private static final String TICKET_STATUS_ERROR = "error"; + private static final String RETRYABLE_TICKET_ERROR = "MessageRateExceeded"; + + private final PushMessageRepository pushMessageRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushPayloadFactory pushPayloadFactory; + private final PushWorkerProperties pushWorkerProperties; + private final Clock clock; + + @Transactional + public List claimDueMessages() { + LocalDateTime now = LocalDateTime.now(clock); + List messages = pushMessageRepository.findDueQueuedForUpdateSkipLocked( + now, + pushWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return List.of(); + } + + Set dispatchIds = new HashSet<>(); + List sendItems = messages.stream() + .peek(message -> { + message.markSending(now); + dispatchIds.add(message.getDispatch().getPushDispatchId()); + }) + .map(message -> createSendItem(message, now)) + .filter(item -> item != null) + .toList(); + + refreshDispatchStatuses(dispatchIds, now); + return sendItems; + } + + @Transactional + public void recordTickets( + List items, + List tickets + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + for (int i = 0; i < items.size(); i += 1) { + PushSendItem item = items.get(i); + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + continue; + } + + ExpoPushTicket ticket = tickets == null || i >= tickets.size() ? null : tickets.get(i); + String ticketError = ticketError(ticket); + if (isRetryableTicket(ticket)) { + message.recordRetryableTicketError( + ticket.status(), + ticketError, + pushWorkerProperties.maxSendAttempts(), + nextRetryAt, + now + ); + } else { + message.recordTicket( + ticket == null ? "missing_ticket" : ticket.status(), + ticket == null ? null : ticket.id(), + ticketError, + now + ); + } + dispatchIds.add(item.pushDispatchId()); + } + + refreshDispatchStatuses(dispatchIds, now); + } + + @Transactional + public void recordClientError( + List items, + boolean retryable, + String ticketError + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + items.forEach(item -> { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + return; + } + message.recordClientError( + retryable, + truncate(ticketError), + pushWorkerProperties.maxSendAttempts(), + nextRetryAt, + now + ); + dispatchIds.add(item.pushDispatchId()); + }); + + refreshDispatchStatuses(dispatchIds, now); + } + + private PushSendItem createSendItem( + PushMessage message, + LocalDateTime now + ) { + PushDispatch dispatch = message.getDispatch(); + PushInstallation installation = pushInstallationRepository + .findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( + message.getPushInstallationId(), + message.getInstallationId(), + dispatch.getAppVariant() + ) + .orElse(null); + + if (installation == null) { + message.recordSkipped( + "installation_inactive", + "inactive_or_variant_mismatch", + now + ); + return null; + } + + try { + PushPayload payload = pushPayloadFactory.create( + String.valueOf(message.getPushMessageId()), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getActionType(), + pushPayloadFactory.deserializeActionParams(dispatch.getActionParams()) + ); + + return new PushSendItem( + message.getPushMessageId(), + dispatch.getPushDispatchId(), + new ExpoPushRequest( + installation.getExpoPushToken(), + payload.title(), + payload.body(), + DEFAULT_SOUND, + payload.data() + ) + ); + } catch (RuntimeException e) { + message.recordSkipped( + "invalid_payload", + "payload_validation_failed", + now + ); + return null; + } + } + + private Map findMessageMap(List items) { + List messageIds = items.stream() + .map(PushSendItem::pushMessageId) + .toList(); + + return pushMessageRepository.findAllByPushMessageIdIn(messageIds) + .stream() + .collect(Collectors.toMap( + PushMessage::getPushMessageId, + message -> message + )); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING) + + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + }); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean isRetryableTicket(ExpoPushTicket ticket) { + return ticket != null + && TICKET_STATUS_ERROR.equals(ticket.status()) + && RETRYABLE_TICKET_ERROR.equals(ticketError(ticket)); + } + + private String ticketError(ExpoPushTicket ticket) { + if (ticket == null) { + return "missing_ticket"; + } + + if (ticket.details() != null && ticket.details().error() != null) { + return truncate(ticket.details().error()); + } + + return truncate(ticket.message()); + } + + private String truncate(String value) { + if (value == null || value.length() <= 1024) { + return value; + } + return value.substring(0, 1024); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java new file mode 100644 index 00000000..e2e43f50 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.dto.worker.PushWorkerDispatchResult; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushMessageDispatchWorker { + + private final PushMessageClaimService pushMessageClaimService; + private final ExpoPushClient expoPushClient; + + public PushWorkerDispatchResult dispatchPending() { + List items = pushMessageClaimService.claimDueMessages(); + if (items.isEmpty()) { + return PushWorkerDispatchResult.empty(); + } + + try { + ExpoPushResponse response = expoPushClient.send(items.stream() + .map(PushSendItem::request) + .toList()); + pushMessageClaimService.recordTickets(items, response.data()); + return new PushWorkerDispatchResult(items.size(), items.size()); + } catch (ExpoPushClientException e) { + pushMessageClaimService.recordClientError( + items, + e.isRetryable(), + e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() + ); + log.warn( + "Expo push send failed. retryable={}, itemCount={}", + e.isRetryable(), + items.size() + ); + return new PushWorkerDispatchResult(items.size(), 0); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b808ee08..965e1d9f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -159,3 +159,9 @@ push: access-token: ${EXPO_ACCESS_TOKEN:} connect-timeout: 3s read-timeout: 10s + worker: + enabled: ${PUSH_WORKER_ENABLED:false} + fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} + batch-size: ${PUSH_WORKER_BATCH_SIZE:100} + max-send-attempts: ${PUSH_WORKER_MAX_SEND_ATTEMPTS:3} + retry-delay-ms: ${PUSH_WORKER_RETRY_DELAY_MS:30000} From 8a4d0bc4371d86f9342f5a852ed89b236714e493 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 15:26:41 +0900 Subject: [PATCH 22/54] =?UTF-8?q?feat=20:=20expo=20push=20receip=20worker?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/ExpoPushPropertiesConfig.java | 3 +- .../config/PushReceiptWorkerProperties.java | 22 ++ .../dto/response/NotificationTestRes.java | 3 +- .../dto/worker/PushReceiptItem.java | 8 + .../dto/worker/PushReceiptWorkerResult.java | 11 + .../notification/entity/PushMessage.java | 62 ++++ .../repository/PushMessageRepository.java | 20 ++ .../scheduler/PushReceiptWorkerScheduler.java | 20 ++ .../service/NotificationTestService.java | 99 +----- .../service/PushMessageClaimService.java | 27 +- .../service/PushReceiptClaimService.java | 281 ++++++++++++++++ .../service/PushReceiptWorker.java | 47 +++ src/main/resources/application.yml | 5 + ..._add_push_message_receipt_available_at.sql | 2 + .../PushMessageRepositoryQueryTest.java | 26 ++ .../service/NotificationTestServiceTest.java | 193 +++++++++++ .../service/PushMessageClaimServiceTest.java | 200 ++++++++++++ .../service/PushReceiptClaimServiceTest.java | 305 ++++++++++++++++++ 18 files changed, 1243 insertions(+), 91 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java create mode 100644 src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java index a72123a7..ca72a6a6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java @@ -6,7 +6,8 @@ @Configuration @EnableConfigurationProperties({ ExpoPushProperties.class, - PushWorkerProperties.class + PushWorkerProperties.class, + PushReceiptWorkerProperties.class }) public class ExpoPushPropertiesConfig { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java new file mode 100644 index 00000000..c543652b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.receipt-worker") +public record PushReceiptWorkerProperties( + boolean enabled, + int batchSize, + int maxReceiptAttempts +) { + + private static final int MAX_EXPO_RECEIPT_BATCH_SIZE = 1000; + + public PushReceiptWorkerProperties { + if (batchSize <= 0 || batchSize > MAX_EXPO_RECEIPT_BATCH_SIZE) { + batchSize = MAX_EXPO_RECEIPT_BATCH_SIZE; + } + if (maxReceiptAttempts <= 0) { + maxReceiptAttempts = 3; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java index d31cd561..32d659d0 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java @@ -1,12 +1,13 @@ package devkor.com.teamcback.domain.notification.dto.response; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; public record NotificationTestRes( String notificationId, String installationId, AppVariant appVariant, - String ticketStatus, + PushMessageStatus messageStatus, String ticketId ) { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java new file mode 100644 index 00000000..b6bda67b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushReceiptItem( + Long pushMessageId, + Long pushDispatchId, + String expoTicketId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java new file mode 100644 index 00000000..0e22543a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushReceiptWorkerResult( + int claimedCount, + int checkedCount +) { + + public static PushReceiptWorkerResult empty() { + return new PushReceiptWorkerResult(0, 0); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java index b37c17f9..7cad5d7a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -81,6 +81,9 @@ public class PushMessage { @Column(name = "sent_at") private LocalDateTime sentAt; + @Column(name = "receipt_available_at") + private LocalDateTime receiptAvailableAt; + @Column(name = "receipt_checked_at") private LocalDateTime receiptCheckedAt; @@ -108,6 +111,7 @@ public PushMessage( this.receiptAttempts = 0; this.nextRetryAt = null; this.sentAt = null; + this.receiptAvailableAt = null; this.receiptCheckedAt = null; this.createdAt = now; this.updatedAt = now; @@ -128,6 +132,7 @@ public void recordTicket( this.status = "ok".equals(ticketStatus) ? PushMessageStatus.RECEIPT_PENDING : PushMessageStatus.FAILED; + this.receiptAvailableAt = "ok".equals(ticketStatus) ? now.plusMinutes(15) : null; this.nextRetryAt = null; } @@ -136,6 +141,57 @@ public void markSending(LocalDateTime now) { this.updatedAt = now; } + public void markReceiptChecking(LocalDateTime now) { + this.status = PushMessageStatus.SENDING; + this.updatedAt = now; + } + + public void recordReceipt( + String receiptStatus, + String receiptError, + LocalDateTime now + ) { + this.receiptStatus = receiptStatus; + this.receiptError = "ok".equals(receiptStatus) ? null : receiptError; + this.receiptAttempts += 1; + this.receiptCheckedAt = now; + this.receiptAvailableAt = null; + this.updatedAt = now; + this.status = "ok".equals(receiptStatus) + ? PushMessageStatus.DELIVERED + : PushMessageStatus.FAILED; + } + + public void scheduleReceiptRetry( + String receiptStatus, + String receiptError, + int maxReceiptAttempts, + LocalDateTime nextReceiptAvailableAt, + LocalDateTime now + ) { + this.receiptStatus = receiptStatus; + this.receiptError = receiptError; + this.receiptAttempts += 1; + this.receiptCheckedAt = now; + this.updatedAt = now; + if (receiptAttempts < maxReceiptAttempts) { + this.status = PushMessageStatus.RECEIPT_PENDING; + this.receiptAvailableAt = nextReceiptAvailableAt; + return; + } + this.status = PushMessageStatus.FAILED; + this.receiptAvailableAt = null; + } + + public void recordReceiptExpired(LocalDateTime now) { + this.receiptStatus = "expired"; + this.receiptError = "receipt_expired"; + this.receiptCheckedAt = now; + this.receiptAvailableAt = null; + this.updatedAt = now; + this.status = PushMessageStatus.FAILED; + } + public void recordRetryableTicketError( String ticketStatus, String ticketError, @@ -152,10 +208,12 @@ public void recordRetryableTicketError( if (sendAttempts < maxSendAttempts) { this.status = PushMessageStatus.QUEUED; this.nextRetryAt = nextRetryAt; + this.receiptAvailableAt = null; return; } this.status = PushMessageStatus.FAILED; this.nextRetryAt = null; + this.receiptAvailableAt = null; } public void recordClientError( @@ -168,6 +226,7 @@ public void recordClientError( this.updatedAt = now; this.status = PushMessageStatus.FAILED; this.nextRetryAt = null; + this.receiptAvailableAt = null; } public void recordClientError( @@ -185,10 +244,12 @@ public void recordClientError( if (retryable && sendAttempts < maxSendAttempts) { this.status = PushMessageStatus.QUEUED; this.nextRetryAt = nextRetryAt; + this.receiptAvailableAt = null; return; } this.status = PushMessageStatus.FAILED; this.nextRetryAt = null; + this.receiptAvailableAt = null; } public void recordSkipped( @@ -201,6 +262,7 @@ public void recordSkipped( this.ticketError = ticketError; this.status = PushMessageStatus.FAILED; this.nextRetryAt = null; + this.receiptAvailableAt = null; this.updatedAt = now; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java index f0660c15..4067379f 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java @@ -34,6 +34,26 @@ List findDueQueuedForUpdateSkipLocked( @Param("limit") int limit ); + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'RECEIPT_PENDING' + AND expo_ticket_id IS NOT NULL + AND expo_ticket_id <> '' + AND receipt_available_at IS NOT NULL + AND receipt_available_at <= :now + ORDER BY receipt_available_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDueReceiptPendingForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + @EntityGraph(attributePaths = "dispatch") List findAllByPushMessageIdIn( Collection pushMessageIds diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java new file mode 100644 index 00000000..a7608f91 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.receipt-worker", name = "enabled", havingValue = "true") +public class PushReceiptWorkerScheduler { + + private final PushReceiptWorker pushReceiptWorker; + + @Scheduled(fixedDelayString = "${push.receipt-worker.fixed-delay-ms:60000}") + public void checkPendingReceipts() { + pushReceiptWorker.checkPendingReceipts(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java index cab6c024..a3592b5a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java @@ -1,10 +1,5 @@ package devkor.com.teamcback.domain.notification.service; -import devkor.com.teamcback.domain.notification.client.ExpoPushClient; -import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; import devkor.com.teamcback.domain.notification.entity.PushDispatch; @@ -30,8 +25,6 @@ import org.springframework.stereotype.Service; import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_NON_RETRYABLE_ERROR; -import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_RETRYABLE_ERROR; -import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_TICKET_ERROR; import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN_PUSH_INSTALLATION; import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_PUSH_INSTALLATION; import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; @@ -46,16 +39,11 @@ public class NotificationTestService { private static final int SCHEMA_VERSION = 1; private static final String TEST_TITLE = "고대로 테스트 알림"; private static final String TEST_BODY = "푸시 알림 연결이 정상적으로 동작합니다."; - private static final String DEFAULT_SOUND = "default"; - private static final String TICKET_STATUS_OK = "ok"; - private static final String CLIENT_ERROR_STATUS = "client_error"; - private static final String RETRYABLE_ERROR = "retryable"; private final PushInstallationRepository pushInstallationRepository; private final PushDispatchRepository pushDispatchRepository; private final PushMessageRepository pushMessageRepository; private final PushPayloadFactory pushPayloadFactory; - private final ExpoPushClient expoPushClient; private final Clock clock; public NotificationTestRes sendTest( @@ -72,26 +60,22 @@ public NotificationTestRes sendTest( return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) .map(dispatch -> responseFromExistingDispatch(dispatch, installation)) - .orElseGet(() -> createAndSend( + .orElseGet(() -> enqueue( userId, idempotencyKey, installation )); } - private NotificationTestRes createAndSend( + private NotificationTestRes enqueue( Long userId, String idempotencyKey, PushInstallation installation ) { - String notificationId = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(clock); - PushDispatch dispatch; - PushMessage message; - try { - dispatch = pushDispatchRepository.saveAndFlush(new PushDispatch( + PushDispatch dispatch = pushDispatchRepository.saveAndFlush(new PushDispatch( NotificationType.GENERAL, PushMode.TEST, installation.getAppVariant(), @@ -100,13 +84,13 @@ private NotificationTestRes createAndSend( TEST_TITLE, TEST_BODY, PushActionType.TEST, - notificationId, + pushPayloadFactory.serializeActionParams(Collections.emptyMap()), idempotencyKey, userId, now )); - message = pushMessageRepository.saveAndFlush(new PushMessage( + PushMessage message = pushMessageRepository.saveAndFlush(new PushMessage( dispatch, installation, now @@ -114,59 +98,13 @@ private NotificationTestRes createAndSend( dispatch.updateRecipientCount(1); pushDispatchRepository.save(dispatch); + + return response(dispatch, message); } catch (DataIntegrityViolationException e) { return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) .map(dispatchFromRace -> responseFromExistingDispatch(dispatchFromRace, installation)) .orElseThrow(() -> new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR)); } - - try { - ExpoPushResponse response = expoPushClient.send(List.of(new ExpoPushRequest( - installation.getExpoPushToken(), - TEST_TITLE, - TEST_BODY, - DEFAULT_SOUND, - pushPayloadFactory.create( - notificationId, - TEST_TITLE, - TEST_BODY, - PushMode.TEST, - installation.getAppVariant(), - PushActionType.TEST, - Collections.emptyMap() - ).data() - ))); - - ExpoPushTicket ticket = response.data().get(0); - message.recordTicket( - ticket.status(), - ticket.id(), - ticket.details() == null ? null : ticket.details().error(), - LocalDateTime.now(clock) - ); - pushMessageRepository.save(message); - - if (!TICKET_STATUS_OK.equals(ticket.status())) { - throw new GlobalException(EXPO_PUSH_TICKET_ERROR); - } - - return new NotificationTestRes( - notificationId, - installation.getInstallationId(), - installation.getAppVariant(), - ticket.status(), - ticket.id() - ); - } catch (ExpoPushClientException e) { - message.recordClientError( - e.isRetryable(), - LocalDateTime.now(clock) - ); - pushMessageRepository.save(message); - throw new GlobalException(e.isRetryable() - ? EXPO_PUSH_RETRYABLE_ERROR - : EXPO_PUSH_NON_RETRYABLE_ERROR); - } } private NotificationTestRes responseFromExistingDispatch( @@ -185,25 +123,18 @@ private NotificationTestRes responseFromExistingDispatch( throw new GlobalException(INVALID_INPUT); } - if (message.getTicketStatus() == null) { - throw new GlobalException(EXPO_PUSH_RETRYABLE_ERROR); - } - - if (CLIENT_ERROR_STATUS.equals(message.getTicketStatus())) { - throw new GlobalException(RETRYABLE_ERROR.equals(message.getTicketError()) - ? EXPO_PUSH_RETRYABLE_ERROR - : EXPO_PUSH_NON_RETRYABLE_ERROR); - } - - if (!TICKET_STATUS_OK.equals(message.getTicketStatus())) { - throw new GlobalException(EXPO_PUSH_TICKET_ERROR); - } + return response(dispatch, message); + } + private NotificationTestRes response( + PushDispatch dispatch, + PushMessage message + ) { return new NotificationTestRes( - dispatch.getActionParams(), + String.valueOf(message.getPushMessageId()), message.getInstallationId(), dispatch.getAppVariant(), - message.getTicketStatus(), + message.getStatus(), message.getExpoTicketId() ); } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java index 962c1087..f1301b39 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java @@ -34,6 +34,7 @@ public class PushMessageClaimService { private static final String DEFAULT_SOUND = "default"; private static final String TICKET_STATUS_ERROR = "error"; private static final String RETRYABLE_TICKET_ERROR = "MessageRateExceeded"; + private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; private final PushMessageRepository pushMessageRepository; private final PushInstallationRepository pushInstallationRepository; @@ -91,7 +92,16 @@ public void recordTickets( ExpoPushTicket ticket = tickets == null || i >= tickets.size() ? null : tickets.get(i); String ticketError = ticketError(ticket); - if (isRetryableTicket(ticket)) { + if (isDeviceNotRegisteredTicket(ticket)) { + message.recordTicket( + ticket.status(), + ticket.id(), + ticketError, + now + ); + pushInstallationRepository.findById(message.getPushInstallationId()) + .ifPresent(installation -> installation.deactivate(now)); + } else if (isRetryableTicket(ticket)) { message.recordRetryableTicketError( ticket.status(), ticketError, @@ -237,11 +247,11 @@ private void refreshDispatchStatuses( new EnumMap<>(PushMessageStatus.class) ); dispatch.updateStatusFromMessageSummary( - count(counts, PushMessageStatus.QUEUED), + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), count(counts, PushMessageStatus.SENDING), - count(counts, PushMessageStatus.TICKET_RECEIVED) - + count(counts, PushMessageStatus.RECEIPT_PENDING) - + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.DELIVERED), count(counts, PushMessageStatus.FAILED), now ); @@ -261,6 +271,13 @@ private boolean isRetryableTicket(ExpoPushTicket ticket) { && RETRYABLE_TICKET_ERROR.equals(ticketError(ticket)); } + private boolean isDeviceNotRegisteredTicket(ExpoPushTicket ticket) { + return ticket != null + && TICKET_STATUS_ERROR.equals(ticket.status()) + && ticket.details() != null + && DEVICE_NOT_REGISTERED_ERROR.equals(ticket.details().error()); + } + private String ticketError(ExpoPushTicket ticket) { if (ticket == null) { return "missing_ticket"; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java new file mode 100644 index 00000000..fb477a4d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java @@ -0,0 +1,281 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushReceiptClaimService { + + private static final String RECEIPT_STATUS_OK = "ok"; + private static final String RECEIPT_STATUS_ERROR = "error"; + private static final String RECEIPT_STATUS_NOT_READY = "not_ready"; + private static final String CLIENT_ERROR_STATUS = "client_error"; + private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; + + private final PushMessageRepository pushMessageRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushReceiptWorkerProperties pushReceiptWorkerProperties; + private final Clock clock; + + @Transactional + public List claimDueReceipts() { + LocalDateTime now = LocalDateTime.now(clock); + List messages = pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked( + now, + pushReceiptWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return List.of(); + } + + Set dispatchIds = new HashSet<>(); + List receiptItems = messages.stream() + .map(message -> claimMessage(message, now, dispatchIds)) + .filter(item -> item != null) + .toList(); + + refreshDispatchStatuses(dispatchIds, now); + return receiptItems; + } + + @Transactional + public void recordReceipts( + List items, + Map receiptMap + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + for (PushReceiptItem item : items) { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + continue; + } + + ExpoPushReceipt receipt = receiptMap == null ? null : receiptMap.get(item.expoTicketId()); + if (receipt == null) { + scheduleRetry(message, RECEIPT_STATUS_NOT_READY, "receipt_not_ready", now); + } else if (isDeviceNotRegisteredReceipt(receipt)) { + message.recordReceipt( + receipt.status(), + receiptError(receipt), + now + ); + pushInstallationRepository.findById(message.getPushInstallationId()) + .ifPresent(installation -> installation.deactivate(now)); + } else { + message.recordReceipt( + receipt.status(), + receiptError(receipt), + now + ); + } + dispatchIds.add(item.pushDispatchId()); + } + + refreshDispatchStatuses(dispatchIds, now); + } + + @Transactional + public void recordClientError( + List items, + boolean retryable, + String receiptError + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + items.forEach(item -> { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + return; + } + if (retryable) { + scheduleRetry(message, CLIENT_ERROR_STATUS, truncate(receiptError), now); + } else { + message.recordReceipt( + CLIENT_ERROR_STATUS, + truncate(receiptError), + now + ); + } + dispatchIds.add(item.pushDispatchId()); + }); + + refreshDispatchStatuses(dispatchIds, now); + } + + private PushReceiptItem claimMessage( + PushMessage message, + LocalDateTime now, + Set dispatchIds + ) { + dispatchIds.add(message.getDispatch().getPushDispatchId()); + + if (isReceiptExpired(message, now)) { + message.recordReceiptExpired(now); + return null; + } + + message.markReceiptChecking(now); + return new PushReceiptItem( + message.getPushMessageId(), + message.getDispatch().getPushDispatchId(), + message.getExpoTicketId() + ); + } + + private void scheduleRetry( + PushMessage message, + String receiptStatus, + String receiptError, + LocalDateTime now + ) { + if (isReceiptExpired(message, now)) { + message.recordReceiptExpired(now); + return; + } + + message.scheduleReceiptRetry( + receiptStatus, + receiptError, + pushReceiptWorkerProperties.maxReceiptAttempts(), + now.plusMinutes(nextBackoffMinutes(message.getReceiptAttempts())), + now + ); + } + + private long nextBackoffMinutes(int currentReceiptAttempts) { + int retryNumber = Math.max(0, currentReceiptAttempts); + return 1L << Math.min(retryNumber, 2); + } + + private boolean isReceiptExpired( + PushMessage message, + LocalDateTime now + ) { + return message.getSentAt() == null + || message.getSentAt().plusHours(24).isBefore(now); + } + + private Map findMessageMap(List items) { + List messageIds = items.stream() + .map(PushReceiptItem::pushMessageId) + .toList(); + + return pushMessageRepository.findAllByPushMessageIdIn(messageIds) + .stream() + .collect(Collectors.toMap( + PushMessage::getPushMessageId, + message -> message + )); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); + } + + private void updateDispatchStatus( + PushDispatch dispatch, + Map> countsByDispatchId, + LocalDateTime now + ) { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean isDeviceNotRegisteredReceipt(ExpoPushReceipt receipt) { + return receipt != null + && RECEIPT_STATUS_ERROR.equals(receipt.status()) + && receipt.details() != null + && DEVICE_NOT_REGISTERED_ERROR.equals(receipt.details().error()); + } + + private String receiptError(ExpoPushReceipt receipt) { + if (receipt == null || RECEIPT_STATUS_OK.equals(receipt.status())) { + return null; + } + + if (receipt.details() != null && receipt.details().error() != null) { + return truncate(receipt.details().error()); + } + + return truncate(receipt.message()); + } + + private String truncate(String value) { + if (value == null || value.length() <= 1024) { + return value; + } + return value.substring(0, 1024); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java new file mode 100644 index 00000000..a5319bad --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptWorkerResult; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushReceiptWorker { + + private final PushReceiptClaimService pushReceiptClaimService; + private final ExpoPushClient expoPushClient; + + public PushReceiptWorkerResult checkPendingReceipts() { + List items = pushReceiptClaimService.claimDueReceipts(); + if (items.isEmpty()) { + return PushReceiptWorkerResult.empty(); + } + + try { + ExpoReceiptResponse response = expoPushClient.getReceipts(items.stream() + .map(PushReceiptItem::expoTicketId) + .toList()); + pushReceiptClaimService.recordReceipts(items, response.data()); + return new PushReceiptWorkerResult(items.size(), items.size()); + } catch (ExpoPushClientException e) { + pushReceiptClaimService.recordClientError( + items, + e.isRetryable(), + e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() + ); + log.warn( + "Expo push receipt request failed. retryable={}, itemCount={}", + e.isRetryable(), + items.size() + ); + return new PushReceiptWorkerResult(items.size(), 0); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 965e1d9f..e28a68e3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -165,3 +165,8 @@ push: batch-size: ${PUSH_WORKER_BATCH_SIZE:100} max-send-attempts: ${PUSH_WORKER_MAX_SEND_ATTEMPTS:3} retry-delay-ms: ${PUSH_WORKER_RETRY_DELAY_MS:30000} + receipt-worker: + enabled: ${PUSH_RECEIPT_WORKER_ENABLED:false} + fixed-delay-ms: ${PUSH_RECEIPT_WORKER_FIXED_DELAY_MS:60000} + batch-size: ${PUSH_RECEIPT_WORKER_BATCH_SIZE:1000} + max-receipt-attempts: ${PUSH_RECEIPT_WORKER_MAX_RECEIPT_ATTEMPTS:3} diff --git a/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql b/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql new file mode 100644 index 00000000..a755793d --- /dev/null +++ b/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE tb_push_message + ADD COLUMN receipt_available_at datetime(6) NULL AFTER sent_at; diff --git a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java new file mode 100644 index 00000000..2d861a58 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.notification.repository; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.Query; + +import static org.assertj.core.api.Assertions.assertThat; + +class PushMessageRepositoryQueryTest { + + @Test + void dueReceiptQueryTargetsOnlyReceiptPendingMessagesWithAvailableReceipts() throws Exception { + Method method = PushMessageRepository.class.getMethod( + "findDueReceiptPendingForUpdateSkipLocked", + java.time.LocalDateTime.class, + int.class + ); + + String query = method.getAnnotation(Query.class).value(); + + assertThat(query).contains("status = 'RECEIPT_PENDING'"); + assertThat(query).contains("expo_ticket_id IS NOT NULL"); + assertThat(query).contains("receipt_available_at <= :now"); + assertThat(query).contains("FOR UPDATE SKIP LOCKED"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java new file mode 100644 index 00000000..e286a5a3 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java @@ -0,0 +1,193 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationTestServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Test + void sendTestEnqueuesQueuedMessageWithoutExpoClientDependency() { + PushInstallation installation = installation(); + when(pushInstallationRepository.findByInstallationId("install-1")) + .thenReturn(Optional.of(installation)); + when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) + .thenReturn(Optional.empty()); + when(pushPayloadFactory.serializeActionParams(any())) + .thenReturn("{}"); + when(pushDispatchRepository.saveAndFlush(any(PushDispatch.class))) + .thenAnswer(invocation -> { + PushDispatch dispatch = invocation.getArgument(0); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + }); + when(pushMessageRepository.saveAndFlush(any(PushMessage.class))) + .thenAnswer(invocation -> { + PushMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + }); + + NotificationTestService service = service(); + NotificationTestRes response = service.sendTest( + 1L, + "7b347ad7-6138-4cb7-af7d-f5201703a596", + new NotificationTestReq(1, "install-1") + ); + + assertThat(response.notificationId()).isEqualTo("20"); + assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); + assertThat(response.ticketId()).isNull(); + assertThat(hasExpoPushClientField()).isFalse(); + } + + @Test + void sendTestReturnsExistingDispatchMessageForSameIdempotencyKey() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + + when(pushInstallationRepository.findByInstallationId("install-1")) + .thenReturn(Optional.of(installation)); + when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) + .thenReturn(Optional.of(dispatch)); + when(pushMessageRepository.findAllByDispatch(dispatch)) + .thenReturn(List.of(message)); + + NotificationTestRes response = service().sendTest( + 1L, + "7b347ad7-6138-4cb7-af7d-f5201703a596", + new NotificationTestReq(1, "install-1") + ); + + assertThat(response.notificationId()).isEqualTo("20"); + assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); + verify(pushDispatchRepository, never()).saveAndFlush(any(PushDispatch.class)); + verify(pushMessageRepository, never()).saveAndFlush(any(PushMessage.class)); + } + + @Test + void receiptPendingDoesNotCompleteDispatch() { + PushDispatch dispatch = dispatch(); + dispatch.updateRecipientCount(1); + + dispatch.updateStatusFromMessageSummary( + 1, + 0, + 0, + 0, + java.time.LocalDateTime.now(CLOCK) + ); + + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + assertThat(dispatch.getCompletedAt()).isNull(); + } + + private NotificationTestService service() { + return new NotificationTestService( + pushInstallationRepository, + pushDispatchRepository, + pushMessageRepository, + pushPayloadFactory, + CLOCK + ); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + java.time.LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + return new PushMessage( + dispatch, + installation, + java.time.LocalDateTime.now(CLOCK) + ); + } + + private boolean hasExpoPushClientField() { + return Arrays.stream(NotificationTestService.class.getDeclaredFields()) + .map(Field::getType) + .anyMatch(ExpoPushClient.class::equals); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java new file mode 100644 index 00000000..4cf6eec4 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java @@ -0,0 +1,200 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushMessageClaimServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Test + void ticketOkStoresReceiptAvailableAtFifteenMinutesLater() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + + stubRecordTickets(message, dispatch, PushMessageStatus.RECEIPT_PENDING); + + service().recordTickets( + List.of(new PushSendItem(20L, 10L, null)), + List.of(new ExpoPushTicket("ok", "ticket-1", null, null)) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); + assertThat(message.getReceiptAvailableAt()) + .isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(15)); + } + + @Test + void deviceNotRegisteredTicketFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + + stubRecordTickets(message, dispatch, PushMessageStatus.FAILED); + when(pushInstallationRepository.findById(100L)) + .thenReturn(Optional.of(installation)); + + service().recordTickets( + List.of(new PushSendItem(20L, 10L, null)), + List.of(new ExpoPushTicket( + "error", + null, + null, + new ExpoPushErrorDetails("DeviceNotRegistered") + )) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptAvailableAt()).isNull(); + assertThat(installation.isActive()).isFalse(); + } + + private void stubRecordTickets( + PushMessage message, + PushDispatch dispatch, + PushMessageStatus status + ) { + when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) + .thenReturn(List.of(message)); + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(List.of(new StatusCount(10L, status, 1L))); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushMessageClaimService service() { + return new PushMessageClaimService( + pushMessageRepository, + pushInstallationRepository, + pushDispatchRepository, + pushPayloadFactory, + new PushWorkerProperties(true, 100, 3, 30_000L), + CLOCK + ); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java new file mode 100644 index 00000000..749e1c29 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java @@ -0,0 +1,305 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushReceiptClaimServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Test + void claimDueReceiptsMarksDueReceiptPendingMessagesAsSending() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) + .thenReturn(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.SENDING, 1L))); + + List items = service().claimDueReceipts(); + + assertThat(items).containsExactly(new PushReceiptItem(20L, 10L, "ticket-1")); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); + } + + @Test + void receiptOkMarksDeliveredByTicketIdMap() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage first = receiptPendingMessage(dispatch, installation, "ticket-1"); + PushMessage second = receiptPendingMessage(dispatch, installation, "ticket-2"); + ReflectionTestUtils.setField(second, "pushMessageId", 21L); + + stubMessages(List.of(first, second)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.DELIVERED, 2L))); + + service().recordReceipts( + List.of( + new PushReceiptItem(20L, 10L, "ticket-1"), + new PushReceiptItem(21L, 10L, "ticket-2") + ), + Map.of( + "ticket-2", new ExpoPushReceipt("ok", null, null), + "ticket-1", new ExpoPushReceipt("error", "failed", new ExpoPushErrorDetails("MessageTooBig")) + ) + ); + + assertThat(first.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(first.getReceiptError()).isEqualTo("MessageTooBig"); + assertThat(second.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); + assertThat(second.getReceiptError()).isNull(); + } + + @Test + void deviceNotRegisteredReceiptFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + when(pushInstallationRepository.findById(100L)) + .thenReturn(Optional.of(installation)); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of("ticket-1", new ExpoPushReceipt( + "error", + null, + new ExpoPushErrorDetails("DeviceNotRegistered") + )) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(installation.isActive()).isFalse(); + } + + @Test + void missingReceiptSchedulesLimitedRetry() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of() + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getReceiptAttempts()).isEqualTo(1); + assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(1)); + } + + @Test + void maxReceiptAttemptsMarksFailed() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + ReflectionTestUtils.setField(message, "receiptAttempts", 2); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of() + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptAvailableAt()).isNull(); + } + + @Test + void sentMoreThanTwentyFourHoursAgoExpiresWithoutReceiptRequestItem() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + ReflectionTestUtils.setField(message, "sentAt", LocalDateTime.now(CLOCK).minusHours(25)); + + when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) + .thenReturn(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + List items = service().claimDueReceipts(); + + assertThat(items).isEmpty(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptError()).isEqualTo("receipt_expired"); + } + + @Test + void dispatchSummaryUsesOnlyFinalStatusesForCompletion() { + PushDispatch allDelivered = dispatch(); + allDelivered.updateRecipientCount(2); + allDelivered.updateStatusFromMessageSummary(0, 0, 2, 0, LocalDateTime.now(CLOCK)); + assertThat(allDelivered.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); + + PushDispatch partial = dispatch(); + partial.updateRecipientCount(2); + partial.updateStatusFromMessageSummary(0, 0, 1, 1, LocalDateTime.now(CLOCK)); + assertThat(partial.getStatus()).isEqualTo(PushDispatchStatus.PARTIAL_FAILED); + + PushDispatch failed = dispatch(); + failed.updateRecipientCount(2); + failed.updateStatusFromMessageSummary(0, 0, 0, 2, LocalDateTime.now(CLOCK)); + assertThat(failed.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + + PushDispatch processing = dispatch(); + processing.updateRecipientCount(2); + processing.updateStatusFromMessageSummary(1, 0, 1, 0, LocalDateTime.now(CLOCK)); + assertThat(processing.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + } + + private PushReceiptClaimService service() { + return new PushReceiptClaimService( + pushMessageRepository, + pushInstallationRepository, + pushDispatchRepository, + new PushReceiptWorkerProperties(true, 1000, 3), + CLOCK + ); + } + + private void stubMessages(List messages) { + when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) + .thenReturn(messages); + } + + private void stubStatusCounts( + PushDispatch dispatch, + List counts + ) { + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(counts); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage receiptPendingMessage( + PushDispatch dispatch, + PushInstallation installation, + String ticketId + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK).minusMinutes(20) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + message.recordTicket("ok", ticketId, null, LocalDateTime.now(CLOCK).minusMinutes(20)); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} From d7091b592e6e5a8685b76a3e7e146ddaf4a30515 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 16:03:59 +0900 Subject: [PATCH 23/54] =?UTF-8?q?test:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...shNotificationPipelineIntegrationTest.java | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java diff --git a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java new file mode 100644 index 00000000..f97ab02e --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java @@ -0,0 +1,342 @@ +package devkor.com.teamcback.domain.notification; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.service.NotificationTestService; +import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; +import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.redisson.api.RedissonClient; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.url=jdbc:h2:mem:push_pipeline;MODE=MySQL;DATABASE_TO_LOWER=TRUE;NON_KEYWORDS=YEAR,END;DB_CLOSE_DELAY=-1", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.sql.init.mode=never", + "spring.cache.type=simple", + "spring.data.redis.host=localhost", + "spring.data.redis.port=6379", + "spring.data.redis.password=test", + "push.worker.enabled=false", + "push.receipt-worker.enabled=false", + "jwt.secret.key=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", + "jwt.admin.token=test-admin-token", + "jwt.social.kakao.iss=test-kakao-iss", + "jwt.social.kakao.aud=test-kakao-aud", + "jwt.social.google.iss=test-google-iss", + "jwt.social.google.aud=test-google-aud", + "jwt.social.apple.iss=test-apple-iss", + "jwt.social.apple.aud=test-apple-aud", + "jwt.social.apple.dev-aud=test-apple-dev-aud", + "metrics.environment=test", + "staff.emails=test@example.com", + "cloud.aws.s3.bucket=test-bucket", + "cloud.aws.credentials.access-key=test-access-key", + "cloud.aws.credentials.secret-key=test-secret-key", + "cloud.aws.region.static=ap-northeast-2", + "date.api.holiday.end-point=http://localhost", + "date.api.holiday.encoded-key=test-encoded-key", + "date.api.holiday.decoded-key=test-decoded-key", + "spring.mail.host=localhost", + "management.health.mail.enabled=false" +}) +class PushNotificationPipelineIntegrationTest { + + private static final String EXPO_PUSH_TOKEN = "ExponentPushToken[pipeline-test-token]"; + private static final String TICKET_ID = "ticket-pipeline-1"; + private static final MockExpoServer EXPO_SERVER = new MockExpoServer(); + + @Autowired + private NotificationTestService notificationTestService; + + @Autowired + private PushMessageDispatchWorker pushMessageDispatchWorker; + + @Autowired + private PushReceiptWorker pushReceiptWorker; + + @Autowired + private PushInstallationRepository pushInstallationRepository; + + @Autowired + private PushDispatchRepository pushDispatchRepository; + + @Autowired + private PushMessageRepository pushMessageRepository; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private RedissonClient redissonClient; + + @DynamicPropertySource + static void expoProperties(DynamicPropertyRegistry registry) { + EXPO_SERVER.start(); + registry.add("push.expo.base-url", EXPO_SERVER::baseUrl); + } + + @AfterAll + static void stopExpoServer() { + EXPO_SERVER.stop(); + } + + @BeforeEach + void setUp() { + EXPO_SERVER.reset(); + pushMessageRepository.deleteAll(); + pushDispatchRepository.deleteAll(); + pushInstallationRepository.deleteAll(); + } + + @Test + void testNotificationPipelineQueuesSendsReceiptsAndCompletesDispatch() throws Exception { + PushInstallation installation = pushInstallationRepository.save(new PushInstallation( + 1L, + "install-pipeline", + EXPO_PUSH_TOKEN, + AppVariant.DEV + )); + + notificationTestService.sendTest( + 1L, + UUID.randomUUID().toString(), + new NotificationTestReq(1, installation.getInstallationId()) + ); + + List dispatches = pushDispatchRepository.findAll(); + List messages = pushMessageRepository.findAll(); + assertThat(dispatches).hasSize(1); + assertThat(messages).hasSize(1); + + PushDispatch dispatch = dispatches.get(0); + PushMessage queuedMessage = messages.get(0); + assertThat(dispatch.getRecipientCount()).isEqualTo(1); + assertThat(queuedMessage.getStatus()).isEqualTo(PushMessageStatus.QUEUED); + assertThat(EXPO_SERVER.requests()).isEmpty(); + + EXPO_SERVER.enqueueJson(200, """ + {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} + """); + pushMessageDispatchWorker.dispatchPending(); + + PushMessage receiptPendingMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); + assertThat(receiptPendingMessage.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(receiptPendingMessage.getExpoTicketId()).isEqualTo(TICKET_ID); + assertThat(receiptPendingMessage.getReceiptAvailableAt()).isNotNull(); + + RecordedRequest sendRequest = EXPO_SERVER.takeOnlyRequest(); + assertThat(sendRequest.path()).isEqualTo("/send"); + JsonNode sendBody = objectMapper.readTree(sendRequest.body()); + JsonNode firstSend = sendBody.get(0); + assertThat(firstSend.get("to").asText()).isEqualTo(EXPO_PUSH_TOKEN); + assertThat(firstSend.at("/data/action/type").asText()).isEqualTo(PushActionType.TEST.name()); + assertThat(firstSend.at("/data/notificationId").asText()) + .isEqualTo(String.valueOf(receiptPendingMessage.getPushMessageId())); + + ReflectionTestUtils.setField( + receiptPendingMessage, + "receiptAvailableAt", + LocalDateTime.now().minusMinutes(1) + ); + pushMessageRepository.saveAndFlush(receiptPendingMessage); + + EXPO_SERVER.reset(); + EXPO_SERVER.enqueueJson(200, """ + {"data":{"ticket-pipeline-1":{"status":"ok"}}} + """); + pushReceiptWorker.checkPendingReceipts(); + + PushMessage deliveredMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); + PushDispatch completedDispatch = pushDispatchRepository.findById(dispatch.getPushDispatchId()).orElseThrow(); + assertThat(deliveredMessage.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); + assertThat(deliveredMessage.getReceiptStatus()).isEqualTo("ok"); + assertThat(deliveredMessage.getReceiptCheckedAt()).isNotNull(); + assertThat(completedDispatch.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); + assertThat(pushMessageRepository.findById(deliveredMessage.getPushMessageId())).isPresent(); + assertThat(pushDispatchRepository.findById(completedDispatch.getPushDispatchId())).isPresent(); + + RecordedRequest receiptRequest = EXPO_SERVER.takeOnlyRequest(); + assertThat(receiptRequest.path()).isEqualTo("/getReceipts"); + JsonNode receiptBody = objectMapper.readTree(receiptRequest.body()); + JsonNode ids = receiptBody.get("ids"); + assertThat(ids) + .as("receipt request body: " + receiptRequest.body()) + .isNotNull(); + assertThat(ids.isArray()).isTrue(); + assertThat(ids.get(0).asText()).isEqualTo(TICKET_ID); + } + + @Test + void receiptDeviceNotRegisteredFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = pushInstallationRepository.save(new PushInstallation( + 1L, + "install-device-not-registered", + EXPO_PUSH_TOKEN, + AppVariant.DEV + )); + + notificationTestService.sendTest( + 1L, + UUID.randomUUID().toString(), + new NotificationTestReq(1, installation.getInstallationId()) + ); + + EXPO_SERVER.enqueueJson(200, """ + {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} + """); + pushMessageDispatchWorker.dispatchPending(); + + PushMessage receiptPendingMessage = pushMessageRepository.findAll().get(0); + ReflectionTestUtils.setField( + receiptPendingMessage, + "receiptAvailableAt", + LocalDateTime.now().minusMinutes(1) + ); + pushMessageRepository.saveAndFlush(receiptPendingMessage); + + EXPO_SERVER.reset(); + EXPO_SERVER.enqueueJson(200, """ + {"data":{"ticket-pipeline-1":{"status":"error","details":{"error":"DeviceNotRegistered"}}}} + """); + pushReceiptWorker.checkPendingReceipts(); + + PushMessage failedMessage = pushMessageRepository.findById(receiptPendingMessage.getPushMessageId()).orElseThrow(); + PushInstallation deactivatedInstallation = pushInstallationRepository.findById(installation.getPushInstallationId()) + .orElseThrow(); + PushDispatch failedDispatch = pushDispatchRepository.findAll().get(0); + + assertThat(failedMessage.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(failedMessage.getReceiptStatus()).isEqualTo("error"); + assertThat(failedMessage.getReceiptError()).isEqualTo("DeviceNotRegistered"); + assertThat(deactivatedInstallation.isActive()).isFalse(); + assertThat(failedDispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + } + + private record RecordedRequest(String path, String body) { + } + + private static final class MockExpoServer { + + private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); + private final List requests = new ArrayList<>(); + private HttpServer server; + + void start() { + if (server != null) { + return; + } + + try { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + + server.createContext("/", this::handle); + server.start(); + } + + void stop() { + if (server != null) { + server.stop(0); + } + } + + String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + void reset() { + responses.clear(); + synchronized (requests) { + requests.clear(); + } + } + + void enqueueJson( + int status, + String body + ) { + responses.add(status + "\n" + body); + } + + List requests() { + synchronized (requests) { + return List.copyOf(requests); + } + } + + RecordedRequest takeOnlyRequest() { + List snapshot = requests(); + assertThat(snapshot).hasSize(1); + return snapshot.get(0); + } + + private void handle(HttpExchange exchange) throws IOException { + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + synchronized (requests) { + requests.add(new RecordedRequest(exchange.getRequestURI().getPath(), requestBody)); + } + + String rawResponse = responses.poll(); + if (rawResponse == null) { + write(exchange, 500, "{\"errors\":[{\"message\":\"missing mock response\"}]}"); + return; + } + + int delimiter = rawResponse.indexOf('\n'); + int status = Integer.parseInt(rawResponse.substring(0, delimiter)); + String body = rawResponse.substring(delimiter + 1); + write(exchange, status, body); + } + + private void write( + HttpExchange exchange, + int status, + String body + ) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(bytes); + } + } + } +} From 49a1a08b2c2451322c01b58b3e3f9267f97580ec Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 16:45:37 +0900 Subject: [PATCH 24/54] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20Logback=20=EC=84=A4=EC=A0=95=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 --- src/test/resources/logback-test.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/test/resources/logback-test.xml diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..1f46bb17 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + From 66942d008a416d82f0543703bb405044a4275f74 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 16:56:44 +0900 Subject: [PATCH 25/54] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20re?= =?UTF-8?q?ceipt=20=EC=BB=AC=EB=9F=BC=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V20260804_01__add_push_message_receipt_available_at.sql | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql diff --git a/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql b/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql deleted file mode 100644 index a755793d..00000000 --- a/src/main/resources/db/migration/V20260804_01__add_push_message_receipt_available_at.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE tb_push_message - ADD COLUMN receipt_available_at datetime(6) NULL AFTER sent_at; From 198316bb2e576e6c0c997b1e51fb2ef317865f95 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 21:31:48 +0900 Subject: [PATCH 26/54] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20worker=20?= =?UTF-8?q?=EA=B3=A0=EC=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/ExpoPushPropertiesConfig.java | 3 +- .../config/PushRecoveryWorkerProperties.java | 24 ++ .../notification/entity/PushMessage.java | 22 ++ .../repository/PushMessageRepository.java | 17 ++ .../PushMessageRecoveryScheduler.java | 20 ++ .../service/PushMessageRecoveryService.java | 112 +++++++++ .../service/PushMessageRecoveryWorker.java | 21 ++ src/main/resources/application.yml | 5 + .../PushMessageRepositoryQueryTest.java | 16 ++ .../PushMessageRecoverySchedulerTest.java | 36 +++ .../PushMessageRecoveryServiceTest.java | 229 ++++++++++++++++++ 11 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java index ca72a6a6..5bef5bd8 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java @@ -7,7 +7,8 @@ @EnableConfigurationProperties({ ExpoPushProperties.class, PushWorkerProperties.class, - PushReceiptWorkerProperties.class + PushReceiptWorkerProperties.class, + PushRecoveryWorkerProperties.class }) public class ExpoPushPropertiesConfig { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java new file mode 100644 index 00000000..5d4172df --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.recovery-worker") +public record PushRecoveryWorkerProperties( + boolean enabled, + String cron, + long staleThresholdMinutes, + int batchSize +) { + + private static final int DEFAULT_BATCH_SIZE = 100; + private static final long DEFAULT_STALE_THRESHOLD_MINUTES = 30L; + + public PushRecoveryWorkerProperties { + if (staleThresholdMinutes <= 0) { + staleThresholdMinutes = DEFAULT_STALE_THRESHOLD_MINUTES; + } + if (batchSize <= 0) { + batchSize = DEFAULT_BATCH_SIZE; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java index 7cad5d7a..d7a2f1c4 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -265,4 +265,26 @@ public void recordSkipped( this.receiptAvailableAt = null; this.updatedAt = now; } + + public void recoverInterruptedSendingWithoutTicket( + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = "worker_interrupted"; + this.expoTicketId = null; + this.ticketError = ticketError; + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + this.updatedAt = now; + } + + public void recoverInterruptedSendingWithTicket(LocalDateTime now) { + this.status = PushMessageStatus.RECEIPT_PENDING; + this.nextRetryAt = null; + if (this.receiptAvailableAt == null) { + this.receiptAvailableAt = now; + } + this.updatedAt = now; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java index 4067379f..d9e0a380 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java @@ -54,6 +54,23 @@ List findDueReceiptPendingForUpdateSkipLocked( @Param("limit") int limit ); + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'SENDING' + AND updated_at <= :staleBefore + ORDER BY updated_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findStaleSendingForUpdateSkipLocked( + @Param("staleBefore") LocalDateTime staleBefore, + @Param("limit") int limit + ); + @EntityGraph(attributePaths = "dispatch") List findAllByPushMessageIdIn( Collection pushMessageIds diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java new file mode 100644 index 00000000..bccb1922 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.recovery-worker", name = "enabled", havingValue = "true") +public class PushMessageRecoveryScheduler { + + private final PushMessageRecoveryWorker pushMessageRecoveryWorker; + + @Scheduled(cron = "${push.recovery-worker.cron:0 0 4 * * *}") + public void recoverStaleSendingMessages() { + pushMessageRecoveryWorker.recoverStaleSendingMessages(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java new file mode 100644 index 00000000..32aed3d4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java @@ -0,0 +1,112 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushMessageRecoveryService { + + private static final String WORKER_INTERRUPTED = "worker_interrupted"; + + private final PushMessageRepository pushMessageRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushRecoveryWorkerProperties pushRecoveryWorkerProperties; + private final Clock clock; + + @Transactional + public int recoverStaleSendingMessages() { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime staleBefore = now.minusMinutes(pushRecoveryWorkerProperties.staleThresholdMinutes()); + List messages = pushMessageRepository.findStaleSendingForUpdateSkipLocked( + staleBefore, + pushRecoveryWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return 0; + } + + Set dispatchIds = new HashSet<>(); + messages.forEach(message -> { + dispatchIds.add(message.getDispatch().getPushDispatchId()); + if (hasText(message.getExpoTicketId())) { + message.recoverInterruptedSendingWithTicket(now); + return; + } + message.recoverInterruptedSendingWithoutTicket(WORKER_INTERRUPTED, now); + }); + + refreshDispatchStatuses(dispatchIds, now); + return messages.size(); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); + } + + private void updateDispatchStatus( + PushDispatch dispatch, + Map> countsByDispatchId, + LocalDateTime now + ) { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java new file mode 100644 index 00000000..6ac46825 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushMessageRecoveryWorker { + + private final PushMessageRecoveryService pushMessageRecoveryService; + + public int recoverStaleSendingMessages() { + int recoveredCount = pushMessageRecoveryService.recoverStaleSendingMessages(); + if (recoveredCount > 0) { + log.warn("Recovered stale SENDING push messages. count={}", recoveredCount); + } + return recoveredCount; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e28a68e3..022d76ff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -170,3 +170,8 @@ push: fixed-delay-ms: ${PUSH_RECEIPT_WORKER_FIXED_DELAY_MS:60000} batch-size: ${PUSH_RECEIPT_WORKER_BATCH_SIZE:1000} max-receipt-attempts: ${PUSH_RECEIPT_WORKER_MAX_RECEIPT_ATTEMPTS:3} + recovery-worker: + enabled: ${PUSH_RECOVERY_WORKER_ENABLED:false} + cron: ${PUSH_RECOVERY_WORKER_CRON:0 0 4 * * *} + stale-threshold-minutes: ${PUSH_RECOVERY_STALE_THRESHOLD_MINUTES:30} + batch-size: ${PUSH_RECOVERY_BATCH_SIZE:100} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java index 2d861a58..6539def0 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java @@ -23,4 +23,20 @@ void dueReceiptQueryTargetsOnlyReceiptPendingMessagesWithAvailableReceipts() thr assertThat(query).contains("receipt_available_at <= :now"); assertThat(query).contains("FOR UPDATE SKIP LOCKED"); } + + @Test + void staleSendingQueryTargetsOnlyOldSendingMessages() throws Exception { + Method method = PushMessageRepository.class.getMethod( + "findStaleSendingForUpdateSkipLocked", + java.time.LocalDateTime.class, + int.class + ); + + String query = method.getAnnotation(Query.class).value(); + + assertThat(query).contains("status = 'SENDING'"); + assertThat(query).contains("updated_at <= :staleBefore"); + assertThat(query).contains("LIMIT :limit"); + assertThat(query).contains("FOR UPDATE SKIP LOCKED"); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java new file mode 100644 index 00000000..ca7f3706 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java @@ -0,0 +1,36 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PushMessageRecoverySchedulerTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(PushMessageRecoveryWorker.class, () -> mock(PushMessageRecoveryWorker.class)) + .withUserConfiguration(TestConfig.class); + + @Test + void schedulerIsDisabledByDefault() { + contextRunner.run(context -> assertThat(context) + .doesNotHaveBean(PushMessageRecoveryScheduler.class)); + } + + @Test + void schedulerIsDisabledWhenPropertyIsFalse() { + contextRunner + .withPropertyValues("push.recovery-worker.enabled=false") + .run(context -> assertThat(context) + .doesNotHaveBean(PushMessageRecoveryScheduler.class)); + } + + @Configuration + @Import(PushMessageRecoveryScheduler.class) + static class TestConfig { + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java new file mode 100644 index 00000000..271233d3 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java @@ -0,0 +1,229 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushMessageRecoveryServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Test + void staleSendingWithoutTicketFailsWithWorkerInterrupted() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + message.markSending(LocalDateTime.now(CLOCK).minusMinutes(31)); + + stubStaleMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isEqualTo(1); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getTicketStatus()).isEqualTo("worker_interrupted"); + assertThat(message.getTicketError()).isEqualTo("worker_interrupted"); + assertThat(message.getReceiptAvailableAt()).isNull(); + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + } + + @Test + void staleSendingWithTicketRecoversToReceiptPendingWithoutResend() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + message.recordTicket("ok", "ticket-1", null, LocalDateTime.now(CLOCK).minusMinutes(40)); + message.markReceiptChecking(LocalDateTime.now(CLOCK).minusMinutes(31)); + ReflectionTestUtils.setField(message, "receiptAvailableAt", null); + + stubStaleMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isEqualTo(1); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); + assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK)); + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + } + + @Test + void nonStaleSendingIsNotChangedWhenQueryDoesNotReturnIt() { + PushMessage message = message(dispatch(), installation()); + message.markSending(LocalDateTime.now(CLOCK).minusMinutes(29)); + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(List.of()); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isZero(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); + } + + @Test + void nonSendingMessageIsNotChangedWhenQueryDoesNotReturnIt() { + PushMessage message = message(dispatch(), installation()); + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(List.of()); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isZero(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.QUEUED); + } + + @Test + void recoveryUsesConfiguredStaleThresholdAndBatchSize() { + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(7))) + .thenReturn(List.of()); + + service(45, 7).recoverStaleSendingMessages(); + + ArgumentCaptor staleBeforeCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(pushMessageRepository).findStaleSendingForUpdateSkipLocked(staleBeforeCaptor.capture(), eq(7)); + assertThat(staleBeforeCaptor.getValue()).isEqualTo(LocalDateTime.now(CLOCK).minusMinutes(45)); + } + + private PushMessageRecoveryService service( + long staleThresholdMinutes, + int batchSize + ) { + return new PushMessageRecoveryService( + pushMessageRepository, + pushDispatchRepository, + new PushRecoveryWorkerProperties(true, "0 0 4 * * *", staleThresholdMinutes, batchSize), + CLOCK + ); + } + + private void stubStaleMessages(List messages) { + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(messages); + } + + private void stubStatusCounts( + PushDispatch dispatch, + List counts + ) { + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(counts); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} From 53d712a3b745abb2f2df0667783d1fcf9d223dbd Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 22:01:22 +0900 Subject: [PATCH 27/54] =?UTF-8?q?fix:=20recovery=20worker=20cron=20YAML=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 022d76ff..cabd8658 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -172,6 +172,6 @@ push: max-receipt-attempts: ${PUSH_RECEIPT_WORKER_MAX_RECEIPT_ATTEMPTS:3} recovery-worker: enabled: ${PUSH_RECOVERY_WORKER_ENABLED:false} - cron: ${PUSH_RECOVERY_WORKER_CRON:0 0 4 * * *} + cron: "${PUSH_RECOVERY_WORKER_CRON:0 0 4 * * *}" stale-threshold-minutes: ${PUSH_RECOVERY_STALE_THRESHOLD_MINUTES:30} batch-size: ${PUSH_RECOVERY_BATCH_SIZE:100} From 8e97b3b159ba5874d9fdada47dc8efd89be0d4b7 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 22:09:05 +0900 Subject: [PATCH 28/54] =?UTF-8?q?fix:=20recovery=20worker=20cron=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/scheduler/PushMessageRecoveryScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java index bccb1922..29961b2e 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java @@ -13,7 +13,7 @@ public class PushMessageRecoveryScheduler { private final PushMessageRecoveryWorker pushMessageRecoveryWorker; - @Scheduled(cron = "${push.recovery-worker.cron:0 0 4 * * *}") + @Scheduled(cron = "${push.recovery-worker.cron}") public void recoverStaleSendingMessages() { pushMessageRecoveryWorker.recoverStaleSendingMessages(); } From 66312d43ce91991949ff6dd4a86280ec2bce1c84 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 4 Aug 2026 23:03:49 +0900 Subject: [PATCH 29/54] =?UTF-8?q?fix:=20push=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20test=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/PushNotificationPipelineIntegrationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java index f97ab02e..f312a787 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java @@ -33,6 +33,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.util.ReflectionTestUtils; @@ -40,6 +41,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@ActiveProfiles("test") @SpringBootTest(properties = { "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.url=jdbc:h2:mem:push_pipeline;MODE=MySQL;DATABASE_TO_LOWER=TRUE;NON_KEYWORDS=YEAR,END;DB_CLOSE_DELAY=-1", From f7945780b3312107bd6cc39ef022f3aed0cf6973 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 17:28:27 +0900 Subject: [PATCH 30/54] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EB=B0=9C=EC=86=A1=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminNotificationController.java | 93 +++++ .../dto/request/AdminPushDispatchReq.java | 20 ++ .../response/AdminPushDispatchDetailRes.java | 58 ++++ .../response/AdminPushDispatchPreviewRes.java | 9 + .../response/AdminPushDispatchSummaryRes.java | 47 +++ .../response/AdminPushInstallationRes.java | 32 ++ .../repository/PushDispatchRepository.java | 19 + .../PushInstallationRepository.java | 4 + .../service/AdminNotificationService.java | 205 +++++++++++ .../service/AdminNotificationServiceTest.java | 326 ++++++++++++++++++ 10 files changed, 813 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java new file mode 100644 index 00000000..9039e74d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java @@ -0,0 +1,93 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/notifications") +public class AdminNotificationController { + + private static final String DEFAULT_PAGE = "1"; + private static final String DEFAULT_SIZE = "20"; + + private final AdminNotificationService adminNotificationService; + + @GetMapping("/installations/search") + public CommonResponse> searchInstallations( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String installationId + ) { + return CommonResponse.success(adminNotificationService.searchInstallations(userId, installationId)); + } + + @PostMapping("/dispatches/preview") + public CommonResponse preview( + @RequestBody AdminPushDispatchReq request + ) { + return CommonResponse.success(adminNotificationService.preview(request)); + } + + @PostMapping("/dispatches") + public CommonResponse enqueue( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @RequestBody AdminPushDispatchReq request + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + return CommonResponse.success(adminNotificationService.enqueue( + userDetail.getUser().getUserId(), + idempotencyKey, + request + )); + } + + @GetMapping("/dispatches") + public CommonResponse> getDispatches( + @RequestParam(defaultValue = DEFAULT_PAGE) int page, + @RequestParam(defaultValue = DEFAULT_SIZE) int size, + @RequestParam(required = false) AppVariant appVariant, + @RequestParam(required = false) PushDispatchStatus status + ) { + return CommonResponse.success(adminNotificationService.getDispatches( + page, + size, + appVariant, + status + )); + } + + @GetMapping("/dispatches/{dispatchId}") + public CommonResponse getDispatch( + @PathVariable Long dispatchId + ) { + return CommonResponse.success(adminNotificationService.getDispatch(dispatchId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java new file mode 100644 index 00000000..80608a33 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.util.Map; + +public record AdminPushDispatchReq( + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + Map actionParams, + Boolean confirm +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java new file mode 100644 index 00000000..3ac470f8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java @@ -0,0 +1,58 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.time.LocalDateTime; +import java.util.Map; + +public record AdminPushDispatchDetailRes( + Long dispatchId, + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + String actionParams, + int recipientCount, + PushDispatchStatus status, + Map messageStatusCounts, + String idempotencyKey, + Long createdBy, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public AdminPushDispatchDetailRes( + PushDispatch dispatch, + Map messageStatusCounts + ) { + this( + dispatch.getPushDispatchId(), + dispatch.getNotificationType(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getTargetType(), + dispatch.getTargetValue(), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getActionType(), + dispatch.getActionParams(), + dispatch.getRecipientCount(), + dispatch.getStatus(), + messageStatusCounts, + dispatch.getIdempotencyKey(), + dispatch.getCreatedBy(), + dispatch.getCreatedAt(), + dispatch.getCompletedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java new file mode 100644 index 00000000..3a32b5bf --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; + +public record AdminPushDispatchPreviewRes( + int recipientCount, + PushPayload payload +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java new file mode 100644 index 00000000..c97e69c6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.time.LocalDateTime; + +public record AdminPushDispatchSummaryRes( + Long dispatchId, + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + int recipientCount, + PushDispatchStatus status, + Long createdBy, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public AdminPushDispatchSummaryRes(PushDispatch dispatch) { + this( + dispatch.getPushDispatchId(), + dispatch.getNotificationType(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getTargetType(), + dispatch.getTargetValue(), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getActionType(), + dispatch.getRecipientCount(), + dispatch.getStatus(), + dispatch.getCreatedBy(), + dispatch.getCreatedAt(), + dispatch.getCompletedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java new file mode 100644 index 00000000..f2cc6bc9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import java.time.LocalDateTime; + +public record AdminPushInstallationRes( + Long userId, + String installationId, + AppVariant appVariant, + boolean active, + LocalDateTime lastActiveAt, + LocalDateTime createdAt, + LocalDateTime modifiedAt, + LocalDateTime deactivatedAt +) { + + public AdminPushInstallationRes(PushInstallation installation) { + this( + installation.getUserId(), + installation.getInstallationId(), + installation.getAppVariant(), + installation.isActive(), + installation.isActive() + ? installation.getModifiedAt() + : installation.getDeactivatedAt(), + installation.getCreatedAt(), + installation.getModifiedAt(), + installation.getDeactivatedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java index f818becb..6571ae74 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java @@ -1,12 +1,31 @@ package devkor.com.teamcback.domain.notification.repository; import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PushDispatchRepository extends JpaRepository { Optional findByIdempotencyKey( String idempotencyKey ); + + @Query(""" + SELECT d + FROM PushDispatch d + WHERE (:appVariant IS NULL OR d.appVariant = :appVariant) + AND (:status IS NULL OR d.status = :status) + ORDER BY d.createdAt DESC, d.pushDispatchId DESC + """) + Page findAdminDispatches( + @Param("appVariant") AppVariant appVariant, + @Param("status") PushDispatchStatus status, + Pageable pageable + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 828af896..283a102b 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -26,6 +26,10 @@ List findAllByUserIdAndActiveTrue( Long userId ); + List findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc( + Long userId + ); + Optional findByInstallationIdAndAppVariantAndActiveTrue( String installationId, AppVariant appVariant diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java new file mode 100644 index 00000000..86b7ff98 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java @@ -0,0 +1,205 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminNotificationService { + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushTargetResolver pushTargetResolver; + private final PushPayloadFactory pushPayloadFactory; + private final PushDispatchService pushDispatchService; + + @Value("${push.admin.production-enabled:false}") + private boolean productionEnabled; + + public List searchInstallations( + Long userId, + String installationId + ) { + if ((userId == null && !hasText(installationId)) + || (userId != null && hasText(installationId))) { + throw new GlobalException(INVALID_INPUT); + } + + if (userId != null) { + return pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(userId) + .stream() + .map(AdminPushInstallationRes::new) + .toList(); + } + + return pushInstallationRepository.findByInstallationId(installationId) + .stream() + .map(AdminPushInstallationRes::new) + .toList(); + } + + public AdminPushDispatchPreviewRes preview(AdminPushDispatchReq request) { + validateTargetRules(request); + + List installations = pushTargetResolver.resolve( + request.targetType(), + request.targetValue(), + request.appVariant() + ); + + PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( + request.title(), + request.body(), + request.mode(), + request.appVariant(), + request.actionType(), + request.actionParams() + ); + + return new AdminPushDispatchPreviewRes( + installations.size(), + payload + ); + } + + @Transactional + public PushDispatchEnqueueRes enqueue( + Long adminUserId, + String idempotencyKey, + AdminPushDispatchReq request + ) { + validateTargetRules(request); + validateProductionGate(request); + + return pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + request.mode(), + request.appVariant(), + request.targetType(), + request.targetValue(), + request.title(), + request.body(), + request.actionType(), + request.actionParams(), + idempotencyKey, + adminUserId + )); + } + + public Page getDispatches( + int page, + int size, + AppVariant appVariant, + PushDispatchStatus status + ) { + if (page < 1 || size < 1) { + throw new GlobalException(INVALID_INPUT); + } + + Pageable pageable = PageRequest.of(page - 1, size); + return pushDispatchRepository.findAdminDispatches(appVariant, status, pageable) + .map(AdminPushDispatchSummaryRes::new); + } + + public AdminPushDispatchDetailRes getDispatch(Long dispatchId) { + if (dispatchId == null) { + throw new GlobalException(INVALID_INPUT); + } + + PushDispatch dispatch = pushDispatchRepository.findById(dispatchId) + .orElseThrow(() -> new GlobalException(INVALID_INPUT)); + + Map statusCounts = zeroStatusCounts(); + pushMessageRepository.countStatusesByDispatchIds(List.of(dispatchId)) + .forEach(count -> statusCounts.put(count.getStatus(), count.getCount())); + + return new AdminPushDispatchDetailRes(dispatch, statusCounts); + } + + private void validateTargetRules(AdminPushDispatchReq request) { + if (request == null + || request.mode() == null + || request.appVariant() == null + || request.targetType() == null) { + throw new GlobalException(INVALID_INPUT); + } + + if (PushTargetType.USER_GROUP.equals(request.targetType())) { + throw new GlobalException(UNSUPPORTED_REQUEST); + } + + if (PushMode.TEST.equals(request.mode()) + && !PushTargetType.INSTALLATION.equals(request.targetType())) { + throw new GlobalException(INVALID_INPUT); + } + + if (PushMode.ACTUAL.equals(request.mode()) + && !PushTargetType.INSTALLATION.equals(request.targetType()) + && !PushTargetType.USER.equals(request.targetType())) { + throw new GlobalException(UNSUPPORTED_REQUEST); + } + } + + private void validateProductionGate(AdminPushDispatchReq request) { + if (!PushMode.ACTUAL.equals(request.mode()) + || !AppVariant.PRODUCTION.equals(request.appVariant())) { + return; + } + + if (!productionEnabled) { + throw new GlobalException(FORBIDDEN); + } + + if (!Boolean.TRUE.equals(request.confirm())) { + throw new GlobalException(INVALID_INPUT); + } + } + + private Map zeroStatusCounts() { + Map statusCounts = new EnumMap<>(PushMessageStatus.class); + for (PushMessageStatus status : PushMessageStatus.values()) { + statusCounts.put(status, 0L); + } + return statusCounts; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java new file mode 100644 index 00000000..607f5710 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java @@ -0,0 +1,326 @@ +package devkor.com.teamcback.domain.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationServiceTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushTargetResolver pushTargetResolver; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Mock + private PushDispatchService pushDispatchService; + + @Test + void searchInstallationsDoesNotExposeExpoPushToken() throws Exception { + PushInstallation installation = installation(AppVariant.DEV); + when(pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(1L)) + .thenReturn(List.of(installation)); + + List response = service(false) + .searchInstallations(1L, null); + + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response); + assertThat(json).contains("installationId"); + assertThat(json).doesNotContain("expoPushToken"); + assertThat(json).doesNotContain("ExponentPushToken"); + } + + @Test + void previewResolvesTargetAndCreatesPayloadWithoutSavingDispatchOrMessages() { + PushInstallation installation = installation(AppVariant.DEV); + PushPayload payload = payload(); + + when(pushTargetResolver.resolve(PushTargetType.INSTALLATION, "install-1", AppVariant.DEV)) + .thenReturn(List.of(installation)); + when(pushPayloadFactory.createForPreDispatchValidation( + "title", + "body", + PushMode.TEST, + AppVariant.DEV, + PushActionType.TEST, + Map.of() + )).thenReturn(payload); + + AdminPushDispatchPreviewRes response = service(false).preview(testRequest()); + + assertThat(response.recipientCount()).isEqualTo(1); + assertThat(response.payload()).isSameAs(payload); + verify(pushDispatchRepository, never()).save(any(PushDispatch.class)); + verify(pushMessageRepository, never()).saveAll(any()); + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void testModeRejectsUserTarget() { + AdminPushDispatchReq request = new AdminPushDispatchReq( + PushMode.TEST, + AppVariant.DEV, + PushTargetType.USER, + "1", + "title", + "body", + PushActionType.TEST, + Map.of(), + null + ); + + assertThatThrownBy(() -> service(false).preview(request)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void productionActualIsRejectedWhenProductionEnabledIsFalse() { + assertThatThrownBy(() -> service(false).enqueue( + 1L, + "key-1", + productionRequest(true) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.FORBIDDEN); + + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void productionActualRequiresConfirmTrue() { + assertThatThrownBy(() -> service(true).enqueue( + 1L, + "key-1", + productionRequest(false) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void enqueueDelegatesToPushDispatchService() { + PushDispatch dispatch = dispatch(PushMode.TEST, AppVariant.DEV, PushTargetType.INSTALLATION); + PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); + when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) + .thenReturn(enqueueResponse); + + PushDispatchEnqueueRes response = service(false).enqueue( + 7L, + "key-1", + testRequest() + ); + + assertThat(response).isSameAs(enqueueResponse); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); + assertThat(command.mode()).isEqualTo(PushMode.TEST); + assertThat(command.appVariant()).isEqualTo(AppVariant.DEV); + assertThat(command.targetType()).isEqualTo(PushTargetType.INSTALLATION); + assertThat(command.idempotencyKey()).isEqualTo("key-1"); + assertThat(command.createdBy()).isEqualTo(7L); + } + + @Test + void getDispatchCountsAllMessageStatuses() { + PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.USER); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(5); + + when(pushDispatchRepository.findById(10L)).thenReturn(Optional.of(dispatch)); + when(pushMessageRepository.countStatusesByDispatchIds(List.of(10L))) + .thenReturn(List.of( + new StatusCount(10L, PushMessageStatus.QUEUED, 1L), + new StatusCount(10L, PushMessageStatus.SENDING, 1L), + new StatusCount(10L, PushMessageStatus.DELIVERED, 2L), + new StatusCount(10L, PushMessageStatus.FAILED, 1L) + )); + + AdminPushDispatchDetailRes response = service(false).getDispatch(10L); + + assertThat(response.recipientCount()).isEqualTo(5); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.QUEUED, 1L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.SENDING, 1L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.TICKET_RECEIVED, 0L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.RECEIPT_PENDING, 0L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.DELIVERED, 2L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.FAILED, 1L); + } + + private AdminNotificationService service(boolean productionEnabled) { + AdminNotificationService service = new AdminNotificationService( + pushInstallationRepository, + pushDispatchRepository, + pushMessageRepository, + pushTargetResolver, + pushPayloadFactory, + pushDispatchService + ); + ReflectionTestUtils.setField(service, "productionEnabled", productionEnabled); + return service; + } + + private AdminPushDispatchReq testRequest() { + return new AdminPushDispatchReq( + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + Map.of(), + null + ); + } + + private AdminPushDispatchReq productionRequest(boolean confirm) { + return new AdminPushDispatchReq( + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.HOME, + Map.of(), + confirm + ); + } + + private PushInstallation installation(AppVariant appVariant) { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + appVariant + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + ReflectionTestUtils.setField(installation, "createdAt", LocalDateTime.parse("2026-08-04T10:00:00")); + ReflectionTestUtils.setField(installation, "modifiedAt", LocalDateTime.parse("2026-08-04T11:00:00")); + return installation; + } + + private PushPayload payload() { + return new PushPayload( + "title", + "body", + new PushPayload.PushPayloadData( + 1, + "00000000-0000-4000-8000-000000000000", + new PushPayload.PushPayloadAction(PushActionType.TEST.name(), Map.of()) + ) + ); + } + + private PushDispatch dispatch( + PushMode mode, + AppVariant appVariant, + PushTargetType targetType + ) { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + mode, + appVariant, + targetType, + targetType == PushTargetType.USER ? "1" : "install-1", + "title", + "body", + mode == PushMode.TEST ? PushActionType.TEST : PushActionType.HOME, + "{}", + "key-1", + 1L, + LocalDateTime.parse("2026-08-04T12:00:00") + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} From d14aa09bf3d91ebeb0322058cf82c6820fcc5590 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 17:30:21 +0900 Subject: [PATCH 31/54] =?UTF-8?q?docs:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20API=20=EC=84=A4=EB=AA=85=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 --- .../AdminNotificationController.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java index 9039e74d..8fa34668 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java @@ -13,6 +13,8 @@ import devkor.com.teamcback.global.response.CommonResponse; import devkor.com.teamcback.global.security.UserDetailsImpl; import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -37,6 +39,14 @@ public class AdminNotificationController { private final AdminNotificationService adminNotificationService; + @Operation( + summary = "푸시 대상 installation 검색", + description = """ + userId 또는 installationId를 기준으로 + 푸시 발송 대상 기기를 조회합니다. + ExpoPushToken 원문은 응답하지 않습니다. + """ + ) @GetMapping("/installations/search") public CommonResponse> searchInstallations( @RequestParam(required = false) Long userId, @@ -45,6 +55,14 @@ public CommonResponse> searchInstallations( return CommonResponse.success(adminNotificationService.searchInstallations(userId, installationId)); } + @Operation( + summary = "관리자 푸시 발송 미리보기", + description = """ + 푸시를 실제로 생성하지 않고 + 대상 기기 수와 최종 payload를 확인합니다. + PushDispatch와 PushMessage는 저장하지 않습니다. + """ + ) @PostMapping("/dispatches/preview") public CommonResponse preview( @RequestBody AdminPushDispatchReq request @@ -52,6 +70,15 @@ public CommonResponse preview( return CommonResponse.success(adminNotificationService.preview(request)); } + + @Operation( + summary = "관리자 푸시 수동 발송", + description = """ + 관리자가 입력한 내용으로 + PushDispatch와 PushMessage를 생성합니다. + 실제 Expo 전송은 기존 비동기 worker가 처리합니다. + """ + ) @PostMapping("/dispatches") public CommonResponse enqueue( @AuthenticationPrincipal UserDetailsImpl userDetail, @@ -69,6 +96,14 @@ public CommonResponse enqueue( )); } + @Operation( + summary = "관리자 푸시 발송 이력 조회", + description = """ + 관리자 푸시 발송 내역을 최신순으로 조회합니다. + appVariant와 발송 상태로 필터링할 수 있습니다. + """ + ) + @GetMapping("/dispatches") public CommonResponse> getDispatches( @RequestParam(defaultValue = DEFAULT_PAGE) int page, @@ -84,6 +119,14 @@ public CommonResponse> getDispatches( )); } + @Operation( + summary = "관리자 푸시 발송 상세 조회", + description = """ + 발송 기본 정보와 전체 대상 수, + 메시지 상태별 처리 건수를 조회합니다. + ExpoPushToken과 개별 메시지 전체 목록은 반환하지 않습니다. + """ + ) @GetMapping("/dispatches/{dispatchId}") public CommonResponse getDispatch( @PathVariable Long dispatchId From a1ec35752e722afb003024c50489cb348be4a104 Mon Sep 17 00:00:00 2001 From: Minwoo Kim <149921142+JokeBear777@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:49:41 +0900 Subject: [PATCH 32/54] =?UTF-8?q?fix:=20ResultCode=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/devkor/com/teamcback/global/response/ResultCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index a91567f3..acfc68ec 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -111,7 +111,7 @@ public enum ResultCode { UNSUPPORTED_PUSH_INSTALLATION_VARIANT(HttpStatus.BAD_REQUEST, 17003, "Push installation variant is not supported for test push."), EXPO_PUSH_RETRYABLE_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 17004, "Expo push request failed with retryable error."), EXPO_PUSH_NON_RETRYABLE_ERROR(HttpStatus.BAD_GATEWAY, 17005, "Expo push request failed with non-retryable error."), - EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."); + EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."), // 캐릭터 18000번대 NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 18000, "캐릭터를 찾을 수 없습니다."), From 457225d23278a7de38527986459a8d587bf36ba7 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 19:41:48 +0900 Subject: [PATCH 33/54] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=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 --- .../ble/event/PlaceBecameVacantEvent.java | 10 ++ .../ble/repository/BLEDataRepository.java | 1 + .../domain/ble/service/BLEService.java | 28 ++++ .../repository/CategoryRepository.java | 11 ++ .../event/CharacterUnlockedEvent.java | 9 ++ .../character/service/AdminStoreService.java | 11 +- .../character/service/StoreService.java | 9 ++ .../entity/type/PushActionType.java | 1 + .../CharacterUnlockedPushEventListener.java | 84 ++++++++++ .../CrowdVacantPushEventListener.java | 137 ++++++++++++++++ .../ReportResolvedPushEventListener.java | 77 +++++++++ .../PushInstallationRepository.java | 5 + .../validation/PushActionValidator.java | 1 + .../report/event/ReportResolvedEvent.java | 10 ++ .../domain/report/service/ReportService.java | 26 ++++ src/main/resources/application.yml | 4 + .../domain/ble/service/BLEServiceTest.java | 144 +++++++++++++++++ .../service/AdminStoreServiceTest.java | 14 +- .../character/service/StoreServiceTest.java | 18 ++- ...haracterUnlockedPushEventListenerTest.java | 82 ++++++++++ .../CrowdVacantPushEventListenerTest.java | 146 ++++++++++++++++++ ...DomainPushEventListenerAnnotationTest.java | 32 ++++ .../ReportResolvedPushEventListenerTest.java | 79 ++++++++++ .../validation/PushActionValidatorTest.java | 38 +++++ .../report/service/ReportServiceTest.java | 118 ++++++++++++++ 25 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java create mode 100644 src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java new file mode 100644 index 00000000..ae3ca230 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.ble.event; + +import java.time.LocalDateTime; + +public record PlaceBecameVacantEvent( + Long placeId, + Long bleDataId, + LocalDateTime occurredAt +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java index 5e3c6854..3ca5fb51 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java @@ -11,5 +11,6 @@ public interface BLEDataRepository extends JpaRepository { Optional findTopByDeviceOrderByLastTimeDesc(BLEDevice device); + Optional findTopByDeviceOrderByLastTimeDescIdDesc(BLEDevice device); List findAllByDeviceAndLastTimeBetweenOrderByLastTimeAsc(BLEDevice device, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java index ecddca8b..d5a876f7 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java @@ -9,6 +9,7 @@ import devkor.com.teamcback.domain.ble.entity.BLEData; import devkor.com.teamcback.domain.ble.entity.BLEDevice; import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; import devkor.com.teamcback.domain.place.entity.Place; @@ -17,6 +18,7 @@ import devkor.com.teamcback.global.response.ResultCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +36,7 @@ public class BLEService { private final BLEDeviceRepository bledeviceRepository; private final BLEDataRepository bleDataRepository; private final PlaceRepository placeRepository; + private final ApplicationEventPublisher eventPublisher; // 평균 구해올 시간대 라벨 private static final int[] TIME_SLOTS = {7, 10, 13, 16, 19, 22}; @@ -46,6 +49,11 @@ public class BLEService { @Transactional public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { BLEDevice bleDevice = bledeviceRepository.findByDeviceName(updateBLEReq.getDeviceName()); + if (bleDevice == null) { + throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); + } + BLEData previousData = bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(bleDevice) + .orElse(null); int capacity = bleDevice.getCapacity(); int people = getBlEPeople(updateBLEReq.getLastCount(), bleDevice); double final_ratio = (double) people / capacity; @@ -61,9 +69,29 @@ public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { bleData.setLastTime(updateBLEReq.getLastTime()); bleDataRepository.save(bleData); + if (becameVacant(previousData, status) && bleDevice.getPlace() != null) { + eventPublisher.publishEvent(new PlaceBecameVacantEvent( + bleDevice.getPlace().getId(), + bleData.getId(), + bleData.getLastTime() + )); + } + return new UpdateBLERes(bleData); } + private boolean becameVacant( + BLEData previousData, + BLEstatus newStatus + ) { + if (!BLEstatus.VACANT.equals(newStatus) || previousData == null) { + return false; + } + + return BLEstatus.AVAILABLE.equals(previousData.getLastStatus()) + || BLEstatus.CROWDED.equals(previousData.getLastStatus()); + } + private int getBlEPeople(int lastCount, BLEDevice bleDevice) { if (bleDevice == null) throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); double ratio = bleDevice.getRatio(); diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java index 3051eaa1..aa5a3ff7 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java @@ -27,4 +27,15 @@ List findCategoriesByUserAndLocationTypeAndLocationId( @Query("SELECT c FROM Category c LEFT JOIN FETCH c.categoryBookmarkList WHERE c.user = :user") List findByUser(@Param("user") User user); + + @Query(""" + SELECT DISTINCT c.user.userId FROM CategoryBookmark cb + JOIN cb.category c + JOIN cb.bookmark b + WHERE c.user IS NOT NULL AND b.locationType = :locationType AND b.locationId = :locationId + """) + List findDistinctUserIdsByLocationTypeAndLocationId( + @Param("locationType") LocationType locationType, + @Param("locationId") Long locationId + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java new file mode 100644 index 00000000..0c6f0c6c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.character.event; + +public record CharacterUnlockedEvent( + Long userId, + Long characterId, + Long userCharacterId, + String characterName +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java index 033241d2..c7fb35f0 100644 --- a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java +++ b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java @@ -16,6 +16,7 @@ import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Level; @@ -26,6 +27,7 @@ import devkor.com.teamcback.infra.s3.S3Util; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -37,6 +39,7 @@ public class AdminStoreService { private final UserCharacterRepository userCharacterRepository; private final UserRepository userRepository; private final S3Util s3Util; + private final ApplicationEventPublisher eventPublisher; /** * 캐릭터 목록 조회 (비활성 포함) @@ -119,7 +122,13 @@ public GrantCharacterRes grantCharacter(Long characterId, Long userId) { throw new GlobalException(ALREADY_OWNED_CHARACTER); } - UserCharacter userCharacter = userCharacterRepository.save(new UserCharacter(user, character)); + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); return new GrantCharacterRes(userCharacter.getUserCharacterId()); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java index 578393e4..5ee5fd66 100644 --- a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java +++ b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java @@ -18,6 +18,7 @@ import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.PurchaseStatus; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Level; @@ -30,6 +31,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +42,7 @@ public class StoreService { private final CharacterRepository characterRepository; private final UserCharacterRepository userCharacterRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /** * 스토어 조회 (보유 포인트 + 캐릭터 목록) @@ -118,6 +121,12 @@ public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { try { UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); return new PurchaseCharacterRes(userCharacter, user.getPoint()); } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) throw new GlobalException(ALREADY_OWNED_CHARACTER); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java index c24f13e5..7b110df6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java @@ -7,5 +7,6 @@ public enum PushActionType { BUS_STOP, BUILDING_DETAIL, PLACE_DETAIL, + CHARACTER_STORE, TEST } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java new file mode 100644 index 00000000..a9e19ef0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -0,0 +1,84 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CharacterUnlockedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + + @Value("${push.event.character-enabled:false}") + private boolean characterEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CharacterUnlockedEvent event) { + if (!characterEnabled) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.userId(), + AppVariant.PRODUCTION + )) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.userId()), + "새 캐릭터가 기다리고 있어요!", + characterBody(event.characterName()), + PushActionType.CHARACTER_STORE, + Map.of(), + "character-unlock:%d:%d:%d".formatted( + event.userId(), + event.characterId(), + event.userCharacterId() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "character unlock push failed: userId={}, characterId={}, userCharacterId={}, error={}", + event.userId(), + event.characterId(), + event.userCharacterId(), + e.getMessage() + ); + } + } + + private String characterBody(String characterName) { + if (characterName == null || characterName.isBlank()) { + return "새 캐릭터를 만나러 가볼까요?"; + } + return characterName.trim() + "을 만나러 가볼까요?"; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java new file mode 100644 index 00000000..2b498061 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -0,0 +1,137 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CrowdVacantPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + private static final String TITLE = "기다리던 자리가 생겼어요!"; + + private final PlaceRepository placeRepository; + private final CategoryRepository categoryRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + + @Value("${push.event.crowd-enabled:false}") + private boolean crowdEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(PlaceBecameVacantEvent event) { + if (!crowdEnabled) { + return; + } + + try { + Place place = placeRepository.findById(event.placeId()) + .orElse(null); + if (place == null) { + log.warn("crowd vacant push skipped: place not found, placeId={}", event.placeId()); + return; + } + + Set userIds = new LinkedHashSet<>( + categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId( + LocationType.PLACE, + event.placeId() + ) + ); + if (userIds.isEmpty()) { + return; + } + + String body = locationName(place) + "이 한산해요. 방문하기 전 현황을 확인해보세요."; + for (Long userId : userIds) { + enqueueIfPushTargetExists(event, userId, body); + } + } catch (Exception e) { + log.warn( + "crowd vacant push failed: placeId={}, bleDataId={}, error={}", + event.placeId(), + event.bleDataId(), + e.getMessage() + ); + } + } + + private void enqueueIfPushTargetExists( + PlaceBecameVacantEvent event, + Long userId, + String body + ) { + if (userId == null + || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(userId), + TITLE, + body, + PushActionType.PLACE_DETAIL, + Map.of("placeId", event.placeId()), + "crowd-vacant:%d:%d:%d".formatted(event.placeId(), userId, event.bleDataId()), + SYSTEM_CREATED_BY + )); + } + + private String locationName(Place place) { + String buildingName = place.getBuilding() == null ? null : place.getBuilding().getName(); + String placeName = place.getName(); + String joined = joinNonBlank(buildingName, placeName); + return joined.isBlank() ? "즐겨찾기한 공간" : joined; + } + + private String joinNonBlank( + String first, + String second + ) { + StringBuilder builder = new StringBuilder(); + appendIfPresent(builder, first); + appendIfPresent(builder, second); + return builder.toString(); + } + + private void appendIfPresent( + StringBuilder builder, + String value + ) { + if (value == null || value.isBlank()) { + return; + } + if (!builder.isEmpty()) { + builder.append(" "); + } + builder.append(value.trim()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java new file mode 100644 index 00000000..293dbafe --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -0,0 +1,77 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReportResolvedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + + @Value("${push.event.report-enabled:false}") + private boolean reportEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ReportResolvedEvent event) { + if (!reportEnabled || event.reporterUserId() == null) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.reporterUserId(), + AppVariant.PRODUCTION + )) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.reporterUserId()), + "신고 처리 결과를 확인해주세요.", + "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요.", + PushActionType.HOME, + Map.of(), + "report-result:%d:%s:%d".formatted( + event.reportId(), + event.finalStatus().name(), + event.reporterUserId() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, error={}", + event.reportId(), + event.reporterUserId(), + event.finalStatus(), + e.getMessage() + ); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 283a102b..3d8e1124 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -40,6 +40,11 @@ List findAllByUserIdAndAppVariantAndActiveTrue( AppVariant appVariant ); + boolean existsByUserIdAndAppVariantAndActiveTrue( + Long userId, + AppVariant appVariant + ); + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( Long pushInstallationId, String installationId, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java index 2e22c2bd..70c78479 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java @@ -19,6 +19,7 @@ public class PushActionValidator { PushActionType.HOME, PushActionType.NOTICE, PushActionType.MY_PAGE, + PushActionType.CHARACTER_STORE, PushActionType.TEST ); diff --git a/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java new file mode 100644 index 00000000..025c971c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.report.event; + +import devkor.com.teamcback.domain.report.entity.ReportStatus; + +public record ReportResolvedEvent( + Long reportId, + Long reporterUserId, + ReportStatus finalStatus +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java index ad81dfbd..60c5d81c 100644 --- a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java +++ b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java @@ -6,6 +6,7 @@ import devkor.com.teamcback.domain.report.entity.Report; import devkor.com.teamcback.domain.report.entity.ReportStatus; import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import devkor.com.teamcback.domain.report.repository.ReportRepository; import devkor.com.teamcback.domain.review.entity.Review; import devkor.com.teamcback.domain.review.repository.ReviewRepository; @@ -14,6 +15,7 @@ import devkor.com.teamcback.global.exception.exception.GlobalException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +33,7 @@ public class ReportService { private final ReportRepository reportRepository; private final ReviewRepository reviewRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /** * 리뷰에 대한 신고 작성 @@ -98,6 +101,7 @@ public GetReportListRes getReportList(ReportStatus status) { public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatusReq req) { // 신고 Report report = findReport(reportId); + ReportStatus previousStatus = report.getStatus(); // 신고 상태 수정 report.setStatus(req.getStatus()); @@ -115,9 +119,31 @@ public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatu } } + if (shouldPublishReportResolved(previousStatus, req.getStatus())) { + eventPublisher.publishEvent(new ReportResolvedEvent( + report.getId(), + report.getReporter() == null ? null : report.getReporter().getUserId(), + req.getStatus() + )); + } + return new UpdateReportStatusRes(); } + private boolean shouldPublishReportResolved( + ReportStatus previousStatus, + ReportStatus newStatus + ) { + return PENDING.equals(previousStatus) + && isFinalStatus(newStatus); + } + + private boolean isFinalStatus(ReportStatus status) { + return RESOLVED.equals(status) + || REJECTED.equals(status) + || EXPIRED.equals(status); + } + /** * 신고 유효일 체크하고 상태 수정 */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1b5b17ec..863190c8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -163,6 +163,10 @@ push: access-token: ${EXPO_ACCESS_TOKEN:} connect-timeout: 3s read-timeout: 10s + event: + crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} + report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} + character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} worker: enabled: ${PUSH_WORKER_ENABLED:false} fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} diff --git a/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java new file mode 100644 index 00000000..25caebab --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java @@ -0,0 +1,144 @@ +package devkor.com.teamcback.domain.ble.service; + +import devkor.com.teamcback.domain.ble.dto.request.UpdateBLEReq; +import devkor.com.teamcback.domain.ble.entity.BLEData; +import devkor.com.teamcback.domain.ble.entity.BLEDevice; +import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; +import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BLEServiceTest { + + @Mock + private BLEDeviceRepository bleDeviceRepository; + + @Mock + private BLEDataRepository bleDataRepository; + + @Mock + private PlaceRepository placeRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private BLEService bleService; + private BLEDevice device; + + @BeforeEach + void setUp() { + bleService = new BLEService( + bleDeviceRepository, + bleDataRepository, + placeRepository, + eventPublisher + ); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + + device = new BLEDevice(); + ReflectionTestUtils.setField(device, "id", 3L); + device.setDeviceName("device-1"); + device.setCapacity(100); + device.setDefaultCount(0); + device.setRatio(1); + device.setPlace(place); + + when(bleDeviceRepository.findByDeviceName("device-1")).thenReturn(device); + when(bleDataRepository.save(any(BLEData.class))).thenAnswer(invocation -> { + BLEData saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 99L); + return saved; + }); + } + + @Test + void publishesEventWhenAvailableBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.AVAILABLE))); + + bleService.updateBLE(req(20)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlaceBecameVacantEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().placeId()).isEqualTo(10L); + assertThat(captor.getValue().bleDataId()).isEqualTo(99L); + } + + @Test + void publishesEventWhenCrowdedBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.CROWDED))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher).publishEvent(any(PlaceBecameVacantEvent.class)); + } + + @Test + void doesNotPublishForRepeatedVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.VACANT))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousDataDoesNotExist() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.empty()); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousStatusIsFailure() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.FAILURE))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateBLEReq req(int lastCount) { + UpdateBLEReq req = new UpdateBLEReq(); + req.setDeviceName("device-1"); + req.setLastCount(lastCount); + req.setLastTime(LocalDateTime.parse("2026-08-05T10:00:00")); + return req; + } + + private BLEData data(BLEstatus status) { + BLEData data = new BLEData(); + data.setDevice(device); + data.setLastStatus(status); + data.setLastCount(50); + data.setLastTime(LocalDateTime.parse("2026-08-05T09:59:00")); + return data; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java index 6d6998f8..586254b5 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -12,6 +12,7 @@ import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Provider; @@ -23,12 +24,14 @@ import devkor.com.teamcback.infra.s3.FilePath; import devkor.com.teamcback.infra.s3.S3Util; import java.util.Optional; +import org.mockito.ArgumentCaptor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; @@ -45,6 +48,8 @@ class AdminStoreServiceTest { UserRepository userRepository; @Mock S3Util s3Util; + @Mock + ApplicationEventPublisher eventPublisher; @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") @Test @@ -174,7 +179,9 @@ void deleteCharacter() { @Test void grantCharacter() { KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); + ReflectionTestUtils.setField(character, "characterId", 1L); User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 2L); when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); @@ -184,7 +191,7 @@ void grantCharacter() { assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userCharacterRepository.save(any(UserCharacter.class))).thenAnswer(invocation -> { + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { UserCharacter userCharacter = invocation.getArgument(0); ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); return userCharacter; @@ -192,5 +199,10 @@ void grantCharacter() { assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(2L, eventCaptor.getValue().userId()); + assertEquals(5L, eventCaptor.getValue().userCharacterId()); } } diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java index 658db82e..2a2fd988 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -14,6 +14,7 @@ import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.PurchaseStatus; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Provider; @@ -29,8 +30,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; @@ -45,6 +48,8 @@ class StoreServiceTest { UserCharacterRepository userCharacterRepository; @Mock UserRepository userRepository; + @Mock + ApplicationEventPublisher eventPublisher; static final Long USER_ID = 1L; static final Long CHARACTER_ID = 10L; @@ -75,13 +80,23 @@ void purchaseCharacter() { when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); + .thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 55L); + return userCharacter; + }); PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); assertEquals(CHARACTER_ID, res.getCharacterId()); assertEquals(10, res.getPrice()); verify(userRepository).deductPoint(USER_ID, 10); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(USER_ID, eventCaptor.getValue().userId()); + assertEquals(CHARACTER_ID, eventCaptor.getValue().characterId()); + assertEquals(55L, eventCaptor.getValue().userCharacterId()); } @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") @@ -160,6 +175,7 @@ void purchaseRaceMappedToAlreadyOwned() { GlobalException e = assertThrows(GlobalException.class, () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); } @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java new file mode 100644 index 00000000..c0813f71 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -0,0 +1,82 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CharacterUnlockedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + private CharacterUnlockedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CharacterUnlockedPushEventListener( + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsCharacterStoreDispatch() { + ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.actionType()).isEqualTo(PushActionType.CHARACTER_STORE); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(command.body()).isEqualTo("아기 호랑이을 만나러 가볼까요?"); + assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44"); + } + + @Test + void usesSafeBodyWhenCharacterNameIsBlank() { + ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, " ")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().body()).isEqualTo("새 캐릭터를 만나러 가볼까요?"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + ReflectionTestUtils.setField(listener, "characterEnabled", false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java new file mode 100644 index 00000000..cc171469 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -0,0 +1,146 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.building.entity.Building; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class CrowdVacantPushEventListenerTest { + + @Mock + private PlaceRepository placeRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + private CrowdVacantPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CrowdVacantPushEventListener( + placeRepository, + categoryRepository, + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsUserDispatchesForDistinctFavoriteUsers() { + ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L, 1L, 2L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(2L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(event()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService, times(2)).enqueue(captor.capture()); + + PushDispatchCommand first = captor.getAllValues().get(0); + assertThat(first.targetType()).isEqualTo(PushTargetType.USER); + assertThat(first.targetValue()).isEqualTo("1"); + assertThat(first.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(first.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(first.actionType()).isEqualTo(PushActionType.PLACE_DETAIL); + assertThat(first.actionParams()).containsEntry("placeId", 10L); + assertThat(first.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(first.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(first.body()).doesNotContain("null"); + assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99"); + } + + @Test + void doesNotCreateDispatchWhenNoFavoriteUsersExist() { + ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of()); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + ReflectionTestUtils.setField(listener, "crowdEnabled", false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + verify(placeRepository, never()).findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void skipsUsersWithoutProductionInstallation() { + ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place(null, "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + private PlaceBecameVacantEvent event() { + return new PlaceBecameVacantEvent( + 10L, + 99L, + LocalDateTime.parse("2026-08-05T10:00:00") + ); + } + + private Place place( + String buildingName, + String placeName + ) { + Building building = new Building(); + ReflectionTestUtils.setField(building, "name", buildingName); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + place.setBuilding(building); + place.setName(placeName); + return place; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java new file mode 100644 index 00000000..7fe7a2e8 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushEventListenerAnnotationTest { + + @Test + void listenersRunAfterCommit() throws NoSuchMethodException { + assertAfterCommit(CrowdVacantPushEventListener.class, PlaceBecameVacantEvent.class); + assertAfterCommit(ReportResolvedPushEventListener.class, ReportResolvedEvent.class); + assertAfterCommit(CharacterUnlockedPushEventListener.class, CharacterUnlockedEvent.class); + } + + private void assertAfterCommit( + Class listenerClass, + Class eventClass + ) throws NoSuchMethodException { + TransactionalEventListener annotation = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(TransactionalEventListener.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java new file mode 100644 index 00000000..11979869 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportResolvedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + private ReportResolvedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new ReportResolvedPushEventListener( + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsReporterDispatch() { + ReflectionTestUtils.setField(listener, "reportEnabled", true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.targetValue()).isEqualTo("7"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); + assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + ReflectionTestUtils.setField(listener, "reportEnabled", false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterIsUnknown() { + ReflectionTestUtils.setField(listener, "reportEnabled", true); + + listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java new file mode 100644 index 00000000..b58033b0 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.validation; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PushActionValidatorTest { + + private final PushActionValidator validator = new PushActionValidator(); + + @Test + void characterStoreAllowsNoParams() { + Map params = validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of() + ); + + assertThat(params).isEmpty(); + } + + @Test + void characterStoreRejectsParams() { + assertThatThrownBy(() -> validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of("characterId", 1L) + )).isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java new file mode 100644 index 00000000..32020469 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java @@ -0,0 +1,118 @@ +package devkor.com.teamcback.domain.report.service; + +import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; +import devkor.com.teamcback.domain.report.entity.ReasonCategory; +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import devkor.com.teamcback.domain.report.repository.ReportRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private ReportService reportService; + private User reporter; + + @BeforeEach + void setUp() { + reportService = new ReportService( + reportRepository, + reviewRepository, + userRepository, + eventPublisher + ); + + reporter = new User("reporter", "reporter@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(reporter, "userId", 7L); + } + + @Test + void publishesEventWhenPendingReportBecomesFinalStatus() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReportResolvedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().reportId()).isEqualTo(1L); + assertThat(captor.getValue().reporterUserId()).isEqualTo(7L); + assertThat(captor.getValue().finalStatus()).isEqualTo(ReportStatus.REJECTED); + } + + @Test + void doesNotPublishWhenFinalReportIsReprocessed() { + Report report = report(ReportStatus.RESOLVED, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenStatusDoesNotChangeToFinal() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.PENDING)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateReportStatusReq req(ReportStatus status) { + UpdateReportStatusReq req = new UpdateReportStatusReq(); + req.setStatus(status); + return req; + } + + private Report report( + ReportStatus status, + User reporter + ) { + Report report = new Report( + TargetType.REVIEW, + 20L, + ReasonCategory.SPAM_OR_ADVERTISING, + "sensitive report content", + status, + reporter, + null + ); + ReflectionTestUtils.setField(report, "id", 1L); + return report; + } +} From 8a0405c565113333f98146d6138d49b5ab238bd6 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 20:54:17 +0900 Subject: [PATCH 34/54] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=91=B8=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CharacterUnlockedPushEventListener.java | 14 ++-- .../CrowdVacantPushEventListener.java | 46 +++---------- .../ReportResolvedPushEventListener.java | 7 +- .../template/DomainPushContentFactory.java | 67 +++++++++++++++++++ .../notification/template/PushContent.java | 7 ++ ...haracterUnlockedPushEventListenerTest.java | 2 +- .../CrowdVacantPushEventListenerTest.java | 2 +- .../ReportResolvedPushEventListenerTest.java | 2 + .../DomainPushContentFactoryTest.java | 55 +++++++++++++++ 9 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java index a9e19ef0..22369db6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -9,6 +9,8 @@ import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,14 +49,15 @@ public void handle(CharacterUnlockedEvent event) { return; } + PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, AppVariant.PRODUCTION, PushTargetType.USER, String.valueOf(event.userId()), - "새 캐릭터가 기다리고 있어요!", - characterBody(event.characterName()), + content.title(), + content.body(), PushActionType.CHARACTER_STORE, Map.of(), "character-unlock:%d:%d:%d".formatted( @@ -74,11 +77,4 @@ public void handle(CharacterUnlockedEvent event) { ); } } - - private String characterBody(String characterName) { - if (characterName == null || characterName.isBlank()) { - return "새 캐릭터를 만나러 가볼까요?"; - } - return characterName.trim() + "을 만나러 가볼까요?"; - } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java index 2b498061..7c543084 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -11,6 +11,8 @@ import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.repository.PlaceRepository; import java.util.LinkedHashSet; @@ -31,7 +33,6 @@ public class CrowdVacantPushEventListener { private static final Long SYSTEM_CREATED_BY = 0L; - private static final String TITLE = "기다리던 자리가 생겼어요!"; private final PlaceRepository placeRepository; private final CategoryRepository categoryRepository; @@ -66,9 +67,12 @@ public void handle(PlaceBecameVacantEvent event) { return; } - String body = locationName(place) + "이 한산해요. 방문하기 전 현황을 확인해보세요."; + PushContent content = DomainPushContentFactory.placeBecameVacant( + place.getBuilding() == null ? null : place.getBuilding().getName(), + place.getName() + ); for (Long userId : userIds) { - enqueueIfPushTargetExists(event, userId, body); + enqueueIfPushTargetExists(event, userId, content); } } catch (Exception e) { log.warn( @@ -83,7 +87,7 @@ public void handle(PlaceBecameVacantEvent event) { private void enqueueIfPushTargetExists( PlaceBecameVacantEvent event, Long userId, - String body + PushContent content ) { if (userId == null || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { @@ -96,42 +100,12 @@ private void enqueueIfPushTargetExists( AppVariant.PRODUCTION, PushTargetType.USER, String.valueOf(userId), - TITLE, - body, + content.title(), + content.body(), PushActionType.PLACE_DETAIL, Map.of("placeId", event.placeId()), "crowd-vacant:%d:%d:%d".formatted(event.placeId(), userId, event.bleDataId()), SYSTEM_CREATED_BY )); } - - private String locationName(Place place) { - String buildingName = place.getBuilding() == null ? null : place.getBuilding().getName(); - String placeName = place.getName(); - String joined = joinNonBlank(buildingName, placeName); - return joined.isBlank() ? "즐겨찾기한 공간" : joined; - } - - private String joinNonBlank( - String first, - String second - ) { - StringBuilder builder = new StringBuilder(); - appendIfPresent(builder, first); - appendIfPresent(builder, second); - return builder.toString(); - } - - private void appendIfPresent( - StringBuilder builder, - String value - ) { - if (value == null || value.isBlank()) { - return; - } - if (!builder.isEmpty()) { - builder.append(" "); - } - builder.append(value.trim()); - } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java index 293dbafe..5871d906 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -8,6 +8,8 @@ import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -47,14 +49,15 @@ public void handle(ReportResolvedEvent event) { return; } + PushContent content = DomainPushContentFactory.reportResolved(); pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, AppVariant.PRODUCTION, PushTargetType.USER, String.valueOf(event.reporterUserId()), - "신고 처리 결과를 확인해주세요.", - "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요.", + content.title(), + content.body(), PushActionType.HOME, Map.of(), "report-result:%d:%s:%d".formatted( diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java new file mode 100644 index 00000000..1e1581e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java @@ -0,0 +1,67 @@ +package devkor.com.teamcback.domain.notification.template; + +public final class DomainPushContentFactory { + + private static final String VACANT_TITLE = + "기다리던 자리가 생겼어요!"; + + private static final String REPORT_RESOLVED_TITLE = + "신고 처리 결과를 확인해주세요."; + + private static final String REPORT_RESOLVED_BODY = + "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."; + + private static final String CHARACTER_UNLOCKED_TITLE = + "새 캐릭터가 기다리고 있어요!"; + + private DomainPushContentFactory() { + } + + public static PushContent placeBecameVacant( + String buildingName, + String placeName + ) { + String location = joinNonBlank(buildingName, placeName); + + return new PushContent( + VACANT_TITLE, + location + "이 한산해요. 방문하기 전 현황을 확인해보세요." + ); + } + + public static PushContent reportResolved() { + return new PushContent( + REPORT_RESOLVED_TITLE, + REPORT_RESOLVED_BODY + ); + } + + public static PushContent characterUnlocked(String characterName) { + String name = isBlank(characterName) + ? "새로운 캐릭터" + : characterName.trim(); + + return new PushContent( + CHARACTER_UNLOCKED_TITLE, + name + "을 만나러 가볼까요?" + ); + } + + private static String joinNonBlank( + String first, + String second + ) { + String firstValue = isBlank(first) ? "" : first.trim(); + String secondValue = isBlank(second) ? "" : second.trim(); + + String result = (firstValue + " " + secondValue).trim(); + + return result.isBlank() + ? "즐겨찾기한 공간" + : result; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java new file mode 100644 index 00000000..dd8a3747 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.notification.template; + +public record PushContent( + String title, + String body +) { +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java index c0813f71..f0a51bba 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -68,7 +68,7 @@ void usesSafeBodyWhenCharacterNameIsBlank() { ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().body()).isEqualTo("새 캐릭터를 만나러 가볼까요?"); + assertThat(captor.getValue().body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java index cc171469..872d4d1a 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -26,9 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class CrowdVacantPushEventListenerTest { diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java index 11979869..38017420 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -55,6 +55,8 @@ void createsReporterDispatch() { assertThat(command.targetValue()).isEqualTo("7"); assertThat(command.actionType()).isEqualTo(PushActionType.HOME); assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(command.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7"); } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java new file mode 100644 index 00000000..0ea3ef2c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java @@ -0,0 +1,55 @@ +package devkor.com.teamcback.domain.notification.template; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushContentFactoryTest { + + @Test + void placeBecameVacantJoinsBuildingAndPlaceWithSingleSpace() { + PushContent content = DomainPushContentFactory.placeBecameVacant( + " 신공학관 ", + " 라운지 " + ); + + assertThat(content.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(content.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void placeBecameVacantDoesNotIncludeNullWhenOneValueExists() { + PushContent buildingOnly = DomainPushContentFactory.placeBecameVacant("신공학관", null); + PushContent placeOnly = DomainPushContentFactory.placeBecameVacant(null, "라운지"); + + assertThat(buildingOnly.body()).isEqualTo("신공학관이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(placeOnly.body()).isEqualTo("라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(buildingOnly.body()).doesNotContain("null"); + assertThat(placeOnly.body()).doesNotContain("null"); + } + + @Test + void placeBecameVacantUsesFallbackWhenBothValuesAreBlank() { + PushContent content = DomainPushContentFactory.placeBecameVacant(" ", null); + + assertThat(content.body()).isEqualTo("즐겨찾기한 공간이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void characterUnlockedUsesFallbackWhenNameIsNullOrBlank() { + PushContent nullName = DomainPushContentFactory.characterUnlocked(null); + PushContent blankName = DomainPushContentFactory.characterUnlocked(" "); + + assertThat(nullName.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(nullName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + assertThat(blankName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + } + + @Test + void reportResolvedCreatesConfiguredTitleAndBody() { + PushContent content = DomainPushContentFactory.reportResolved(); + + assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + } +} From 974be030a70e9f3e47a8b20280d8e343ee10280a Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 21:30:23 +0900 Subject: [PATCH 35/54] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=9F=B0=ED=83=80=EC=9E=84=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EA=B4=80=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 --- .../AdminNotificationController.java | 24 ++++++ .../dto/request/UpdatePushEventFlagReq.java | 6 ++ .../dto/response/AdminPushEventFlagRes.java | 9 ++ .../entity/type/PushEventType.java | 17 ++++ .../CharacterUnlockedPushEventListener.java | 9 +- .../CrowdVacantPushEventListener.java | 9 +- .../ReportResolvedPushEventListener.java | 9 +- .../service/PushEventFlagService.java | 67 +++++++++++++++ .../service/AdminStoreServiceTest.java | 1 + .../character/service/StoreServiceTest.java | 1 + .../AdminNotificationControllerTest.java | 66 +++++++++++++++ ...haracterUnlockedPushEventListenerTest.java | 26 ++++-- .../CrowdVacantPushEventListenerTest.java | 16 ++-- ...DomainPushEventListenerAnnotationTest.java | 7 ++ .../ReportResolvedPushEventListenerTest.java | 26 ++++-- .../service/PushEventFlagServiceTest.java | 83 +++++++++++++++++++ 16 files changed, 346 insertions(+), 30 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java index 8fa34668..825f0a87 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java @@ -1,16 +1,21 @@ package devkor.com.teamcback.domain.notification.controller; import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.UpdatePushEventFlagReq; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.global.exception.exception.GlobalException; import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.response.ResultCode; import devkor.com.teamcback.global.security.UserDetailsImpl; import java.util.List; @@ -20,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -38,6 +44,7 @@ public class AdminNotificationController { private static final String DEFAULT_SIZE = "20"; private final AdminNotificationService adminNotificationService; + private final PushEventFlagService pushEventFlagService; @Operation( summary = "푸시 대상 installation 검색", @@ -133,4 +140,21 @@ public CommonResponse getDispatch( ) { return CommonResponse.success(adminNotificationService.getDispatch(dispatchId)); } + + @GetMapping("/event-flags") + public CommonResponse> getEventFlags() { + return CommonResponse.success(pushEventFlagService.getFlags()); + } + + @PatchMapping("/event-flags/{eventType}") + public CommonResponse updateEventFlag( + @PathVariable PushEventType eventType, + @RequestBody UpdatePushEventFlagReq request + ) { + if (request == null || request.enabled() == null) { + throw new GlobalException(ResultCode.INVALID_INPUT); + } + + return CommonResponse.success(pushEventFlagService.updateFlag(eventType, request.enabled())); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java new file mode 100644 index 00000000..70a5508e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +public record UpdatePushEventFlagReq( + Boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java new file mode 100644 index 00000000..79df8c2a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; + +public record AdminPushEventFlagRes( + PushEventType eventType, + boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java new file mode 100644 index 00000000..52d298c5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushEventType { + CROWD("push:event:crowd-enabled"), + REPORT("push:event:report-enabled"), + CHARACTER("push:event:character-enabled"); + + private final String redisKey; + + PushEventType(String redisKey) { + this.redisKey = redisKey; + } + + public String redisKey() { + return redisKey; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java index 22369db6..bd49011c 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -5,16 +5,17 @@ import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.NotificationType; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -30,14 +31,12 @@ public class CharacterUnlockedPushEventListener { private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.character-enabled:false}") - private boolean characterEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(CharacterUnlockedEvent event) { - if (!characterEnabled) { + if (!pushEventFlagService.isEnabled(PushEventType.CHARACTER)) { return; } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java index 7c543084..5f7ae697 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -7,9 +7,11 @@ import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.NotificationType; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.notification.service.PushDispatchService; import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; @@ -20,7 +22,6 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -38,14 +39,12 @@ public class CrowdVacantPushEventListener { private final CategoryRepository categoryRepository; private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.crowd-enabled:false}") - private boolean crowdEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(PlaceBecameVacantEvent event) { - if (!crowdEnabled) { + if (!pushEventFlagService.isEnabled(PushEventType.CROWD)) { return; } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java index 5871d906..fff6f4b6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -4,17 +4,18 @@ import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.NotificationType; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -30,14 +31,12 @@ public class ReportResolvedPushEventListener { private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.report-enabled:false}") - private boolean reportEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(ReportResolvedEvent event) { - if (!reportEnabled || event.reporterUserId() == null) { + if (!pushEventFlagService.isEnabled(PushEventType.REPORT) || event.reporterUserId() == null) { return; } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java new file mode 100644 index 00000000..7e621e03 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -0,0 +1,67 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PushEventFlagService { + + private final StringRedisTemplate redisTemplate; + + @Value("${push.event.crowd-enabled:false}") + private boolean crowdDefaultEnabled; + + @Value("${push.event.report-enabled:false}") + private boolean reportDefaultEnabled; + + @Value("${push.event.character-enabled:false}") + private boolean characterDefaultEnabled; + + public boolean isEnabled(PushEventType eventType) { + String redisValue = getRedisValue(eventType); + if ("true".equalsIgnoreCase(redisValue)) { + return true; + } + if ("false".equalsIgnoreCase(redisValue)) { + return false; + } + return defaultEnabled(eventType); + } + + public List getFlags() { + return Arrays.stream(PushEventType.values()) + .map(eventType -> new AdminPushEventFlagRes(eventType, isEnabled(eventType))) + .toList(); + } + + public AdminPushEventFlagRes updateFlag( + PushEventType eventType, + boolean enabled + ) { + redisTemplate.opsForValue().set(eventType.redisKey(), Boolean.toString(enabled)); + return new AdminPushEventFlagRes(eventType, isEnabled(eventType)); + } + + private String getRedisValue(PushEventType eventType) { + try { + return redisTemplate.opsForValue().get(eventType.redisKey()); + } catch (RuntimeException e) { + return null; + } + } + + private boolean defaultEnabled(PushEventType eventType) { + return switch (eventType) { + case CROWD -> crowdDefaultEnabled; + case REPORT -> reportDefaultEnabled; + case CHARACTER -> characterDefaultEnabled; + }; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java index 586254b5..09020cbb 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -189,6 +189,7 @@ void grantCharacter() { GlobalException e = assertThrows(GlobalException.class, () -> adminStoreService.grantCharacter(1L, 2L)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java index 2a2fd988..76914615 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -147,6 +147,7 @@ void purchaseAlreadyOwned() { () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + verify(eventPublisher, never()).publishEvent(any()); } @DisplayName("비활성 캐릭터는 구매 불가") diff --git a/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java new file mode 100644 index 00000000..55d5c870 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java @@ -0,0 +1,66 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationControllerTest { + + @Mock + private AdminNotificationService adminNotificationService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + AdminNotificationController controller = new AdminNotificationController( + adminNotificationService, + pushEventFlagService + ); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void updatedEventFlagIsReflectedInAdminApiQueryResult() throws Exception { + when(pushEventFlagService.updateFlag(PushEventType.REPORT, true)) + .thenReturn(new AdminPushEventFlagRes(PushEventType.REPORT, true)); + when(pushEventFlagService.getFlags()) + .thenReturn(List.of( + new AdminPushEventFlagRes(PushEventType.CROWD, false), + new AdminPushEventFlagRes(PushEventType.REPORT, true), + new AdminPushEventFlagRes(PushEventType.CHARACTER, false) + )); + + mockMvc.perform(patch("/api/admin/notifications/event-flags/REPORT") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.eventType").value("REPORT")) + .andExpect(jsonPath("$.data.enabled").value(true)); + + mockMvc.perform(get("/api/admin/notifications/event-flags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[1].eventType").value("REPORT")) + .andExpect(jsonPath("$.data[1].enabled").value(true)); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java index f0a51bba..f8a04e01 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -4,16 +4,17 @@ import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; @@ -29,19 +30,23 @@ class CharacterUnlockedPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private CharacterUnlockedPushEventListener listener; @BeforeEach void setUp() { listener = new CharacterUnlockedPushEventListener( pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsCharacterStoreDispatch() { - ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -60,7 +65,7 @@ void createsCharacterStoreDispatch() { @Test void usesSafeBodyWhenCharacterNameIsBlank() { - ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -73,7 +78,18 @@ void usesSafeBodyWhenCharacterNameIsBlank() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "characterEnabled", false); + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenUserHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java index 872d4d1a..cabb35be 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -7,10 +7,12 @@ import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushMode; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.repository.PlaceRepository; import java.time.LocalDateTime; @@ -45,6 +47,9 @@ class CrowdVacantPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private CrowdVacantPushEventListener listener; @BeforeEach @@ -53,13 +58,14 @@ void setUp() { placeRepository, categoryRepository, pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsUserDispatchesForDistinctFavoriteUsers() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) .thenReturn(List.of(1L, 1L, 2L)); @@ -88,7 +94,7 @@ void createsUserDispatchesForDistinctFavoriteUsers() { @Test void doesNotCreateDispatchWhenNoFavoriteUsersExist() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) .thenReturn(List.of()); @@ -100,7 +106,7 @@ void doesNotCreateDispatchWhenNoFavoriteUsersExist() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "crowdEnabled", false); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(false); listener.handle(event()); @@ -110,7 +116,7 @@ void doesNotCreateDispatchWhenFeatureFlagIsFalse() { @Test void skipsUsersWithoutProductionInstallation() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); when(placeRepository.findById(10L)).thenReturn(Optional.of(place(null, "라운지"))); when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) .thenReturn(List.of(1L)); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java index 7fe7a2e8..baf20a3a 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -4,6 +4,8 @@ import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -25,8 +27,13 @@ private void assertAfterCommit( TransactionalEventListener annotation = listenerClass .getDeclaredMethod("handle", eventClass) .getAnnotation(TransactionalEventListener.class); + Transactional transactional = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(Transactional.class); assertThat(annotation).isNotNull(); assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + assertThat(transactional).isNotNull(); + assertThat(transactional.propagation()).isEqualTo(Propagation.REQUIRES_NEW); } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java index 38017420..755c3046 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -3,9 +3,11 @@ import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.report.entity.ReportStatus; import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import org.junit.jupiter.api.BeforeEach; @@ -14,7 +16,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; @@ -30,19 +31,23 @@ class ReportResolvedPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private ReportResolvedPushEventListener listener; @BeforeEach void setUp() { listener = new ReportResolvedPushEventListener( pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsReporterDispatch() { - ReflectionTestUtils.setField(listener, "reportEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -63,7 +68,7 @@ void createsReporterDispatch() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "reportEnabled", false); + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(false); listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); @@ -72,10 +77,21 @@ void doesNotCreateDispatchWhenFeatureFlagIsFalse() { @Test void doesNotCreateDispatchWhenReporterIsUnknown() { - ReflectionTestUtils.setField(listener, "reportEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); } + + @Test + void doesNotCreateDispatchWhenReporterHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java new file mode 100644 index 00000000..c554106b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -0,0 +1,83 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushEventFlagServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private PushEventFlagService service; + + @BeforeEach + void setUp() { + service = new PushEventFlagService(redisTemplate); + ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); + ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); + ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); + + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void returnsYamlDefaultWhenRedisValueDoesNotExist() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); + + assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + } + + @Test + void redisTrueOrFalseOverridesYamlDefault() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn("true"); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn("false"); + + assertThat(service.isEnabled(PushEventType.CROWD)).isTrue(); + assertThat(service.isEnabled(PushEventType.REPORT)).isFalse(); + } + + @Test + void returnsYamlDefaultWhenRedisReadFails() { + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenThrow(new RuntimeException("redis down")); + + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + } + + @Test + void updatedValueIsReflectedImmediatelyInQueryResult() { + when(valueOperations.get(PushEventType.CHARACTER.redisKey())) + .thenReturn(null) + .thenReturn("true") + .thenReturn("true"); + + assertThat(service.isEnabled(PushEventType.CHARACTER)).isFalse(); + + AdminPushEventFlagRes updated = service.updateFlag(PushEventType.CHARACTER, true); + assertThat(updated.enabled()).isTrue(); + + List flags = service.getFlags(); + assertThat(flags) + .filteredOn(flag -> flag.eventType() == PushEventType.CHARACTER) + .singleElement() + .extracting(AdminPushEventFlagRes::enabled) + .isEqualTo(true); + } +} From 2a8e0b83d74454806c9ee7a912c7070248c59b10 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Thu, 6 Aug 2026 19:06:04 +0900 Subject: [PATCH 36/54] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=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 --- .../AdminSurveyPushScheduleController.java | 44 +++ .../SurveyNotificationController.java | 38 +++ .../request/AdminSurveyPushScheduleReq.java | 10 + .../response/AdminSurveyPushScheduleRes.java | 10 + .../AdminSurveyPushScheduleStageRes.java | 21 ++ .../dto/response/SurveyReminderRes.java | 12 + .../entity/SurveyPushSchedule.java | 122 ++++++++ .../entity/type/PushEventType.java | 3 +- .../entity/type/PushTargetType.java | 3 +- .../entity/type/SurveyNotificationStage.java | 8 + .../entity/type/SurveyPushScheduleStatus.java | 8 + .../type/SurveyReminderSuppressedBy.java | 10 + .../PushInstallationRepository.java | 8 + .../SurveyPushScheduleRepository.java | 51 ++++ .../resolver/PushTargetResolver.java | 12 + .../service/PushEventFlagService.java | 4 + .../service/SurveyPushScheduleService.java | 270 ++++++++++++++++++ .../service/SurveyPushScheduleWorker.java | 188 ++++++++++++ .../template/DomainPushContentFactory.java | 28 ++ src/main/resources/application.yml | 3 + .../V1__create_survey_push_schedule.sql | 18 ++ .../resolver/PushTargetResolverTest.java | 53 ++++ .../service/PushEventFlagServiceTest.java | 3 + .../SurveyPushScheduleServiceTest.java | 238 +++++++++++++++ .../service/SurveyPushScheduleWorkerTest.java | 188 ++++++++++++ .../DomainPushContentFactoryTest.java | 23 ++ 26 files changed, 1374 insertions(+), 2 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java create mode 100644 src/main/resources/db/migration/V1__create_survey_push_schedule.sql create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java new file mode 100644 index 00000000..7840c637 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/notifications/survey-schedules") +public class AdminSurveyPushScheduleController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PutMapping("/{surveyKey}") + public CommonResponse upsertSurveySchedules( + @PathVariable String surveyKey, + @RequestBody AdminSurveyPushScheduleReq request + ) { + return CommonResponse.success(surveyPushScheduleService.upsertAdminSchedules(surveyKey, request)); + } + + @GetMapping("/{surveyKey}") + public CommonResponse getSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.getAdminSchedules(surveyKey)); + } + + @DeleteMapping("/{surveyKey}") + public CommonResponse cancelSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.cancelSchedules(surveyKey)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java new file mode 100644 index 00000000..a415ea11 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/surveys") +public class SurveyNotificationController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PostMapping("/{surveyKey}/reminders") + public CommonResponse remindAfterLater( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @PathVariable String surveyKey + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + return CommonResponse.success(surveyPushScheduleService.remindAfterLater( + surveyKey, + userDetail.getUser().getUserId() + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java new file mode 100644 index 00000000..d9c352af --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleReq( + LocalDateTime startNotificationAt, + LocalDateTime deadlineNotificationAt, + Integer rewardPoint +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java new file mode 100644 index 00000000..3a2428b4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +public record AdminSurveyPushScheduleRes( + String surveyKey, + int rewardPoint, + AdminSurveyPushScheduleStageRes started, + AdminSurveyPushScheduleStageRes dMinus3, + AdminSurveyPushScheduleStageRes deadline +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java new file mode 100644 index 00000000..5428cc6b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleStageRes( + SurveyPushScheduleStatus status, + LocalDateTime scheduledAt +) { + + public static AdminSurveyPushScheduleStageRes from(SurveyPushSchedule schedule) { + if (schedule == null) { + return null; + } + return new AdminSurveyPushScheduleStageRes( + schedule.getStatus(), + schedule.getScheduledAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java new file mode 100644 index 00000000..0b1e704f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import java.time.LocalDateTime; + +public record SurveyReminderRes( + String surveyKey, + boolean scheduled, + LocalDateTime scheduledAt, + SurveyReminderSuppressedBy suppressedBy +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java new file mode 100644 index 00000000..7ca41786 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java @@ -0,0 +1,122 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_survey_push_schedule", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_survey_push_schedule_idempotency_key", + columnNames = "idempotency_key" + ) + }, + indexes = { + @Index( + name = "idx_survey_push_schedule_status_scheduled_at", + columnList = "status, scheduled_at" + ) + } +) +@NoArgsConstructor +@Getter +public class SurveyPushSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "survey_push_schedule_id") + private Long surveyPushScheduleId; + + @Column(name = "survey_key", nullable = false, length = 64) + private String surveyKey; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_stage", nullable = false, length = 40) + private SurveyNotificationStage notificationStage; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @Column(name = "reward_point", nullable = false) + private int rewardPoint; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private SurveyPushScheduleStatus status; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + public SurveyPushSchedule( + String surveyKey, + SurveyNotificationStage notificationStage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint, + String idempotencyKey, + LocalDateTime createdAt + ) { + this.surveyKey = surveyKey; + this.notificationStage = notificationStage; + this.targetUserId = targetUserId; + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + this.status = SurveyPushScheduleStatus.PENDING; + this.idempotencyKey = idempotencyKey; + this.createdAt = createdAt; + this.processedAt = null; + } + + public boolean isPending() { + return SurveyPushScheduleStatus.PENDING.equals(status); + } + + public void updatePendingSchedule( + LocalDateTime scheduledAt, + int rewardPoint + ) { + if (!isPending()) { + return; + } + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + } + + public void complete(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.COMPLETED; + this.processedAt = now; + } + + public void cancel(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.CANCELLED; + this.processedAt = now; + } + + public void skip(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.SKIPPED; + this.processedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java index 52d298c5..3445875f 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java @@ -3,7 +3,8 @@ public enum PushEventType { CROWD("push:event:crowd-enabled"), REPORT("push:event:report-enabled"), - CHARACTER("push:event:character-enabled"); + CHARACTER("push:event:character-enabled"), + SURVEY("push:event:survey-enabled"); private final String redisKey; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java index 01c41341..296d2715 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java @@ -3,5 +3,6 @@ public enum PushTargetType { INSTALLATION, USER, - USER_GROUP + USER_GROUP, + ALL } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java new file mode 100644 index 00000000..8891eac6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyNotificationStage { + STARTED, + D_MINUS_3, + DEADLINE, + REMIND_AFTER_LATER +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java new file mode 100644 index 00000000..8e06baed --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyPushScheduleStatus { + PENDING, + COMPLETED, + CANCELLED, + SKIPPED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java new file mode 100644 index 00000000..61fb3b1f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyReminderSuppressedBy { + NONE, + D3, + DEADLINE, + EXPIRED, + ALREADY_PROCESSED, + ALREADY_PARTICIPATED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 3d8e1124..96ec8433 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -40,11 +40,19 @@ List findAllByUserIdAndAppVariantAndActiveTrue( AppVariant appVariant ); + List findAllByAppVariantAndActiveTrue( + AppVariant appVariant + ); + boolean existsByUserIdAndAppVariantAndActiveTrue( Long userId, AppVariant appVariant ); + boolean existsByAppVariantAndActiveTrue( + AppVariant appVariant + ); + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( Long pushInstallationId, String installationId, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java new file mode 100644 index 00000000..9cd0b446 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java @@ -0,0 +1,51 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SurveyPushScheduleRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); + + List findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(String surveyKey); + + List findAllBySurveyKeyAndNotificationStageIn( + String surveyKey, + Collection notificationStages + ); + + Optional findBySurveyKeyAndNotificationStage( + String surveyKey, + SurveyNotificationStage notificationStage + ); + + @Query( + value = """ + SELECT * + FROM tb_survey_push_schedule + WHERE status = 'PENDING' + AND scheduled_at <= :now + ORDER BY scheduled_at ASC, survey_push_schedule_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDuePendingForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + List findAllByStatusAndScheduledAtLessThanEqualOrderByScheduledAtAscSurveyPushScheduleIdAsc( + SurveyPushScheduleStatus status, + LocalDateTime now + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java index b0acba42..0f8d3694 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java @@ -33,6 +33,7 @@ public List resolve( case INSTALLATION -> resolveInstallation(targetValue, appVariant); case USER -> resolveUser(targetValue, appVariant); case USER_GROUP -> throw new GlobalException(UNSUPPORTED_REQUEST); + case ALL -> resolveAll(targetValue, appVariant); }; List distinctInstallations = distinctByInstallation(installations); @@ -68,6 +69,17 @@ private List resolveUser( ); } + private List resolveAll( + String targetValue, + AppVariant appVariant + ) { + if (!"ALL".equals(targetValue)) { + throw new GlobalException(INVALID_INPUT); + } + + return pushInstallationRepository.findAllByAppVariantAndActiveTrue(appVariant); + } + private List distinctByInstallation(List installations) { Map distinct = new LinkedHashMap<>(); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java index 7e621e03..09a5793e 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -24,6 +24,9 @@ public class PushEventFlagService { @Value("${push.event.character-enabled:false}") private boolean characterDefaultEnabled; + @Value("${push.event.survey-enabled:false}") + private boolean surveyDefaultEnabled; + public boolean isEnabled(PushEventType eventType) { String redisValue = getRedisValue(eventType); if ("true".equalsIgnoreCase(redisValue)) { @@ -62,6 +65,7 @@ private boolean defaultEnabled(PushEventType eventType) { case CROWD -> crowdDefaultEnabled; case REPORT -> reportDefaultEnabled; case CHARACTER -> characterDefaultEnabled; + case SURVEY -> surveyDefaultEnabled; }; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java new file mode 100644 index 00000000..fe4b37f7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java @@ -0,0 +1,270 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleStageRes; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SurveyPushScheduleService { + + private static final int MAX_SURVEY_KEY_LENGTH = 64; + private static final List ADMIN_STAGES = List.of( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final Clock clock; + + @Transactional + public AdminSurveyPushScheduleRes upsertAdminSchedules( + String surveyKey, + AdminSurveyPushScheduleReq request + ) { + validateSurveyKey(surveyKey); + validateAdminRequest(request); + + LocalDateTime dMinus3At = request.deadlineNotificationAt().minusDays(3); + if (!request.startNotificationAt().isBefore(dMinus3At)) { + throw new GlobalException(INVALID_INPUT); + } + + upsertSchedule( + surveyKey, + SurveyNotificationStage.STARTED, + null, + request.startNotificationAt(), + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.D_MINUS_3, + null, + dMinus3At, + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.DEADLINE, + null, + request.deadlineNotificationAt(), + request.rewardPoint() + ); + + return getAdminSchedules(surveyKey); + } + + public AdminSurveyPushScheduleRes getAdminSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + Map schedules = new EnumMap<>(SurveyNotificationStage.class); + surveyPushScheduleRepository.findAllBySurveyKeyAndNotificationStageIn(surveyKey, ADMIN_STAGES) + .forEach(schedule -> schedules.put(schedule.getNotificationStage(), schedule)); + + int rewardPoint = schedules.values() + .stream() + .findFirst() + .map(SurveyPushSchedule::getRewardPoint) + .orElse(0); + + return new AdminSurveyPushScheduleRes( + surveyKey, + rewardPoint, + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.STARTED)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.D_MINUS_3)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.DEADLINE)) + ); + } + + @Transactional + public AdminSurveyPushScheduleRes cancelSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + LocalDateTime now = LocalDateTime.now(clock); + surveyPushScheduleRepository.findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(surveyKey) + .stream() + .filter(SurveyPushSchedule::isPending) + .forEach(schedule -> schedule.cancel(now)); + + return getAdminSchedules(surveyKey); + } + + @Transactional + public SurveyReminderRes remindAfterLater( + String surveyKey, + Long userId + ) { + validateSurveyKey(surveyKey); + if (userId == null || userId <= 0) { + throw new GlobalException(INVALID_INPUT); + } + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.DEADLINE) + .orElseThrow(() -> new GlobalException(INVALID_INPUT)); + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime remindAt = now.plusDays(1); + SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); + if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { + return new SurveyReminderRes(surveyKey, false, null, suppressedBy); + } + + String idempotencyKey = idempotencyKey( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId + ); + + return surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .map(existing -> updateExistingReminder(surveyKey, remindAt, deadline.getRewardPoint(), existing)) + .orElseGet(() -> createReminder(surveyKey, userId, remindAt, deadline.getRewardPoint(), idempotencyKey)); + } + + private void upsertSchedule( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint + ) { + String idempotencyKey = idempotencyKey(surveyKey, stage, targetUserId); + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .ifPresentOrElse( + schedule -> schedule.updatePendingSchedule(scheduledAt, rewardPoint), + () -> surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + stage, + targetUserId, + scheduledAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )) + ); + } + + private SurveyReminderRes updateExistingReminder( + String surveyKey, + LocalDateTime remindAt, + int rewardPoint, + SurveyPushSchedule existing + ) { + if (!existing.isPending()) { + return new SurveyReminderRes( + surveyKey, + false, + null, + SurveyReminderSuppressedBy.ALREADY_PROCESSED + ); + } + + existing.updatePendingSchedule(remindAt, rewardPoint); + return new SurveyReminderRes( + surveyKey, + true, + existing.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private SurveyReminderRes createReminder( + String surveyKey, + Long userId, + LocalDateTime remindAt, + int rewardPoint, + String idempotencyKey + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + SurveyPushSchedule saved = surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId, + remindAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )); + + return new SurveyReminderRes( + surveyKey, + true, + saved.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private SurveyReminderSuppressedBy suppressionForReminder( + String surveyKey, + LocalDateTime remindAt, + LocalDateTime deadlineAt + ) { + if (remindAt.isAfter(deadlineAt)) { + return SurveyReminderSuppressedBy.EXPIRED; + } + + LocalDate remindDate = remindAt.toLocalDate(); + if (remindDate.equals(deadlineAt.toLocalDate())) { + return SurveyReminderSuppressedBy.DEADLINE; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(remindDate::equals) + .map(ignored -> SurveyReminderSuppressedBy.D3) + .orElse(SurveyReminderSuppressedBy.NONE); + } + + private void validateAdminRequest(AdminSurveyPushScheduleReq request) { + if (request == null + || request.startNotificationAt() == null + || request.deadlineNotificationAt() == null + || request.rewardPoint() == null + || request.rewardPoint() < 0) { + throw new GlobalException(INVALID_INPUT); + } + + if (!request.startNotificationAt().isBefore(request.deadlineNotificationAt())) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateSurveyKey(String surveyKey) { + if (surveyKey == null || surveyKey.isBlank() || surveyKey.length() > MAX_SURVEY_KEY_LENGTH) { + throw new GlobalException(INVALID_INPUT); + } + } + + public static String idempotencyKey( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(stage)) { + return "survey:" + surveyKey + ":" + stage.name() + ":" + targetUserId; + } + return "survey:" + surveyKey + ":" + stage.name(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java new file mode 100644 index 00000000..91c21416 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -0,0 +1,188 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyPushScheduleWorker { + + private static final int BATCH_SIZE = 50; + private static final Long SYSTEM_CREATED_BY = 0L; + private static final String ALL_TARGET_VALUE = "ALL"; + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + private final Clock clock; + + @Scheduled(fixedDelayString = "${push.survey.poll-interval-ms:60000}") + @Transactional + public void processDueSchedules() { + processDueSchedulesOnce(); + } + + public int processDueSchedulesOnce() { + if (!pushEventFlagService.isEnabled(PushEventType.SURVEY)) { + return 0; + } + + LocalDateTime now = LocalDateTime.now(clock); + List schedules = surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked( + now, + BATCH_SIZE + ); + + schedules.forEach(schedule -> processSchedule(schedule, now)); + return schedules.size(); + } + + private void processSchedule( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage()) + && cancelReminderByLatestPriority(schedule, now)) { + return; + } + + if (!hasActiveTarget(schedule)) { + schedule.skip(now); + return; + } + + try { + pushDispatchService.enqueue(command(schedule)); + schedule.complete(now); + } catch (GlobalException e) { + if (ResultCode.INVALID_INPUT.equals(e.getResultCode())) { + schedule.skip(now); + return; + } + log.warn( + "Survey push schedule processing failed. scheduleId={}, stage={}, resultCode={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e.getResultCode() + ); + } catch (RuntimeException e) { + log.warn( + "Unexpected survey push schedule processing failure. scheduleId={}, stage={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e + ); + } + } + + private boolean cancelReminderByLatestPriority( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + LocalDate executionDate = schedule.getScheduledAt().toLocalDate(); + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.DEADLINE) + .orElse(null); + if (deadline == null) { + schedule.cancel(now); + return true; + } + + if (executionDate.equals(deadline.getScheduledAt().toLocalDate()) + || now.isAfter(deadline.getScheduledAt())) { + schedule.cancel(now); + return true; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(executionDate::equals) + .map(ignored -> { + schedule.cancel(now); + return true; + }) + .orElse(false); + } + + private PushDispatchCommand command(SurveyPushSchedule schedule) { + PushContent content = content(schedule); + + return new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + targetType(schedule), + targetValue(schedule), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + schedule.getIdempotencyKey(), + SYSTEM_CREATED_BY + ); + } + + private PushContent content(SurveyPushSchedule schedule) { + return switch (schedule.getNotificationStage()) { + case STARTED -> DomainPushContentFactory.surveyStarted(); + case D_MINUS_3 -> DomainPushContentFactory.surveyDMinus3(); + case DEADLINE -> DomainPushContentFactory.surveyDeadline(schedule.getRewardPoint()); + case REMIND_AFTER_LATER -> DomainPushContentFactory.surveyRemindAfterLater(schedule.getRewardPoint()); + }; + } + + private PushTargetType targetType(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return PushTargetType.USER; + } + return PushTargetType.ALL; + } + + private String targetValue(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return String.valueOf(schedule.getTargetUserId()); + } + return ALL_TARGET_VALUE; + } + + private boolean hasActiveTarget(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return schedule.getTargetUserId() != null + && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + schedule.getTargetUserId(), + AppVariant.PRODUCTION + ); + } + + return pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java index 1e1581e4..a3041462 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java @@ -47,6 +47,34 @@ public static PushContent characterUnlocked(String characterName) { ); } + public static PushContent surveyStarted() { + return new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDMinus3() { + return new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDeadline(int rewardPoint) { + return new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 " + rewardPoint + " 포인트를 받을 수 있어요.(5초 소요)" + ); + } + + public static PushContent surveyRemindAfterLater(int rewardPoint) { + return new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 " + rewardPoint + "포인트를 받아보세요.(5초 소요)" + ); + } + private static String joinNonBlank( String first, String second diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 863190c8..6eacf1f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -167,6 +167,9 @@ push: crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} + survey-enabled: ${PUSH_EVENT_SURVEY_ENABLED:false} + survey: + poll-interval-ms: ${PUSH_SURVEY_POLL_INTERVAL_MS:60000} worker: enabled: ${PUSH_WORKER_ENABLED:false} fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} diff --git a/src/main/resources/db/migration/V1__create_survey_push_schedule.sql b/src/main/resources/db/migration/V1__create_survey_push_schedule.sql new file mode 100644 index 00000000..ac4c37f3 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_survey_push_schedule.sql @@ -0,0 +1,18 @@ +CREATE TABLE tb_survey_push_schedule ( + survey_push_schedule_id BIGINT NOT NULL AUTO_INCREMENT, + survey_key VARCHAR(64) NOT NULL, + notification_stage VARCHAR(40) NOT NULL, + target_user_id BIGINT NULL, + scheduled_at DATETIME(6) NOT NULL, + reward_point INT NOT NULL, + status VARCHAR(30) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + created_at DATETIME(6) NOT NULL, + processed_at DATETIME(6) NULL, + PRIMARY KEY (survey_push_schedule_id), + CONSTRAINT uk_survey_push_schedule_idempotency_key UNIQUE (idempotency_key), + CONSTRAINT chk_survey_push_schedule_reward_point CHECK (reward_point >= 0) +); + +CREATE INDEX idx_survey_push_schedule_status_scheduled_at + ON tb_survey_push_schedule (status, scheduled_at); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java new file mode 100644 index 00000000..1978fc94 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java @@ -0,0 +1,53 @@ +package devkor.com.teamcback.domain.notification.resolver; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushTargetResolverTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + private PushTargetResolver resolver; + + @BeforeEach + void setUp() { + resolver = new PushTargetResolver(pushInstallationRepository); + } + + @Test + void allTargetResolvesDistinctActiveProductionInstallations() { + PushInstallation first = new PushInstallation(1L, "install-1", "ExponentPushToken[first]", AppVariant.PRODUCTION); + PushInstallation duplicate = new PushInstallation(2L, "install-1", "ExponentPushToken[duplicate]", AppVariant.PRODUCTION); + PushInstallation second = new PushInstallation(3L, "install-2", "ExponentPushToken[second]", AppVariant.PRODUCTION); + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of(first, duplicate, second)); + + List resolved = resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION); + + assertThat(resolved).containsExactly(first, second); + } + + @Test + void allTargetRejectsNonAllTargetValue() { + assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "1", AppVariant.PRODUCTION)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java index c554106b..6b293256 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -32,6 +32,7 @@ void setUp() { ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); + ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); when(redisTemplate.opsForValue()).thenReturn(valueOperations); } @@ -40,9 +41,11 @@ void setUp() { void returnsYamlDefaultWhenRedisValueDoesNotExist() { when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.SURVEY.redisKey())).thenReturn(null); assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + assertThat(service.isEnabled(PushEventType.SURVEY)).isFalse(); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java new file mode 100644 index 00000000..e5bae585 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java @@ -0,0 +1,238 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleServiceTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-06T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository repository; + + private SurveyPushScheduleService service; + + @BeforeEach + void setUp() { + service = new SurveyPushScheduleService(repository, FIXED_CLOCK); + } + + @Test + void upsertAdminSchedulesCreatesThreeWholeAudienceSchedules() { + List saved = new ArrayList<>(); + when(repository.findByIdempotencyKey(any())).thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> { + SurveyPushSchedule schedule = invocation.getArgument(0); + saved.add(schedule); + return schedule; + }); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenAnswer(ignored -> saved); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + ); + + assertThat(saved).hasSize(3); + assertThat(saved) + .extracting(SurveyPushSchedule::getNotificationStage) + .containsExactly( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + assertThat(saved.get(1).getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(saved).allSatisfy(schedule -> { + assertThat(schedule.getTargetUserId()).isNull(); + assertThat(schedule.getRewardPoint()).isEqualTo(100); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + }); + } + + @Test + void upsertAdminSchedulesUpdatesOnlyPendingExistingSchedule() { + SurveyPushSchedule pending = schedule(SurveyNotificationStage.STARTED, null, "2026-08-10T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-17T10:01:00")); + + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":STARTED")) + .thenReturn(Optional.of(pending)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":D_MINUS_3")) + .thenReturn(Optional.of(completed)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":DEADLINE")) + .thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenReturn(List.of(pending, completed)); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-11T10:00:00"), + LocalDateTime.parse("2026-08-21T10:00:00"), + 200 + ) + ); + + assertThat(pending.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-11T10:00:00")); + assertThat(pending.getRewardPoint()).isEqualTo(200); + assertThat(completed.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(completed.getRewardPoint()).isEqualTo(100); + assertThat(completed.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void upsertAdminSchedulesValidatesTimesAndRewardPoint() { + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-18T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + -1 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void remindAfterLaterCreatesOrUpdatesOnePendingPersonalSchedule() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 50); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isTrue(); + assertThat(response.scheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-07T10:00:00")); + assertThat(existing.getRewardPoint()).isEqualTo(100); + } + + @Test + void remindAfterLaterSuppressesByPriorityAndExpiry() { + assertReminderSuppressed( + "2026-08-07T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.DEADLINE + ); + assertReminderSuppressed( + "2026-08-20T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.D3 + ); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + assertThat(response.scheduled()).isFalse(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + } + + @Test + void remindAfterLaterDoesNotRecreateProcessedReminder() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-07T09:01:00")); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(completed)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.ALREADY_PROCESSED); + } + + private void assertReminderSuppressed( + String deadlineAt, + String d3At, + SurveyReminderSuppressedBy suppressedBy + ) { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, deadlineAt, 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, d3At, 100))); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.suppressedBy()).isEqualTo(suppressedBy); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java new file mode 100644 index 00000000..94335abf --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java @@ -0,0 +1,188 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleWorkerTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-17T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository surveyPushScheduleRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private SurveyPushScheduleWorker worker; + + @BeforeEach + void setUp() { + worker = new SurveyPushScheduleWorker( + surveyPushScheduleRepository, + pushInstallationRepository, + pushDispatchService, + pushEventFlagService, + FIXED_CLOCK + ); + } + + @Test + void surveyFlagFalseDoesNotClaimOrEnqueue() { + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(false); + + int processed = worker.processDueSchedulesOnce(); + + assertThat(processed).isZero(); + verify(surveyPushScheduleRepository, never()).findDuePendingForUpdateSkipLocked(any(), any(Integer.class)); + verify(pushDispatchService, never()).enqueue(any()); + } + + @Test + void dueStartedScheduleEnqueuesAllProductionActualGeneralAndCompletes() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); + assertThat(command.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(command.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(command.targetType()).isEqualTo(PushTargetType.ALL); + assertThat(command.targetValue()).isEqualTo("ALL"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void dueReminderScheduleEnqueuesUserTarget() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-18T10:00:00", 100))); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.USER); + assertThat(captor.getValue().targetValue()).isEqualTo("7"); + assertThat(captor.getValue().title()).isEqualTo("잠깐, 설문을 잊지 않으셨나요?"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void noActiveTargetMarksSkippedWithoutEnqueue() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(false); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.SKIPPED); + } + + @Test + void unexpectedExceptionKeepsSchedulePendingForRetry() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new RuntimeException("boom")); + + worker.processDueSchedulesOnce(); + + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void reminderIsCancelledWhenLatestD3DateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java index 0ea3ef2c..4e78095c 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java @@ -52,4 +52,27 @@ void reportResolvedCreatesConfiguredTitleAndBody() { assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); } + @Test + void surveyContentsAreConfiguredExactly() { + assertThat(DomainPushContentFactory.surveyStarted()) + .isEqualTo(new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDMinus3()) + .isEqualTo(new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDeadline(100)) + .isEqualTo(new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 100 포인트를 받을 수 있어요.(5초 소요)" + )); + assertThat(DomainPushContentFactory.surveyRemindAfterLater(100)) + .isEqualTo(new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 100포인트를 받아보세요.(5초 소요)" + )); + } } From 385efbaf90df17b92d81c1c2997e208985fb4bf8 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Thu, 6 Aug 2026 19:14:08 +0900 Subject: [PATCH 37/54] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=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 --- .../service/SurveyPushScheduleService.java | 20 +++- .../service/SurveyPushScheduleWorker.java | 9 +- .../SurveyPushScheduleServiceTest.java | 84 +++++++++++++++++ .../service/SurveyPushScheduleWorkerTest.java | 92 ++++++++++++++++++- 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java index fe4b37f7..b27189b4 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java @@ -125,17 +125,18 @@ public SurveyReminderRes remindAfterLater( LocalDateTime now = LocalDateTime.now(clock); LocalDateTime remindAt = now.plusDays(1); - SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); - if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { - return new SurveyReminderRes(surveyKey, false, null, suppressedBy); - } - String idempotencyKey = idempotencyKey( surveyKey, SurveyNotificationStage.REMIND_AFTER_LATER, userId ); + SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); + if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { + cancelPendingReminderIfExists(idempotencyKey, now); + return new SurveyReminderRes(surveyKey, false, null, suppressedBy); + } + return surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) .map(existing -> updateExistingReminder(surveyKey, remindAt, deadline.getRewardPoint(), existing)) .orElseGet(() -> createReminder(surveyKey, userId, remindAt, deadline.getRewardPoint(), idempotencyKey)); @@ -214,6 +215,15 @@ private SurveyReminderRes createReminder( ); } + private void cancelPendingReminderIfExists( + String idempotencyKey, + LocalDateTime now + ) { + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .filter(SurveyPushSchedule::isPending) + .ifPresent(schedule -> schedule.cancel(now)); + } + private SurveyReminderSuppressedBy suppressionForReminder( String surveyKey, LocalDateTime remindAt, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java index 91c21416..c435cda2 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -14,7 +14,6 @@ import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; import java.time.Clock; import java.time.LocalDate; import java.time.LocalDateTime; @@ -80,10 +79,6 @@ && cancelReminderByLatestPriority(schedule, now)) { pushDispatchService.enqueue(command(schedule)); schedule.complete(now); } catch (GlobalException e) { - if (ResultCode.INVALID_INPUT.equals(e.getResultCode())) { - schedule.skip(now); - return; - } log.warn( "Survey push schedule processing failed. scheduleId={}, stage={}, resultCode={}", schedule.getSurveyPushScheduleId(), @@ -92,10 +87,10 @@ && cancelReminderByLatestPriority(schedule, now)) { ); } catch (RuntimeException e) { log.warn( - "Unexpected survey push schedule processing failure. scheduleId={}, stage={}", + "Unexpected survey push schedule processing failure. scheduleId={}, stage={}, exception={}", schedule.getSurveyPushScheduleId(), schedule.getNotificationStage(), - e + e.getClass().getSimpleName() ); } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java index e5bae585..494b3000 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java @@ -27,6 +27,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -178,12 +180,91 @@ void remindAfterLaterSuppressesByPriorityAndExpiry() { when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); } + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByD3() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByDeadline() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T11:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.DEADLINE); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenExpired() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterDoesNotCreateCancelledRowWhenSuppressedWithoutExistingReminder() { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + @Test void remindAfterLaterDoesNotRecreateProcessedReminder() { SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); @@ -212,10 +293,13 @@ private void assertReminderSuppressed( .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, deadlineAt, 100))); when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, d3At, 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); assertThat(response.suppressedBy()).isEqualTo(suppressedBy); } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java index 94335abf..226c2391 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java @@ -12,6 +12,8 @@ import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; @@ -28,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -124,7 +127,7 @@ void dueReminderScheduleEnqueuesUserTarget() { } @Test - void noActiveTargetMarksSkippedWithoutEnqueue() { + void noActiveTargetMarksSkippedWithProcessedAt() { SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) @@ -135,10 +138,43 @@ void noActiveTargetMarksSkippedWithoutEnqueue() { verify(pushDispatchService, never()).enqueue(any()); assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.SKIPPED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void pushDispatchInvalidInputNotCausedByNoTargetKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.INVALID_INPUT)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void pushDispatchGlobalExceptionKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.UNSUPPORTED_REQUEST)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); } @Test - void unexpectedExceptionKeepsSchedulePendingForRetry() { + void pushDispatchRuntimeExceptionKeepsPending() { SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) @@ -148,6 +184,7 @@ void unexpectedExceptionKeepsSchedulePendingForRetry() { worker.processDueSchedulesOnce(); + verify(pushDispatchService).enqueue(any()); assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); assertThat(schedule.getProcessedAt()).isNull(); } @@ -169,6 +206,57 @@ void reminderIsCancelledWhenLatestD3DateHasPriority() { assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); } + @Test + void reminderIsCancelledWhenLatestDeadlineDateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void reminderIsCancelledWhenNowIsAfterDeadline() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-16T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:59:59", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void deadlineAndD3SchedulesDoNotRunReminderPriorityCancellation() { + SurveyPushSchedule started = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule d3 = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(started, d3, deadline)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + verify(surveyPushScheduleRepository, never()).findBySurveyKeyAndNotificationStage(any(), any()); + verify(pushDispatchService, times(3)).enqueue(any()); + assertThat(started.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(d3.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(deadline.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + private SurveyPushSchedule schedule( SurveyNotificationStage stage, Long targetUserId, From 66e2c8b2944d17ff8703d1c0bfbb4bd116ea7756 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Thu, 6 Aug 2026 23:06:25 +0900 Subject: [PATCH 38/54] fix: remove unused push database migration --- .../V1__create_survey_push_schedule.sql | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 src/main/resources/db/migration/V1__create_survey_push_schedule.sql diff --git a/src/main/resources/db/migration/V1__create_survey_push_schedule.sql b/src/main/resources/db/migration/V1__create_survey_push_schedule.sql deleted file mode 100644 index ac4c37f3..00000000 --- a/src/main/resources/db/migration/V1__create_survey_push_schedule.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE tb_survey_push_schedule ( - survey_push_schedule_id BIGINT NOT NULL AUTO_INCREMENT, - survey_key VARCHAR(64) NOT NULL, - notification_stage VARCHAR(40) NOT NULL, - target_user_id BIGINT NULL, - scheduled_at DATETIME(6) NOT NULL, - reward_point INT NOT NULL, - status VARCHAR(30) NOT NULL, - idempotency_key VARCHAR(128) NOT NULL, - created_at DATETIME(6) NOT NULL, - processed_at DATETIME(6) NULL, - PRIMARY KEY (survey_push_schedule_id), - CONSTRAINT uk_survey_push_schedule_idempotency_key UNIQUE (idempotency_key), - CONSTRAINT chk_survey_push_schedule_reward_point CHECK (reward_point >= 0) -); - -CREATE INDEX idx_survey_push_schedule_status_scheduled_at - ON tb_survey_push_schedule (status, scheduled_at); From 2f17d3aa77e6a310bee6a12eb3afe9a23bc88570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 02:11:22 +0900 Subject: [PATCH 39/54] feat: add admin social login endpoint --- .../user/controller/UserController.java | 11 ++ .../user/dto/request/AdminLoginReq.java | 15 +++ .../user/dto/response/AdminLoginRes.java | 24 ++++ .../domain/user/service/UserService.java | 26 +++++ .../service/UserServiceAdminLoginTest.java | 103 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java index 30af7515..0814ac83 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java +++ b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java @@ -1,7 +1,9 @@ package devkor.com.teamcback.domain.user.controller; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -59,6 +61,15 @@ public CommonResponse releaseLogin( return CommonResponse.success(userService.releaseLogin(loginUserReq)); } + /** + * 관리자 대시보드 소셜 로그인 + */ + @Operation(summary = "관리자 소셜 로그인", description = "Google 또는 Kakao ID Token을 검증하고 기존 ADMIN 계정에만 토큰을 발급") + @PostMapping("/login/admin") + public CommonResponse adminLogin(@RequestBody AdminLoginReq adminLoginReq) { + return CommonResponse.success(userService.adminLogin(adminLoginReq)); + } + /** * 자동 로그인 */ diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java new file mode 100644 index 00000000..dd8c20e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.user.dto.request; + +import devkor.com.teamcback.domain.user.entity.Provider; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "관리자 소셜 로그인 요청") +public class AdminLoginReq { + @Schema(description = "관리자 소셜 로그인 제공자", allowableValues = {"GOOGLE", "KAKAO"}) + private Provider provider; + + @Schema(description = "소셜 로그인에서 발급받은 OIDC ID Token") + private String token; +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java new file mode 100644 index 00000000..16ba6507 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.user.dto.response; + +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class AdminLoginRes { + private final String accessToken; + private final String refreshToken; + private final Long userId; + private final String username; + private final String email; + private final Provider provider; + + public AdminLoginRes(String accessToken, String refreshToken, User user) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.email = user.getEmail(); + this.provider = user.getProvider(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 68101dde..6e245f35 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -12,7 +12,9 @@ import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -126,6 +128,30 @@ private String validateToken(Provider provider, String token) { }; } + /** + * 관리자 대시보드용 소셜 로그인. + * 일반 로그인과 달리 신규 사용자를 만들지 않으며 기존 ADMIN 계정만 토큰을 발급한다. + */ + @Transactional(readOnly = true) + public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { + Provider provider = adminLoginReq.getProvider(); + if(provider != Provider.GOOGLE && provider != Provider.KAKAO) { + throw new GlobalException(INVALID_INPUT); + } + + String email = validateToken(provider, adminLoginReq.getToken()); + User user = userRepository.findByEmailAndProvider(email, provider); + if(user == null || user.getRole() != Role.ADMIN) { + throw new GlobalException(FORBIDDEN); + } + + return new AdminLoginRes( + jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), + jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), + user + ); + } + /** * 자동 로그인 */ diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java new file mode 100644 index 00000000..d36467c2 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -0,0 +1,103 @@ +package devkor.com.teamcback.domain.user.service; + +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceAdminLoginTest { + @InjectMocks + UserService userService; + + @Mock UserRepository userRepository; + @Mock CategoryRepository categoryRepository; + @Mock BookmarkRepository bookmarkRepository; + @Mock UserBookmarkLogRepository userBookmarkLogRepository; + @Mock SuggestionRepository suggestionRepository; + @Mock UserCharacterRepository userCharacterRepository; + @Mock JwtUtil jwtUtil; + @Mock KakaoValidator kakaoValidator; + @Mock GoogleValidator googleValidator; + @Mock AppleValidator appleValidator; + @Mock PasswordEncoder passwordEncoder; + @Mock PushInstallationService pushInstallationService; + + AdminLoginReq request; + + @BeforeEach + void setUp() { + request = new AdminLoginReq(); + ReflectionTestUtils.setField(request, "provider", Provider.GOOGLE); + ReflectionTestUtils.setField(request, "token", "google-id-token"); + } + + @Test + void issuesTokensOnlyForExistingAdmin() { + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.GOOGLE); + ReflectionTestUtils.setField(admin, "userId", 7L); + when(googleValidator.validateToken("google-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.GOOGLE)).thenReturn(admin); + when(jwtUtil.createAccessToken("7", "ROLE_ADMIN")).thenReturn("access"); + when(jwtUtil.createRefreshToken("7", "ROLE_ADMIN")).thenReturn("refresh"); + + AdminLoginRes result = userService.adminLogin(request); + + assertEquals("access", result.getAccessToken()); + assertEquals("refresh", result.getRefreshToken()); + assertEquals(7L, result.getUserId()); + assertEquals("admin@example.com", result.getEmail()); + } + + @Test + void rejectsNonAdminWithoutCreatingUser() { + User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); + when(googleValidator.validateToken("google-id-token")).thenReturn("user@example.com"); + when(userRepository.findByEmailAndProvider("user@example.com", Provider.GOOGLE)).thenReturn(user); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(FORBIDDEN, exception.getResultCode()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void rejectsUnsupportedProviderBeforeTokenValidation() { + ReflectionTestUtils.setField(request, "provider", Provider.APPLE); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(INVALID_INPUT, exception.getResultCode()); + verify(appleValidator, never()).validateToken("google-id-token"); + } +} From ef17774414924c0ff6808697861160088edc7331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 02:43:43 +0900 Subject: [PATCH 40/54] fix: remove unused public IP action from deploy workflows --- .github/workflows/cd-dev.yml | 4 ---- .github/workflows/cd.yml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index dc0ba501..38998e8f 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -53,10 +53,6 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: - - name: Get Github action IP - id: ip - uses: haythem/public-ip@v1.2 - - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 199d0ce8..cf73b241 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -56,10 +56,6 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: - - name: Get Github action IP - id: ip - uses: haythem/public-ip@v1.2 - - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master From b1769a143bd4595b54cad79fcee388157f5b457d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 03:10:52 +0900 Subject: [PATCH 41/54] chore: trigger dev deployment From c6f1549ce10c4743caf170e3992637c8baae7e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 03:11:43 +0900 Subject: [PATCH 42/54] ci: allow manual dev deployment --- .github/workflows/cd-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 38998e8f..3312f967 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,6 +1,7 @@ name: CI/CD using github actions & docker on: + workflow_dispatch: push: branches: - develop From d45d37049e302760a0b7b50ff944936a58ceccb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 03:40:35 +0900 Subject: [PATCH 43/54] feat: add usage surveys and review author profiles --- .../dto/response/SearchPlaceReviewRes.java | 14 +- .../domain/review/service/ReviewService.java | 21 ++- .../controller/UsageSurveyController.java | 52 +++++++ .../RecordUsageSurveyDismissalReq.java | 10 ++ .../request/SubmitUsageSurveyResponseReq.java | 9 ++ .../SubmitUsageSurveyResponseRes.java | 30 ++++ .../dto/response/UsageSurveyStatusRes.java | 10 ++ .../entity/UsageSurveyDismissReason.java | 6 + .../entity/UsageSurveyDismissal.java | 49 ++++++ .../entity/UsageSurveyQuestion.java | 8 + .../entity/UsageSurveyResponse.java | 55 +++++++ .../UsageSurveyDismissalRepository.java | 7 + .../UsageSurveyResponseRepository.java | 12 ++ .../service/UsageSurveyService.java | 137 +++++++++++++++++ .../teamcback/global/response/ResultCode.java | 6 +- .../global/security/SecurityConfig.java | 1 + .../service/UsageSurveyServiceTest.java | 142 ++++++++++++++++++ 17 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java index b9f71187..a68756b0 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java @@ -14,6 +14,12 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰 사용자 id") private Long userId; + @Schema(description = "리뷰 작성자 닉네임") + private String username; + + @Schema(description = "리뷰 작성자 프로필 이미지 URL") + private String profileImageUrl; + @Schema(description = "리뷰 id") private Long reviewId; @@ -29,8 +35,14 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰별 사진 목록") private List reviewImageRes; - public SearchPlaceReviewRes(Review review, List reviewImageRes) { + public SearchPlaceReviewRes( + Review review, + List reviewImageRes, + String profileImageUrl + ) { this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.username = review.getUser() != null ? review.getUser().getUsername() : null; + this.profileImageUrl = profileImageUrl; this.reviewId = review.getId(); this.isRevisit = review.isRevisit(); this.comment = review.getComment(); diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java index 6f284976..610a71d0 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -3,6 +3,7 @@ import devkor.com.teamcback.domain.common.entity.File; import devkor.com.teamcback.domain.common.repository.FileRepository; import devkor.com.teamcback.domain.common.util.FileUtil; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.domain.place.repository.PlaceRepository; @@ -43,6 +44,7 @@ public class ReviewService { private final PlaceReviewTagMapRepository placeReviewTagMapRepository; private final FileRepository fileRepository; private final UserRepository userRepository; + private final CharacterRepository characterRepository; private final FileUtil fileUtil; /** @@ -88,6 +90,17 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 리뷰 최신순 조회 List reviewList = reviewRepository.findAllByPlaceAndIsReportedOrderByCreatedAtDesc(place, false); + List equippedCharacterIds = reviewList.stream() + .map(Review::getUser) + .filter(user -> user != null && user.getEquippedCharacterId() != null) + .map(User::getEquippedCharacterId) + .distinct() + .toList(); + Map equippedCharacterImages = new HashMap<>(); + characterRepository.findAllById(equippedCharacterIds).forEach(character -> + equippedCharacterImages.put(character.getCharacterId(), character.getImageUrl()) + ); + // 리뷰별 이미지 조회(썸네일) List reviewImageList = new ArrayList<>(); @@ -98,7 +111,13 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 썸네일 이미지 추출 List imageResList = reviewFiles.stream().map(file -> new SearchReviewImageRes(file.getId(), file.getThumbSavedName())).toList(); - reviewImageList.add(new SearchPlaceReviewRes(review, imageResList)); + Long equippedCharacterId = review.getUser() != null + ? review.getUser().getEquippedCharacterId() + : null; + String profileImageUrl = equippedCharacterId != null + ? equippedCharacterImages.get(equippedCharacterId) + : null; + reviewImageList.add(new SearchPlaceReviewRes(review, imageResList, profileImageUrl)); } // 리뷰 전체 이미지 조회(원본 - 10장까지) diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java new file mode 100644 index 00000000..401b50c4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java @@ -0,0 +1,52 @@ +package devkor.com.teamcback.domain.usagesurvey.controller; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; +import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; +import devkor.com.teamcback.domain.usagesurvey.service.UsageSurveyService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/usage-surveys") +public class UsageSurveyController { + private final UsageSurveyService usageSurveyService; + + @GetMapping("/status") + public CommonResponse getStatus( + @AuthenticationPrincipal UserDetailsImpl userDetail + ) { + return CommonResponse.success( + usageSurveyService.getStatus(userDetail.getUser().getUserId()) + ); + } + + @PostMapping("/responses") + public CommonResponse submitResponse( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestBody SubmitUsageSurveyResponseReq req + ) { + return CommonResponse.success(usageSurveyService.submitResponse( + userDetail.getUser().getUserId(), + req + )); + } + + @PostMapping("/dismissals") + public CommonResponse recordDismissal( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestBody RecordUsageSurveyDismissalReq req + ) { + usageSurveyService.recordDismissal(userDetail.getUser().getUserId(), req); + return CommonResponse.success(null); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java new file mode 100644 index 00000000..a22561d2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.request; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; + +public record RecordUsageSurveyDismissalReq( + UsageSurveyQuestion questionKey, + UsageSurveyDismissReason reason +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java new file mode 100644 index 00000000..791ea24a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.request; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; + +public record SubmitUsageSurveyResponseReq( + UsageSurveyQuestion questionKey, + String optionKey +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java new file mode 100644 index 00000000..102a3e54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java @@ -0,0 +1,30 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.response; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class SubmitUsageSurveyResponseRes implements ScoreUpdateResponse { + private final UsageSurveyQuestion questionKey; + private final String optionKey; + private final int rewardPoint; + + @Setter + private boolean levelUp; + @Setter + private Long currentScore; + @Setter + private boolean scoreGained; + + public SubmitUsageSurveyResponseRes( + UsageSurveyQuestion questionKey, + String optionKey, + int rewardPoint + ) { + this.questionKey = questionKey; + this.optionKey = optionKey; + this.rewardPoint = rewardPoint; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java new file mode 100644 index 00000000..de45ca24 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.response; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import java.util.List; + +public record UsageSurveyStatusRes( + List answeredQuestionKeys, + int rewardPoint +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java new file mode 100644 index 00000000..6951e557 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +public enum UsageSurveyDismissReason { + LATER, + AUTO_DISMISS +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java new file mode 100644 index 00000000..ddb56b6a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java @@ -0,0 +1,49 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "tb_usage_survey_dismissal") +public class UsageSurveyDismissal extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "question_key", nullable = false, length = 40) + private UsageSurveyQuestion questionKey; + + @Enumerated(EnumType.STRING) + @Column(name = "dismiss_reason", nullable = false, length = 30) + private UsageSurveyDismissReason dismissReason; + + public UsageSurveyDismissal( + User user, + UsageSurveyQuestion questionKey, + UsageSurveyDismissReason dismissReason + ) { + this.user = user; + this.questionKey = questionKey; + this.dismissReason = dismissReason; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java new file mode 100644 index 00000000..e3b15b13 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +public enum UsageSurveyQuestion { + INSTALL_REASON, + RECENT_USE_REASON, + DESIRED_FEATURE, + DISAPPOINTMENT +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java new file mode 100644 index 00000000..826694c7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java @@ -0,0 +1,55 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "tb_usage_survey_response", + uniqueConstraints = @UniqueConstraint( + name = "uk_usage_survey_response_user_question", + columnNames = {"user_id", "question_key"} + ) +) +public class UsageSurveyResponse extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "question_key", nullable = false, length = 40) + private UsageSurveyQuestion questionKey; + + @Column(name = "option_key", nullable = false, length = 60) + private String optionKey; + + public UsageSurveyResponse( + User user, + UsageSurveyQuestion questionKey, + String optionKey + ) { + this.user = user; + this.questionKey = questionKey; + this.optionKey = optionKey; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java new file mode 100644 index 00000000..2fb11415 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.usagesurvey.repository; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UsageSurveyDismissalRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java new file mode 100644 index 00000000..9dd9ceaa --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.usagesurvey.repository; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UsageSurveyResponseRepository extends JpaRepository { + boolean existsByUserUserIdAndQuestionKey(Long userId, UsageSurveyQuestion questionKey); + + List findAllByUserUserId(Long userId); +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java new file mode 100644 index 00000000..e9c72bba --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java @@ -0,0 +1,137 @@ +package devkor.com.teamcback.domain.usagesurvey.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; +import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UsageSurveyService { + public static final int REWARD_POINT = 10; + + private static final Map> ALLOWED_OPTIONS = Map.of( + UsageSurveyQuestion.INSTALL_REASON, Set.of( + "COMPLEX_CAMPUS_ROUTES", + "SCHOOL_INFO", + "FACILITIES", + "LOUNGE_CROWDING", + "BUILDING_INTERIOR" + ), + UsageSurveyQuestion.RECENT_USE_REASON, Set.of( + "ROUTE", + "CROWDING", + "FACILITIES", + "MEAL_SHUTTLE", + "OTHER" + ), + UsageSurveyQuestion.DESIRED_FEATURE, Set.of( + "SCHEDULE_ROUTE", + "STUDY_ROOM_LINK", + "CUP_AVAILABILITY", + "QUIET_ALERT", + "COMMUNITY" + ), + UsageSurveyQuestion.DISAPPOINTMENT, Set.of( + "STALE_INFO", + "NAVIGATION_DIFFICULTY", + "MISSING_REVIEW_OUTLET", + "CROWD_MISMATCH", + "OTHER" + ) + ); + + private final UsageSurveyResponseRepository responseRepository; + private final UsageSurveyDismissalRepository dismissalRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public UsageSurveyStatusRes getStatus(Long userId) { + findUser(userId); + List answeredQuestionKeys = responseRepository + .findAllByUserUserId(userId) + .stream() + .map(UsageSurveyResponse::getQuestionKey) + .distinct() + .toList(); + return new UsageSurveyStatusRes(answeredQuestionKeys, REWARD_POINT); + } + + @Transactional + @UpdateScore(addScore = REWARD_POINT) + public SubmitUsageSurveyResponseRes submitResponse( + Long userId, + SubmitUsageSurveyResponseReq req + ) { + validateResponse(req); + if (responseRepository.existsByUserUserIdAndQuestionKey(userId, req.questionKey())) { + throw new GlobalException(ALREADY_ANSWERED_USAGE_SURVEY); + } + + User user = findUser(userId); + responseRepository.save(new UsageSurveyResponse( + user, + req.questionKey(), + req.optionKey() + )); + return new SubmitUsageSurveyResponseRes( + req.questionKey(), + req.optionKey(), + REWARD_POINT + ); + } + + @Transactional + public void recordDismissal( + Long userId, + RecordUsageSurveyDismissalReq req + ) { + if (req == null || req.questionKey() == null) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + User user = findUser(userId); + UsageSurveyDismissReason reason = req.reason() == null + ? UsageSurveyDismissReason.LATER + : req.reason(); + dismissalRepository.save(new UsageSurveyDismissal( + user, + req.questionKey(), + reason + )); + } + + private void validateResponse(SubmitUsageSurveyResponseReq req) { + if (req == null || req.questionKey() == null || req.optionKey() == null) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + Set options = ALLOWED_OPTIONS.get(req.questionKey()); + if (options == null || !options.contains(req.optionKey())) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index acfc68ec..9287f63a 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -120,7 +120,11 @@ public enum ResultCode { NOT_OWNED_CHARACTER(HttpStatus.BAD_REQUEST, 18003, "보유하지 않은 캐릭터입니다."), CHARACTER_IN_USE(HttpStatus.CONFLICT, 18004, "사용자가 보유 중인 캐릭터는 삭제할 수 없습니다."), INACTIVE_CHARACTER(HttpStatus.BAD_REQUEST, 18005, "비활성화된 캐릭터입니다."), - INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 18006, "레벨이 부족합니다."); + INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 18006, "레벨이 부족합니다."), + + // 사용 성향 조사 19000번대 + ALREADY_ANSWERED_USAGE_SURVEY(HttpStatus.CONFLICT, 19000, "이미 응답한 조사 문항입니다."), + INVALID_USAGE_SURVEY_OPTION(HttpStatus.BAD_REQUEST, 19001, "유효하지 않은 조사 응답입니다."); private final HttpStatus status; diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index de71b354..90e13621 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -94,6 +94,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 .requestMatchers("/api/store/**").authenticated() // 캐릭터 스토어는 로그인 필요 + .requestMatchers("/api/usage-surveys/**").authenticated() // 사용 성향 조사는 로그인 필요 .requestMatchers(HttpMethod.POST, "/api/notifications/test").authenticated() .anyRequest().permitAll() ).exceptionHandling(ex -> ex diff --git a/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java b/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java new file mode 100644 index 00000000..9d53c9e7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java @@ -0,0 +1,142 @@ +package devkor.com.teamcback.domain.usagesurvey.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UsageSurveyServiceTest { + @Mock + private UsageSurveyResponseRepository responseRepository; + @Mock + private UsageSurveyDismissalRepository dismissalRepository; + @Mock + private UserRepository userRepository; + + private UsageSurveyService service; + private User user; + + @BeforeEach + void setUp() { + service = new UsageSurveyService( + responseRepository, + dismissalRepository, + userRepository + ); + user = new User("survey-user", "survey@test.com", Role.USER, Provider.KAKAO); + lenient().when(userRepository.findById(7L)).thenReturn(Optional.of(user)); + } + + @Test + void statusReturnsAnsweredQuestionsAndReward() { + when(responseRepository.findAllByUserUserId(7L)).thenReturn(List.of( + new UsageSurveyResponse( + user, + UsageSurveyQuestion.INSTALL_REASON, + "SCHOOL_INFO" + ) + )); + + var result = service.getStatus(7L); + + assertThat(result.answeredQuestionKeys()) + .containsExactly(UsageSurveyQuestion.INSTALL_REASON); + assertThat(result.rewardPoint()).isEqualTo(10); + } + + @Test + void validResponseIsStoredAndMethodCarriesTenPointReward() throws Exception { + var req = new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.DESIRED_FEATURE, + "STUDY_ROOM_LINK" + ); + + var result = service.submitResponse(7L, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UsageSurveyResponse.class); + verify(responseRepository).save(captor.capture()); + assertThat(captor.getValue().getQuestionKey()) + .isEqualTo(UsageSurveyQuestion.DESIRED_FEATURE); + assertThat(captor.getValue().getOptionKey()).isEqualTo("STUDY_ROOM_LINK"); + assertThat(result.getRewardPoint()).isEqualTo(10); + assertThat(UsageSurveyService.class + .getMethod("submitResponse", Long.class, SubmitUsageSurveyResponseReq.class) + .getAnnotation(UpdateScore.class) + .addScore()).isEqualTo(10); + } + + @Test + void duplicateResponseIsRejectedBeforeReward() { + when(responseRepository.existsByUserUserIdAndQuestionKey( + 7L, + UsageSurveyQuestion.INSTALL_REASON + )).thenReturn(true); + + assertThatThrownBy(() -> service.submitResponse( + 7L, + new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.INSTALL_REASON, + "FACILITIES" + ) + )).isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ALREADY_ANSWERED_USAGE_SURVEY); + } + + @Test + void mismatchedOptionIsRejected() { + assertThatThrownBy(() -> service.submitResponse( + 7L, + new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.INSTALL_REASON, + "STUDY_ROOM_LINK" + ) + )).isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(INVALID_USAGE_SURVEY_OPTION); + } + + @Test + void laterDismissalIsStoredForExitMetrics() { + service.recordDismissal(7L, new RecordUsageSurveyDismissalReq( + UsageSurveyQuestion.DISAPPOINTMENT, + UsageSurveyDismissReason.LATER + )); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UsageSurveyDismissal.class); + verify(dismissalRepository).save(captor.capture()); + assertThat(captor.getValue().getQuestionKey()) + .isEqualTo(UsageSurveyQuestion.DISAPPOINTMENT); + assertThat(captor.getValue().getDismissReason()) + .isEqualTo(UsageSurveyDismissReason.LATER); + } +} From 4351ec811812f48527deaab80ae4255f7a875517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:28:43 +0900 Subject: [PATCH 44/54] feat: configure push event app variant --- .../CharacterUnlockedPushEventListener.java | 10 ++++++++-- .../listener/CrowdVacantPushEventListener.java | 10 ++++++++-- .../listener/ReportResolvedPushEventListener.java | 10 ++++++++-- .../notification/service/PushEventFlagService.java | 8 ++++++++ .../service/SurveyPushScheduleWorker.java | 11 ++++++++--- src/main/resources/application.yml | 1 + .../CharacterUnlockedPushEventListenerTest.java | 14 ++++++++++++++ 7 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java index bd49011c..3c597287 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -41,9 +41,10 @@ public void handle(CharacterUnlockedEvent event) { } try { + AppVariant targetAppVariant = targetAppVariant(); if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( event.userId(), - AppVariant.PRODUCTION + targetAppVariant )) { return; } @@ -52,7 +53,7 @@ public void handle(CharacterUnlockedEvent event) { pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, - AppVariant.PRODUCTION, + targetAppVariant, PushTargetType.USER, String.valueOf(event.userId()), content.title(), @@ -76,4 +77,9 @@ public void handle(CharacterUnlockedEvent event) { ); } } + + private AppVariant targetAppVariant() { + AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); + return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java index 5f7ae697..6a5f10a1 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -88,15 +88,16 @@ private void enqueueIfPushTargetExists( Long userId, PushContent content ) { + AppVariant targetAppVariant = targetAppVariant(); if (userId == null - || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { + || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, targetAppVariant)) { return; } pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, - AppVariant.PRODUCTION, + targetAppVariant, PushTargetType.USER, String.valueOf(userId), content.title(), @@ -107,4 +108,9 @@ private void enqueueIfPushTargetExists( SYSTEM_CREATED_BY )); } + + private AppVariant targetAppVariant() { + AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); + return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java index fff6f4b6..6149e822 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -41,9 +41,10 @@ public void handle(ReportResolvedEvent event) { } try { + AppVariant targetAppVariant = targetAppVariant(); if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( event.reporterUserId(), - AppVariant.PRODUCTION + targetAppVariant )) { return; } @@ -52,7 +53,7 @@ public void handle(ReportResolvedEvent event) { pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, - AppVariant.PRODUCTION, + targetAppVariant, PushTargetType.USER, String.valueOf(event.reporterUserId()), content.title(), @@ -76,4 +77,9 @@ public void handle(ReportResolvedEvent event) { ); } } + + private AppVariant targetAppVariant() { + AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); + return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java index 09a5793e..969e58f5 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.notification.service; import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import java.util.Arrays; import java.util.List; @@ -27,6 +28,13 @@ public class PushEventFlagService { @Value("${push.event.survey-enabled:false}") private boolean surveyDefaultEnabled; + @Value("${push.event.target-app-variant:PRODUCTION}") + private AppVariant targetAppVariant; + + public AppVariant getTargetAppVariant() { + return targetAppVariant; + } + public boolean isEnabled(PushEventType eventType) { String redisValue = getRedisValue(eventType); if ("true".equalsIgnoreCase(redisValue)) { diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java index c435cda2..c75569a2 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -134,7 +134,7 @@ private PushDispatchCommand command(SurveyPushSchedule schedule) { return new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, - AppVariant.PRODUCTION, + targetAppVariant(), targetType(schedule), targetValue(schedule), content.title(), @@ -174,10 +174,15 @@ private boolean hasActiveTarget(SurveyPushSchedule schedule) { return schedule.getTargetUserId() != null && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( schedule.getTargetUserId(), - AppVariant.PRODUCTION + targetAppVariant() ); } - return pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION); + return pushInstallationRepository.existsByAppVariantAndActiveTrue(targetAppVariant()); + } + + private AppVariant targetAppVariant() { + AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); + return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6eacf1f5..ec2f9bbd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -164,6 +164,7 @@ push: connect-timeout: 3s read-timeout: 10s event: + target-app-variant: ${PUSH_EVENT_TARGET_APP_VARIANT:PRODUCTION} crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java index f8a04e01..c8c565f4 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -63,6 +63,20 @@ void createsCharacterStoreDispatch() { assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44"); } + @Test + void usesConfiguredDevVariantForAutomaticDispatch() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushEventFlagService.getTargetAppVariant()).thenReturn(AppVariant.DEV); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.DEV)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().appVariant()).isEqualTo(AppVariant.DEV); + } + @Test void usesSafeBodyWhenCharacterNameIsBlank() { when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); From b11080ea7ec50e930d67ddd44b5e6f4459cdcae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:33:23 +0900 Subject: [PATCH 45/54] feat: send push events to dev and production --- .../CharacterUnlockedPushEventListener.java | 80 ++++++++++--------- .../CrowdVacantPushEventListener.java | 50 +++++++----- .../ReportResolvedPushEventListener.java | 80 ++++++++++--------- .../service/PushEventFlagService.java | 17 +++- .../service/SurveyPushScheduleWorker.java | 37 ++++++--- src/main/resources/application.yml | 2 +- ...haracterUnlockedPushEventListenerTest.java | 21 +++-- .../CrowdVacantPushEventListenerTest.java | 2 +- .../ReportResolvedPushEventListenerTest.java | 2 +- .../service/PushEventFlagServiceTest.java | 10 +++ .../service/SurveyPushScheduleWorkerTest.java | 2 +- 11 files changed, 186 insertions(+), 117 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java index 3c597287..db24ed9a 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -13,6 +13,7 @@ import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,46 +41,51 @@ public void handle(CharacterUnlockedEvent event) { return; } - try { - AppVariant targetAppVariant = targetAppVariant(); - if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( - event.userId(), - targetAppVariant - )) { - return; - } + for (AppVariant targetAppVariant : targetAppVariants()) { + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.userId(), + targetAppVariant + )) { + continue; + } - PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(event.userId()), - content.title(), - content.body(), - PushActionType.CHARACTER_STORE, - Map.of(), - "character-unlock:%d:%d:%d".formatted( - event.userId(), - event.characterId(), - event.userCharacterId() - ), - SYSTEM_CREATED_BY - )); - } catch (Exception e) { - log.warn( - "character unlock push failed: userId={}, characterId={}, userCharacterId={}, error={}", - event.userId(), - event.characterId(), - event.userCharacterId(), - e.getMessage() - ); + PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(event.userId()), + content.title(), + content.body(), + PushActionType.CHARACTER_STORE, + Map.of(), + "character-unlock:%d:%d:%d:%s".formatted( + event.userId(), + event.characterId(), + event.userCharacterId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "character unlock push failed: userId={}, characterId={}, userCharacterId={}, appVariant={}, error={}", + event.userId(), + event.characterId(), + event.userCharacterId(), + targetAppVariant, + e.getMessage() + ); + } } } - private AppVariant targetAppVariant() { - AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); - return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java index 6a5f10a1..80c08dc6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -18,6 +18,7 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.repository.PlaceRepository; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -88,29 +89,40 @@ private void enqueueIfPushTargetExists( Long userId, PushContent content ) { - AppVariant targetAppVariant = targetAppVariant(); - if (userId == null - || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, targetAppVariant)) { + if (userId == null) { return; } - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(userId), - content.title(), - content.body(), - PushActionType.PLACE_DETAIL, - Map.of("placeId", event.placeId()), - "crowd-vacant:%d:%d:%d".formatted(event.placeId(), userId, event.bleDataId()), - SYSTEM_CREATED_BY - )); + for (AppVariant targetAppVariant : targetAppVariants()) { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, targetAppVariant)) { + continue; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(userId), + content.title(), + content.body(), + PushActionType.PLACE_DETAIL, + Map.of("placeId", event.placeId()), + "crowd-vacant:%d:%d:%d:%s".formatted( + event.placeId(), + userId, + event.bleDataId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } } - private AppVariant targetAppVariant() { - AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); - return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java index 6149e822..1a58fd09 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -13,6 +13,7 @@ import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; import devkor.com.teamcback.domain.notification.template.PushContent; import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,46 +41,51 @@ public void handle(ReportResolvedEvent event) { return; } - try { - AppVariant targetAppVariant = targetAppVariant(); - if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( - event.reporterUserId(), - targetAppVariant - )) { - return; - } + for (AppVariant targetAppVariant : targetAppVariants()) { + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.reporterUserId(), + targetAppVariant + )) { + continue; + } - PushContent content = DomainPushContentFactory.reportResolved(); - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(event.reporterUserId()), - content.title(), - content.body(), - PushActionType.HOME, - Map.of(), - "report-result:%d:%s:%d".formatted( - event.reportId(), - event.finalStatus().name(), - event.reporterUserId() - ), - SYSTEM_CREATED_BY - )); - } catch (Exception e) { - log.warn( - "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, error={}", - event.reportId(), - event.reporterUserId(), - event.finalStatus(), - e.getMessage() - ); + PushContent content = DomainPushContentFactory.reportResolved(); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(event.reporterUserId()), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + "report-result:%d:%s:%d:%s".formatted( + event.reportId(), + event.finalStatus().name(), + event.reporterUserId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, appVariant={}, error={}", + event.reportId(), + event.reporterUserId(), + event.finalStatus(), + targetAppVariant, + e.getMessage() + ); + } } } - private AppVariant targetAppVariant() { - AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); - return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java index 969e58f5..1a7a77b2 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -5,6 +5,7 @@ import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import java.util.Arrays; import java.util.List; +import java.util.Locale; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; @@ -28,11 +29,19 @@ public class PushEventFlagService { @Value("${push.event.survey-enabled:false}") private boolean surveyDefaultEnabled; - @Value("${push.event.target-app-variant:PRODUCTION}") - private AppVariant targetAppVariant; + @Value("${push.event.target-app-variants:DEV,PRODUCTION}") + private String targetAppVariants; - public AppVariant getTargetAppVariant() { - return targetAppVariant; + public List getTargetAppVariants() { + List configuredVariants = Arrays.stream(targetAppVariants.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(value -> AppVariant.valueOf(value.toUpperCase(Locale.ROOT))) + .distinct() + .toList(); + return configuredVariants.isEmpty() + ? List.of(AppVariant.DEV, AppVariant.PRODUCTION) + : configuredVariants; } public boolean isEnabled(PushEventType eventType) { diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java index c75569a2..b5ec71b9 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -70,13 +70,14 @@ && cancelReminderByLatestPriority(schedule, now)) { return; } - if (!hasActiveTarget(schedule)) { + List activeTargetVariants = activeTargetVariants(schedule); + if (activeTargetVariants.isEmpty()) { schedule.skip(now); return; } try { - pushDispatchService.enqueue(command(schedule)); + activeTargetVariants.forEach(appVariant -> pushDispatchService.enqueue(command(schedule, appVariant))); schedule.complete(now); } catch (GlobalException e) { log.warn( @@ -128,20 +129,23 @@ private boolean cancelReminderByLatestPriority( .orElse(false); } - private PushDispatchCommand command(SurveyPushSchedule schedule) { + private PushDispatchCommand command( + SurveyPushSchedule schedule, + AppVariant appVariant + ) { PushContent content = content(schedule); return new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, - targetAppVariant(), + appVariant, targetType(schedule), targetValue(schedule), content.title(), content.body(), PushActionType.HOME, Map.of(), - schedule.getIdempotencyKey(), + "%s:%s".formatted(schedule.getIdempotencyKey(), appVariant.name().toLowerCase()), SYSTEM_CREATED_BY ); } @@ -169,20 +173,31 @@ private String targetValue(SurveyPushSchedule schedule) { return ALL_TARGET_VALUE; } - private boolean hasActiveTarget(SurveyPushSchedule schedule) { + private List activeTargetVariants(SurveyPushSchedule schedule) { + return targetAppVariants().stream() + .filter(appVariant -> hasActiveTarget(schedule, appVariant)) + .toList(); + } + + private boolean hasActiveTarget( + SurveyPushSchedule schedule, + AppVariant appVariant + ) { if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { return schedule.getTargetUserId() != null && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( schedule.getTargetUserId(), - targetAppVariant() + appVariant ); } - return pushInstallationRepository.existsByAppVariantAndActiveTrue(targetAppVariant()); + return pushInstallationRepository.existsByAppVariantAndActiveTrue(appVariant); } - private AppVariant targetAppVariant() { - AppVariant configuredVariant = pushEventFlagService.getTargetAppVariant(); - return configuredVariant == null ? AppVariant.PRODUCTION : configuredVariant; + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ec2f9bbd..809734b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -164,7 +164,7 @@ push: connect-timeout: 3s read-timeout: 10s event: - target-app-variant: ${PUSH_EVENT_TARGET_APP_VARIANT:PRODUCTION} + target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:DEV,PRODUCTION} crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java index c8c565f4..a04bd2e6 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -9,6 +9,7 @@ import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; import devkor.com.teamcback.domain.notification.service.PushDispatchService; import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -60,21 +61,31 @@ void createsCharacterStoreDispatch() { assertThat(command.actionParams()).isEmpty(); assertThat(command.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); assertThat(command.body()).isEqualTo("아기 호랑이을 만나러 가볼까요?"); - assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44"); + assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44:production"); } @Test - void usesConfiguredDevVariantForAutomaticDispatch() { + void createsSeparateDevAndProductionDispatches() { when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); - when(pushEventFlagService.getTargetAppVariant()).thenReturn(AppVariant.DEV); + when(pushEventFlagService.getTargetAppVariants()).thenReturn(List.of(AppVariant.DEV, AppVariant.PRODUCTION)); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.DEV)) .thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().appVariant()).isEqualTo(AppVariant.DEV); + verify(pushDispatchService, org.mockito.Mockito.times(2)).enqueue(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(PushDispatchCommand::appVariant) + .containsExactly(AppVariant.DEV, AppVariant.PRODUCTION); + assertThat(captor.getAllValues()) + .extracting(PushDispatchCommand::idempotencyKey) + .containsExactly( + "character-unlock:7:4:44:dev", + "character-unlock:7:4:44:production" + ); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java index cabb35be..8f0f3bf5 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -89,7 +89,7 @@ void createsUserDispatchesForDistinctFavoriteUsers() { assertThat(first.title()).isEqualTo("기다리던 자리가 생겼어요!"); assertThat(first.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); assertThat(first.body()).doesNotContain("null"); - assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99"); + assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99:production"); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java index 755c3046..fce0fc11 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -63,7 +63,7 @@ void createsReporterDispatch() { assertThat(command.title()).isEqualTo("신고 처리 결과를 확인해주세요."); assertThat(command.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); - assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7"); + assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7:production"); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java index 6b293256..ca2b7941 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.notification.service; import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -33,10 +34,19 @@ void setUp() { ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); + ReflectionTestUtils.setField(service, "targetAppVariants", "DEV,PRODUCTION"); when(redisTemplate.opsForValue()).thenReturn(valueOperations); } + @Test + void returnsConfiguredDevAndProductionTargetVariants() { + service.isEnabled(PushEventType.CROWD); + + assertThat(service.getTargetAppVariants()) + .containsExactly(AppVariant.DEV, AppVariant.PRODUCTION); + } + @Test void returnsYamlDefaultWhenRedisValueDoesNotExist() { when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java index 226c2391..b914c971 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java @@ -99,7 +99,7 @@ void dueStartedScheduleEnqueuesAllProductionActualGeneralAndCompletes() { assertThat(command.targetValue()).isEqualTo("ALL"); assertThat(command.actionType()).isEqualTo(PushActionType.HOME); assertThat(command.actionParams()).isEmpty(); - assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED"); + assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED:production"); assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); } From e7f084d9e7dd3d83b52b5b4cc9db41ce79312746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:50:54 +0900 Subject: [PATCH 46/54] feat: add active-user push broadcast console --- admin-panel/.gitignore | 1 + admin-panel/Dockerfile | 15 + admin-panel/package-lock.json | 865 ++++++++++++++++++ admin-panel/package.json | 13 + admin-panel/public/index.html | 506 ++++++++++ admin-panel/public/manifest.json | 9 + admin-panel/server.js | 144 +++ admin-panel/server.test.js | 90 ++ .../service/AdminNotificationService.java | 3 +- .../service/AdminNotificationServiceTest.java | 41 + 10 files changed, 1686 insertions(+), 1 deletion(-) create mode 100644 admin-panel/.gitignore create mode 100644 admin-panel/Dockerfile create mode 100644 admin-panel/package-lock.json create mode 100644 admin-panel/package.json create mode 100644 admin-panel/public/index.html create mode 100644 admin-panel/public/manifest.json create mode 100644 admin-panel/server.js create mode 100644 admin-panel/server.test.js diff --git a/admin-panel/.gitignore b/admin-panel/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/admin-panel/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/admin-panel/Dockerfile b/admin-panel/Dockerfile new file mode 100644 index 00000000..a455492c --- /dev/null +++ b/admin-panel/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +# docker-cli와 함께 'docker-cli-compose' 그리고 'bash'를 설치합니다. +RUN apk add --no-cache docker-cli docker-cli-compose bash + +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . + +# 권한 문제 방지 +USER root + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/admin-panel/package-lock.json b/admin-panel/package-lock.json new file mode 100644 index 00000000..9dc08a86 --- /dev/null +++ b/admin-panel/package-lock.json @@ -0,0 +1,865 @@ +{ + "name": "kodaero-admin-panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kodaero-admin-panel", + "version": "1.0.0", + "dependencies": { + "express": "^5.1.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/admin-panel/package.json b/admin-panel/package.json new file mode 100644 index 00000000..b18c6d65 --- /dev/null +++ b/admin-panel/package.json @@ -0,0 +1,13 @@ +{ + "name": "kodaero-admin-panel", + "private": true, + "version": "1.0.0", + "scripts": { + "start": "node server.js", + "check": "node --check server.js && node --check server.test.js", + "test": "node --test server.test.js" + }, + "dependencies": { + "express": "^5.1.0" + } +} diff --git a/admin-panel/public/index.html b/admin-panel/public/index.html new file mode 100644 index 00000000..fb707ba0 --- /dev/null +++ b/admin-panel/public/index.html @@ -0,0 +1,506 @@ + + + + + + + + Kodaero Operations + + + +
+
+
+

KODAERO / OPERATIONS

+

관리자 콘솔

+

알림 수신을 체크한 활성 기기에 공지 푸시를 발송합니다.

+
+ +
+ + + + +
+ + + + diff --git a/admin-panel/public/manifest.json b/admin-panel/public/manifest.json new file mode 100644 index 00000000..868c906d --- /dev/null +++ b/admin-panel/public/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "Kodaero Admin", + "short_name": "Kodaero", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2563eb", + "icons": [{ "src": "https://cdn-icons-png.flaticon.com/512/906/906341.png", "sizes": "512x512", "type": "image/png" }] +} diff --git a/admin-panel/server.js b/admin-panel/server.js new file mode 100644 index 00000000..015feb03 --- /dev/null +++ b/admin-panel/server.js @@ -0,0 +1,144 @@ +const express = require("express"); +const { execFile } = require("child_process"); +const path = require("path"); + +const createApp = ({ + backendBaseUrl = String(process.env.BACKEND_BASE_URL || "http://app-dev:8080").replace(/\/$/, ""), +} = {}) => { + const app = express(); + + app.use(express.json({ limit: "64kb" })); + +const authHeaders = (req) => { + const accessToken = req.get("AccessToken"); + const refreshToken = req.get("refreshToken"); + const headers = { "Content-Type": "application/json" }; + + if (accessToken) headers.AccessToken = accessToken; + if (refreshToken) headers.refreshToken = refreshToken; + return headers; +}; + +const readJson = async (response) => { + const text = await response.text(); + if (!text) return null; + + try { + return JSON.parse(text); + } catch { + return { statusCode: response.status, message: text }; + } +}; + +const forwardBackendResponse = async (upstream, res) => { + const renewedAccessToken = upstream.headers.get("AccessToken"); + if (renewedAccessToken) res.set("AccessToken", renewedAccessToken); + res.status(upstream.status).json(await readJson(upstream)); +}; + +const verifyAdmin = async (req, res, next) => { + if (!req.get("AccessToken")) { + return res.status(401).json({ message: "관리자 AccessToken이 필요합니다." }); + } + + try { + const upstream = await fetch(`${backendBaseUrl}/api/admin/notifications/event-flags`, { + headers: authHeaders(req), + }); + + if (!upstream.ok) { + return forwardBackendResponse(upstream, res); + } + + const renewedAccessToken = upstream.headers.get("AccessToken"); + if (renewedAccessToken) res.set("AccessToken", renewedAccessToken); + next(); + } catch (error) { + res.status(502).json({ message: `관리자 인증 서버 연결 실패: ${error.message}` }); + } +}; + +const proxyNotificationRequest = async (req, res, backendPath, method = req.method) => { + try { + const headers = authHeaders(req); + const idempotencyKey = req.get("Idempotency-Key"); + if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; + + const upstream = await fetch(`${backendBaseUrl}${backendPath}`, { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(req.body || {}), + }); + await forwardBackendResponse(upstream, res); + } catch (error) { + res.status(502).json({ message: `푸시 서버 연결 실패: ${error.message}` }); + } +}; + +app.get("/api/session", verifyAdmin, (req, res) => { + res.json({ authenticated: true }); +}); + +app.post("/api/notifications/preview", verifyAdmin, (req, res) => + proxyNotificationRequest(req, res, "/api/admin/notifications/dispatches/preview", "POST"), +); + +app.post("/api/notifications/dispatches", verifyAdmin, (req, res) => + proxyNotificationRequest(req, res, "/api/admin/notifications/dispatches", "POST"), +); + +app.get("/api/notifications/dispatches", verifyAdmin, (req, res) => { + const query = new URLSearchParams({ + page: String(req.query.page || 1), + size: String(req.query.size || 20), + }); + proxyNotificationRequest(req, res, `/api/admin/notifications/dispatches?${query}`, "GET"); +}); + +app.get("/api/status", verifyAdmin, (req, res) => { + execFile("docker", ["ps", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"], (error, stdout) => { + if (error) return res.status(500).json({ message: error.message }); + + const containers = stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [name, status, image] = line.split("\t"); + return { name, status, image }; + }); + res.json({ containers }); + }); +}); + +app.post("/api/deploy", verifyAdmin, (req, res) => { + const { version, type } = req.body || {}; + if (!new Set(["dev", "prod"]).has(type)) { + return res.status(400).json({ message: "지원하지 않는 배포 유형입니다." }); + } + if (type === "prod" && !/^\d+$/.test(String(version || ""))) { + return res.status(400).json({ message: "운영 배포 버전은 숫자여야 합니다." }); + } + + const scriptPath = type === "prod" ? "/scripts/deploy-prod.sh" : "/scripts/deploy-dev.sh"; + const args = type === "prod" ? [scriptPath, String(version)] : [scriptPath]; + execFile("bash", args, { cwd: "/scripts", maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + res.status(error ? 500 : 200).json({ + success: !error, + output: `${stdout}${stderr || ""}`, + }); + }); +}); + + app.use(express.static(path.join(__dirname, "public"))); + app.use((req, res) => res.sendFile(path.join(__dirname, "public", "index.html"))); + + return app; +}; + +if (require.main === module) { + const port = Number(process.env.PORT || 3000); + createApp().listen(port, () => console.log(`Kodaero Admin Server ready on port ${port}`)); +} + +module.exports = { createApp }; diff --git a/admin-panel/server.test.js b/admin-panel/server.test.js new file mode 100644 index 00000000..be8369f0 --- /dev/null +++ b/admin-panel/server.test.js @@ -0,0 +1,90 @@ +const assert = require("node:assert/strict"); +const http = require("node:http"); +const test = require("node:test"); + +const { createApp } = require("./server"); + +const listen = (server) => new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const close = (server) => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +const urlFor = (server) => `http://127.0.0.1:${server.address().port}`; + +test("admin session requires an AccessToken and validates it upstream", async (context) => { + const upstreamRequests = []; + const upstream = http.createServer((req, res) => { + upstreamRequests.push({ method: req.method, url: req.url, accessToken: req.headers.accesstoken }); + res.setHeader("Content-Type", "application/json"); + res.setHeader("AccessToken", "renewed-token"); + res.end(JSON.stringify({ statusCode: 200, data: [] })); + }); + await listen(upstream); + context.after(() => close(upstream)); + + const admin = createApp({ backendBaseUrl: urlFor(upstream) }).listen(0, "127.0.0.1"); + await new Promise((resolve) => admin.once("listening", resolve)); + context.after(() => close(admin)); + + const unauthenticated = await fetch(`${urlFor(admin)}/api/session`); + assert.equal(unauthenticated.status, 401); + + const authenticated = await fetch(`${urlFor(admin)}/api/session`, { + headers: { AccessToken: "admin-token" }, + }); + assert.equal(authenticated.status, 200); + assert.equal(authenticated.headers.get("AccessToken"), "renewed-token"); + assert.deepEqual(await authenticated.json(), { authenticated: true }); + assert.deepEqual(upstreamRequests, [{ + method: "GET", + url: "/api/admin/notifications/event-flags", + accessToken: "admin-token", + }]); +}); + +test("notification preview is proxied only after admin validation", async (context) => { + const upstreamRequests = []; + const upstream = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + upstreamRequests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null }); + res.setHeader("Content-Type", "application/json"); + if (req.url.endsWith("/preview")) { + res.end(JSON.stringify({ statusCode: 200, data: { recipientCount: 2 } })); + return; + } + res.end(JSON.stringify({ statusCode: 200, data: [] })); + }); + }); + await listen(upstream); + context.after(() => close(upstream)); + + const admin = createApp({ backendBaseUrl: urlFor(upstream) }).listen(0, "127.0.0.1"); + await new Promise((resolve) => admin.once("listening", resolve)); + context.after(() => close(admin)); + + const requestBody = { + mode: "ACTUAL", + appVariant: "DEV", + targetType: "ALL", + targetValue: "ALL", + title: "공지", + body: "내용", + actionType: "HOME", + actionParams: {}, + confirm: true, + }; + const response = await fetch(`${urlFor(admin)}/api/notifications/preview`, { + method: "POST", + headers: { AccessToken: "admin-token", "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + assert.equal(response.status, 200); + assert.equal((await response.json()).data.recipientCount, 2); + assert.equal(upstreamRequests.length, 2); + assert.equal(upstreamRequests[0].url, "/api/admin/notifications/event-flags"); + assert.deepEqual(upstreamRequests[1], { + method: "POST", + url: "/api/admin/notifications/dispatches/preview", + body: requestBody, + }); +}); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java index 86b7ff98..f1cebb60 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java @@ -171,7 +171,8 @@ private void validateTargetRules(AdminPushDispatchReq request) { if (PushMode.ACTUAL.equals(request.mode()) && !PushTargetType.INSTALLATION.equals(request.targetType()) - && !PushTargetType.USER.equals(request.targetType())) { + && !PushTargetType.USER.equals(request.targetType()) + && !PushTargetType.ALL.equals(request.targetType())) { throw new GlobalException(UNSUPPORTED_REQUEST); } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java index 607f5710..a3b54d33 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java @@ -177,6 +177,47 @@ void enqueueDelegatesToPushDispatchService() { assertThat(command.createdBy()).isEqualTo(7L); } + @Test + void actualAllTargetsOnlyActiveInstallationsResolvedForTheVariant() { + PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.ALL); + PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); + when(pushTargetResolver.resolve(PushTargetType.ALL, "ALL", AppVariant.DEV)) + .thenReturn(List.of(installation(AppVariant.DEV))); + when(pushPayloadFactory.createForPreDispatchValidation( + "title", + "body", + PushMode.ACTUAL, + AppVariant.DEV, + PushActionType.HOME, + Map.of() + )).thenReturn(payload()); + when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) + .thenReturn(enqueueResponse); + + AdminPushDispatchReq request = new AdminPushDispatchReq( + PushMode.ACTUAL, + AppVariant.DEV, + PushTargetType.ALL, + "ALL", + "title", + "body", + PushActionType.HOME, + Map.of(), + false + ); + + AdminPushDispatchPreviewRes preview = service(false).preview(request); + PushDispatchEnqueueRes response = service(false).enqueue(7L, "broadcast-dev-1", request); + + assertThat(preview.recipientCount()).isEqualTo(1); + assertThat(response).isSameAs(enqueueResponse); + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.ALL); + assertThat(captor.getValue().targetValue()).isEqualTo("ALL"); + assertThat(captor.getValue().appVariant()).isEqualTo(AppVariant.DEV); + } + @Test void getDispatchCountsAllMessageStatuses() { PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.USER); From ab0c34ed644f390ab81a201c00e3cbc7fffe5aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:52:31 +0900 Subject: [PATCH 47/54] feat: support Kakao web admin audience --- .../domain/user/service/UserService.java | 4 +++- .../domain/user/validator/KakaoValidator.java | 12 +++++++++++- src/main/resources/application.yml | 1 + .../user/service/UserServiceAdminLoginTest.java | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 6e245f35..20c7aea5 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -139,7 +139,9 @@ public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { throw new GlobalException(INVALID_INPUT); } - String email = validateToken(provider, adminLoginReq.getToken()); + String email = provider == Provider.KAKAO + ? kakaoValidator.validateAdminToken(adminLoginReq.getToken()) + : googleValidator.validateToken(adminLoginReq.getToken()); User user = userRepository.findByEmailAndProvider(email, provider); if(user == null || user.getRole() != Role.ADMIN) { throw new GlobalException(FORBIDDEN); diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java index 9415ebb0..5de8f149 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java @@ -25,15 +25,25 @@ public class KakaoValidator{ private String ISS; @Value("${jwt.social.kakao.aud}") private String AUD; + @Value("${jwt.social.kakao.admin-aud:}") + private String ADMIN_AUD; public OIDCPublicKeysResponse getCachedData() { return kakaoClient.getPublicKeys(); } public String validateToken(String token) { + return validateToken(token, AUD); + } + + public String validateAdminToken(String token) { + return validateToken(token, ADMIN_AUD.isBlank() ? AUD : ADMIN_AUD); + } + + private String validateToken(String token, String audience) { try { // 카카오 id_token 정보 - String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, AUD, ISS); + String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, audience, ISS); // 공개키 가져오기 OIDCPublicKeysResponse publicKeysResponse = getCachedData(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 809734b6..963d252a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,7 @@ jwt: kakao: iss: ${KAKAO_ISS} aud: ${KAKAO_AUD} + admin-aud: ${KAKAO_ADMIN_AUD:} google: iss: ${GOOGLE_ISS} aud: ${GOOGLE_AUD} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java index d36467c2..f61f5538 100644 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -79,6 +79,21 @@ void issuesTokensOnlyForExistingAdmin() { assertEquals("admin@example.com", result.getEmail()); } + @Test + void usesDedicatedKakaoAdminAudience() { + ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); + ReflectionTestUtils.setField(request, "token", "kakao-admin-id-token"); + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.KAKAO); + ReflectionTestUtils.setField(admin, "userId", 8L); + when(kakaoValidator.validateAdminToken("kakao-admin-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.KAKAO)).thenReturn(admin); + + userService.adminLogin(request); + + verify(kakaoValidator).validateAdminToken("kakao-admin-id-token"); + verify(kakaoValidator, never()).validateToken("kakao-admin-id-token"); + } + @Test void rejectsNonAdminWithoutCreatingUser() { User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); From 528c4c135d97a60b154aba92530090084133c14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:57:39 +0900 Subject: [PATCH 48/54] fix: show zero recipients in push preview --- .../resolver/PushTargetResolver.java | 19 +++++++++++++- .../service/AdminNotificationService.java | 2 +- .../resolver/PushTargetResolverTest.java | 25 +++++++++++++++++++ .../service/AdminNotificationServiceTest.java | 4 +-- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java index 0f8d3694..60fe13b1 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java @@ -24,6 +24,23 @@ public List resolve( PushTargetType targetType, String targetValue, AppVariant appVariant + ) { + return resolve(targetType, targetValue, appVariant, false); + } + + public List resolveForPreview( + PushTargetType targetType, + String targetValue, + AppVariant appVariant + ) { + return resolve(targetType, targetValue, appVariant, true); + } + + private List resolve( + PushTargetType targetType, + String targetValue, + AppVariant appVariant, + boolean allowEmpty ) { if (targetType == null || targetValue == null || targetValue.isBlank() || appVariant == null) { throw new GlobalException(INVALID_INPUT); @@ -38,7 +55,7 @@ public List resolve( List distinctInstallations = distinctByInstallation(installations); - if (distinctInstallations.isEmpty()) { + if (distinctInstallations.isEmpty() && !allowEmpty) { throw new GlobalException(INVALID_INPUT); } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java index f1cebb60..c529ee4b 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java @@ -77,7 +77,7 @@ public List searchInstallations( public AdminPushDispatchPreviewRes preview(AdminPushDispatchReq request) { validateTargetRules(request); - List installations = pushTargetResolver.resolve( + List installations = pushTargetResolver.resolveForPreview( request.targetType(), request.targetValue(), request.appVariant() diff --git a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java index 1978fc94..3e48a5d6 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java @@ -50,4 +50,29 @@ void allTargetRejectsNonAllTargetValue() { .extracting("resultCode") .isEqualTo(ResultCode.INVALID_INPUT); } + + @Test + void previewAllTargetAllowsZeroActiveInstallations() { + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of()); + + List resolved = resolver.resolveForPreview( + PushTargetType.ALL, + "ALL", + AppVariant.PRODUCTION + ); + + assertThat(resolved).isEmpty(); + } + + @Test + void actualAllTargetStillRejectsZeroActiveInstallations() { + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java index a3b54d33..214fce10 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java @@ -83,7 +83,7 @@ void previewResolvesTargetAndCreatesPayloadWithoutSavingDispatchOrMessages() { PushInstallation installation = installation(AppVariant.DEV); PushPayload payload = payload(); - when(pushTargetResolver.resolve(PushTargetType.INSTALLATION, "install-1", AppVariant.DEV)) + when(pushTargetResolver.resolveForPreview(PushTargetType.INSTALLATION, "install-1", AppVariant.DEV)) .thenReturn(List.of(installation)); when(pushPayloadFactory.createForPreDispatchValidation( "title", @@ -181,7 +181,7 @@ void enqueueDelegatesToPushDispatchService() { void actualAllTargetsOnlyActiveInstallationsResolvedForTheVariant() { PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.ALL); PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); - when(pushTargetResolver.resolve(PushTargetType.ALL, "ALL", AppVariant.DEV)) + when(pushTargetResolver.resolveForPreview(PushTargetType.ALL, "ALL", AppVariant.DEV)) .thenReturn(List.of(installation(AppVariant.DEV))); when(pushPayloadFactory.createForPreDispatchValidation( "title", From fd83d59a1a6835699e4c9f0f533bcc9980286f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 05:08:29 +0900 Subject: [PATCH 49/54] chore: remove duplicate push admin panel --- admin-panel/.gitignore | 1 - admin-panel/Dockerfile | 15 - admin-panel/package-lock.json | 865 ------------------------------- admin-panel/package.json | 13 - admin-panel/public/index.html | 506 ------------------ admin-panel/public/manifest.json | 9 - admin-panel/server.js | 144 ----- admin-panel/server.test.js | 90 ---- 8 files changed, 1643 deletions(-) delete mode 100644 admin-panel/.gitignore delete mode 100644 admin-panel/Dockerfile delete mode 100644 admin-panel/package-lock.json delete mode 100644 admin-panel/package.json delete mode 100644 admin-panel/public/index.html delete mode 100644 admin-panel/public/manifest.json delete mode 100644 admin-panel/server.js delete mode 100644 admin-panel/server.test.js diff --git a/admin-panel/.gitignore b/admin-panel/.gitignore deleted file mode 100644 index c2658d7d..00000000 --- a/admin-panel/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/admin-panel/Dockerfile b/admin-panel/Dockerfile deleted file mode 100644 index a455492c..00000000 --- a/admin-panel/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM node:20-alpine - -# docker-cli와 함께 'docker-cli-compose' 그리고 'bash'를 설치합니다. -RUN apk add --no-cache docker-cli docker-cli-compose bash - -WORKDIR /app -COPY package*.json ./ -RUN npm install --omit=dev -COPY . . - -# 권한 문제 방지 -USER root - -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/admin-panel/package-lock.json b/admin-panel/package-lock.json deleted file mode 100644 index 9dc08a86..00000000 --- a/admin-panel/package-lock.json +++ /dev/null @@ -1,865 +0,0 @@ -{ - "name": "kodaero-admin-panel", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "kodaero-admin-panel", - "version": "1.0.0", - "dependencies": { - "express": "^5.1.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - } - } -} diff --git a/admin-panel/package.json b/admin-panel/package.json deleted file mode 100644 index b18c6d65..00000000 --- a/admin-panel/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "kodaero-admin-panel", - "private": true, - "version": "1.0.0", - "scripts": { - "start": "node server.js", - "check": "node --check server.js && node --check server.test.js", - "test": "node --test server.test.js" - }, - "dependencies": { - "express": "^5.1.0" - } -} diff --git a/admin-panel/public/index.html b/admin-panel/public/index.html deleted file mode 100644 index fb707ba0..00000000 --- a/admin-panel/public/index.html +++ /dev/null @@ -1,506 +0,0 @@ - - - - - - - - Kodaero Operations - - - -
-
-
-

KODAERO / OPERATIONS

-

관리자 콘솔

-

알림 수신을 체크한 활성 기기에 공지 푸시를 발송합니다.

-
- -
- - - - -
- - - - diff --git a/admin-panel/public/manifest.json b/admin-panel/public/manifest.json deleted file mode 100644 index 868c906d..00000000 --- a/admin-panel/public/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "Kodaero Admin", - "short_name": "Kodaero", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#2563eb", - "icons": [{ "src": "https://cdn-icons-png.flaticon.com/512/906/906341.png", "sizes": "512x512", "type": "image/png" }] -} diff --git a/admin-panel/server.js b/admin-panel/server.js deleted file mode 100644 index 015feb03..00000000 --- a/admin-panel/server.js +++ /dev/null @@ -1,144 +0,0 @@ -const express = require("express"); -const { execFile } = require("child_process"); -const path = require("path"); - -const createApp = ({ - backendBaseUrl = String(process.env.BACKEND_BASE_URL || "http://app-dev:8080").replace(/\/$/, ""), -} = {}) => { - const app = express(); - - app.use(express.json({ limit: "64kb" })); - -const authHeaders = (req) => { - const accessToken = req.get("AccessToken"); - const refreshToken = req.get("refreshToken"); - const headers = { "Content-Type": "application/json" }; - - if (accessToken) headers.AccessToken = accessToken; - if (refreshToken) headers.refreshToken = refreshToken; - return headers; -}; - -const readJson = async (response) => { - const text = await response.text(); - if (!text) return null; - - try { - return JSON.parse(text); - } catch { - return { statusCode: response.status, message: text }; - } -}; - -const forwardBackendResponse = async (upstream, res) => { - const renewedAccessToken = upstream.headers.get("AccessToken"); - if (renewedAccessToken) res.set("AccessToken", renewedAccessToken); - res.status(upstream.status).json(await readJson(upstream)); -}; - -const verifyAdmin = async (req, res, next) => { - if (!req.get("AccessToken")) { - return res.status(401).json({ message: "관리자 AccessToken이 필요합니다." }); - } - - try { - const upstream = await fetch(`${backendBaseUrl}/api/admin/notifications/event-flags`, { - headers: authHeaders(req), - }); - - if (!upstream.ok) { - return forwardBackendResponse(upstream, res); - } - - const renewedAccessToken = upstream.headers.get("AccessToken"); - if (renewedAccessToken) res.set("AccessToken", renewedAccessToken); - next(); - } catch (error) { - res.status(502).json({ message: `관리자 인증 서버 연결 실패: ${error.message}` }); - } -}; - -const proxyNotificationRequest = async (req, res, backendPath, method = req.method) => { - try { - const headers = authHeaders(req); - const idempotencyKey = req.get("Idempotency-Key"); - if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; - - const upstream = await fetch(`${backendBaseUrl}${backendPath}`, { - method, - headers, - body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(req.body || {}), - }); - await forwardBackendResponse(upstream, res); - } catch (error) { - res.status(502).json({ message: `푸시 서버 연결 실패: ${error.message}` }); - } -}; - -app.get("/api/session", verifyAdmin, (req, res) => { - res.json({ authenticated: true }); -}); - -app.post("/api/notifications/preview", verifyAdmin, (req, res) => - proxyNotificationRequest(req, res, "/api/admin/notifications/dispatches/preview", "POST"), -); - -app.post("/api/notifications/dispatches", verifyAdmin, (req, res) => - proxyNotificationRequest(req, res, "/api/admin/notifications/dispatches", "POST"), -); - -app.get("/api/notifications/dispatches", verifyAdmin, (req, res) => { - const query = new URLSearchParams({ - page: String(req.query.page || 1), - size: String(req.query.size || 20), - }); - proxyNotificationRequest(req, res, `/api/admin/notifications/dispatches?${query}`, "GET"); -}); - -app.get("/api/status", verifyAdmin, (req, res) => { - execFile("docker", ["ps", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"], (error, stdout) => { - if (error) return res.status(500).json({ message: error.message }); - - const containers = stdout - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - const [name, status, image] = line.split("\t"); - return { name, status, image }; - }); - res.json({ containers }); - }); -}); - -app.post("/api/deploy", verifyAdmin, (req, res) => { - const { version, type } = req.body || {}; - if (!new Set(["dev", "prod"]).has(type)) { - return res.status(400).json({ message: "지원하지 않는 배포 유형입니다." }); - } - if (type === "prod" && !/^\d+$/.test(String(version || ""))) { - return res.status(400).json({ message: "운영 배포 버전은 숫자여야 합니다." }); - } - - const scriptPath = type === "prod" ? "/scripts/deploy-prod.sh" : "/scripts/deploy-dev.sh"; - const args = type === "prod" ? [scriptPath, String(version)] : [scriptPath]; - execFile("bash", args, { cwd: "/scripts", maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { - res.status(error ? 500 : 200).json({ - success: !error, - output: `${stdout}${stderr || ""}`, - }); - }); -}); - - app.use(express.static(path.join(__dirname, "public"))); - app.use((req, res) => res.sendFile(path.join(__dirname, "public", "index.html"))); - - return app; -}; - -if (require.main === module) { - const port = Number(process.env.PORT || 3000); - createApp().listen(port, () => console.log(`Kodaero Admin Server ready on port ${port}`)); -} - -module.exports = { createApp }; diff --git a/admin-panel/server.test.js b/admin-panel/server.test.js deleted file mode 100644 index be8369f0..00000000 --- a/admin-panel/server.test.js +++ /dev/null @@ -1,90 +0,0 @@ -const assert = require("node:assert/strict"); -const http = require("node:http"); -const test = require("node:test"); - -const { createApp } = require("./server"); - -const listen = (server) => new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); -const close = (server) => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); -const urlFor = (server) => `http://127.0.0.1:${server.address().port}`; - -test("admin session requires an AccessToken and validates it upstream", async (context) => { - const upstreamRequests = []; - const upstream = http.createServer((req, res) => { - upstreamRequests.push({ method: req.method, url: req.url, accessToken: req.headers.accesstoken }); - res.setHeader("Content-Type", "application/json"); - res.setHeader("AccessToken", "renewed-token"); - res.end(JSON.stringify({ statusCode: 200, data: [] })); - }); - await listen(upstream); - context.after(() => close(upstream)); - - const admin = createApp({ backendBaseUrl: urlFor(upstream) }).listen(0, "127.0.0.1"); - await new Promise((resolve) => admin.once("listening", resolve)); - context.after(() => close(admin)); - - const unauthenticated = await fetch(`${urlFor(admin)}/api/session`); - assert.equal(unauthenticated.status, 401); - - const authenticated = await fetch(`${urlFor(admin)}/api/session`, { - headers: { AccessToken: "admin-token" }, - }); - assert.equal(authenticated.status, 200); - assert.equal(authenticated.headers.get("AccessToken"), "renewed-token"); - assert.deepEqual(await authenticated.json(), { authenticated: true }); - assert.deepEqual(upstreamRequests, [{ - method: "GET", - url: "/api/admin/notifications/event-flags", - accessToken: "admin-token", - }]); -}); - -test("notification preview is proxied only after admin validation", async (context) => { - const upstreamRequests = []; - const upstream = http.createServer((req, res) => { - let body = ""; - req.on("data", (chunk) => { body += chunk; }); - req.on("end", () => { - upstreamRequests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null }); - res.setHeader("Content-Type", "application/json"); - if (req.url.endsWith("/preview")) { - res.end(JSON.stringify({ statusCode: 200, data: { recipientCount: 2 } })); - return; - } - res.end(JSON.stringify({ statusCode: 200, data: [] })); - }); - }); - await listen(upstream); - context.after(() => close(upstream)); - - const admin = createApp({ backendBaseUrl: urlFor(upstream) }).listen(0, "127.0.0.1"); - await new Promise((resolve) => admin.once("listening", resolve)); - context.after(() => close(admin)); - - const requestBody = { - mode: "ACTUAL", - appVariant: "DEV", - targetType: "ALL", - targetValue: "ALL", - title: "공지", - body: "내용", - actionType: "HOME", - actionParams: {}, - confirm: true, - }; - const response = await fetch(`${urlFor(admin)}/api/notifications/preview`, { - method: "POST", - headers: { AccessToken: "admin-token", "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - - assert.equal(response.status, 200); - assert.equal((await response.json()).data.recipientCount, 2); - assert.equal(upstreamRequests.length, 2); - assert.equal(upstreamRequests[0].url, "/api/admin/notifications/event-flags"); - assert.deepEqual(upstreamRequests[1], { - method: "POST", - url: "/api/admin/notifications/dispatches/preview", - body: requestBody, - }); -}); From 83aaa8375d4210acb8216f93720bb132dba75ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 06:18:18 +0900 Subject: [PATCH 50/54] fix: derive mobile login identity from verified token --- .../user/dto/response/LoginUserRes.java | 1 + .../domain/user/service/UserService.java | 7 +- .../service/UserServiceReleaseLoginTest.java | 79 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java index 5b648f3e..4ec6835e 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java @@ -9,4 +9,5 @@ public class LoginUserRes { String accessToken; String refreshToken; String code; + String loginKey; } diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 20c7aea5..c7f4c85d 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -116,7 +116,12 @@ public LoginUserRes releaseLogin(LoginUserReq loginUserReq) { String rawCode = UUID.randomUUID().toString(); user.setCode(passwordEncoder.encode(rawCode)); - return new LoginUserRes(jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), rawCode); + return new LoginUserRes( + jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), + jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), + rawCode, + email + ); } private String validateToken(Provider provider, String token) { diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java new file mode 100644 index 00000000..cd3c2b71 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.LoginUserRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceReleaseLoginTest { + @InjectMocks + UserService userService; + + @Mock UserRepository userRepository; + @Mock CategoryRepository categoryRepository; + @Mock BookmarkRepository bookmarkRepository; + @Mock UserBookmarkLogRepository userBookmarkLogRepository; + @Mock SuggestionRepository suggestionRepository; + @Mock UserCharacterRepository userCharacterRepository; + @Mock JwtUtil jwtUtil; + @Mock KakaoValidator kakaoValidator; + @Mock GoogleValidator googleValidator; + @Mock AppleValidator appleValidator; + @Mock PasswordEncoder passwordEncoder; + @Mock PushInstallationService pushInstallationService; + + LoginUserReq request; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(userService, "adminToken", "admin-token"); + request = new LoginUserReq(); + ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); + ReflectionTestUtils.setField(request, "email", "untrusted-client@example.com"); + ReflectionTestUtils.setField(request, "token", "kakao-id-token"); + } + + @Test + void returnsLoginKeyExtractedFromVerifiedToken() { + User user = new User("member", "verified@example.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 3L); + when(kakaoValidator.validateToken("kakao-id-token")).thenReturn("verified@example.com"); + when(userRepository.findByEmailAndProvider("verified@example.com", Provider.KAKAO)).thenReturn(user); + when(passwordEncoder.encode(anyString())).thenReturn("encoded-code"); + when(jwtUtil.createAccessToken("3", "ROLE_USER")).thenReturn("access"); + when(jwtUtil.createRefreshToken("3", "ROLE_USER")).thenReturn("refresh"); + + LoginUserRes result = userService.releaseLogin(request); + + assertEquals("verified@example.com", result.getLoginKey()); + assertEquals("access", result.getAccessToken()); + assertEquals("refresh", result.getRefreshToken()); + verify(userRepository).findByEmailAndProvider("verified@example.com", Provider.KAKAO); + } +} From d1f948a95212f524b2e540ad37799d83b4d72590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 06:20:07 +0900 Subject: [PATCH 51/54] fix: route push events to isolated app variants --- .../notification/dto/payload/PushPayload.java | 1 + .../factory/PushPayloadFactory.java | 1 + .../service/PushEventFlagService.java | 4 +-- .../domain/user/validator/AppleValidator.java | 12 ++++++- src/main/resources/application.yml | 4 ++- .../factory/PushPayloadFactoryTest.java | 36 +++++++++++++++++++ .../service/AdminNotificationServiceTest.java | 1 + .../service/PushEventFlagServiceTest.java | 6 ++-- 8 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java index deeac8a8..2f032793 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java @@ -11,6 +11,7 @@ public record PushPayload( public record PushPayloadData( int version, String notificationId, + String appVariant, PushPayloadAction action ) { } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java index fb0d7e0c..4f93898f 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java @@ -52,6 +52,7 @@ public PushPayload create( new PushPayload.PushPayloadData( PAYLOAD_VERSION, notificationId, + appVariant.toValue(), new PushPayload.PushPayloadAction( actionType.name(), normalizedActionParams diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java index 1a7a77b2..91f8cbb5 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -29,7 +29,7 @@ public class PushEventFlagService { @Value("${push.event.survey-enabled:false}") private boolean surveyDefaultEnabled; - @Value("${push.event.target-app-variants:DEV,PRODUCTION}") + @Value("${push.event.target-app-variants:PRODUCTION}") private String targetAppVariants; public List getTargetAppVariants() { @@ -40,7 +40,7 @@ public List getTargetAppVariants() { .distinct() .toList(); return configuredVariants.isEmpty() - ? List.of(AppVariant.DEV, AppVariant.PRODUCTION) + ? List.of(AppVariant.PRODUCTION) : configuredVariants; } diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java index 9c50b7bf..b5dcf1d9 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java @@ -28,6 +28,8 @@ public class AppleValidator { private String ISS; @Value("${jwt.social.apple.dev-aud}") private String DEV_AUD; + @Value("${jwt.social.apple.preview-aud:}") + private String PREVIEW_AUD; @Value("${jwt.social.apple.aud}") private String AUD; @@ -38,7 +40,15 @@ public OIDCPublicKeysResponse getCachedData() { public String validateToken(String token) { try { // id_token 정보 - Header tokenInfo = oidcUtil.getUnsignedTokenClaims(token, new String[] {DEV_AUD, AUD}, ISS).getHeader(); + Header tokenInfo = oidcUtil.getUnsignedTokenClaims( + token, + new String[] { + DEV_AUD, + PREVIEW_AUD.isBlank() ? DEV_AUD : PREVIEW_AUD, + AUD + }, + ISS + ).getHeader(); String kid = (String) tokenInfo.get(KID); String alg = (String) tokenInfo.get(ALG); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 963d252a..16406861 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,6 +83,7 @@ jwt: iss: ${APPLE_ISS} aud: ${APPLE_AUD} dev-aud: ${APPLE_DEV_AUD} + preview-aud: ${APPLE_PREVIEW_AUD:} admin: token: ${JWT_ADMIN_TOKEN} @@ -165,7 +166,8 @@ push: connect-timeout: 3s read-timeout: 10s event: - target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:DEV,PRODUCTION} + # Dev server sets PREVIEW; production safely defaults to PRODUCTION. + target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:PRODUCTION} crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java new file mode 100644 index 00000000..ce5248cb --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java @@ -0,0 +1,36 @@ +package devkor.com.teamcback.domain.notification.factory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.validation.PushActionValidator; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PushPayloadFactoryTest { + + private final PushPayloadFactory factory = new PushPayloadFactory( + new PushActionValidator(), + new ObjectMapper() + ); + + @Test + void includesTargetAppVariantInDeepLinkPayload() { + PushPayload payload = factory.create( + "notification-1", + "title", + "body", + PushMode.ACTUAL, + AppVariant.PREVIEW, + PushActionType.HOME, + Map.of() + ); + + assertThat(payload.data().appVariant()).isEqualTo("preview"); + assertThat(payload.data().action().type()).isEqualTo("HOME"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java index 214fce10..22f47b77 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java @@ -305,6 +305,7 @@ private PushPayload payload() { new PushPayload.PushPayloadData( 1, "00000000-0000-4000-8000-000000000000", + "dev", new PushPayload.PushPayloadAction(PushActionType.TEST.name(), Map.of()) ) ); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java index ca2b7941..efca762d 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -34,17 +34,17 @@ void setUp() { ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); - ReflectionTestUtils.setField(service, "targetAppVariants", "DEV,PRODUCTION"); + ReflectionTestUtils.setField(service, "targetAppVariants", "PREVIEW"); when(redisTemplate.opsForValue()).thenReturn(valueOperations); } @Test - void returnsConfiguredDevAndProductionTargetVariants() { + void returnsConfiguredPreviewTargetVariant() { service.isEnabled(PushEventType.CROWD); assertThat(service.getTargetAppVariants()) - .containsExactly(AppVariant.DEV, AppVariant.PRODUCTION); + .containsExactly(AppVariant.PREVIEW); } @Test From 9e5811091d3a20bbcae2936fb768624b9b405d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 06:25:00 +0900 Subject: [PATCH 52/54] chore: defer login callback audience changes --- .../domain/user/validator/AppleValidator.java | 12 +----------- src/main/resources/application.yml | 1 - 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java index b5dcf1d9..9c50b7bf 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/AppleValidator.java @@ -28,8 +28,6 @@ public class AppleValidator { private String ISS; @Value("${jwt.social.apple.dev-aud}") private String DEV_AUD; - @Value("${jwt.social.apple.preview-aud:}") - private String PREVIEW_AUD; @Value("${jwt.social.apple.aud}") private String AUD; @@ -40,15 +38,7 @@ public OIDCPublicKeysResponse getCachedData() { public String validateToken(String token) { try { // id_token 정보 - Header tokenInfo = oidcUtil.getUnsignedTokenClaims( - token, - new String[] { - DEV_AUD, - PREVIEW_AUD.isBlank() ? DEV_AUD : PREVIEW_AUD, - AUD - }, - ISS - ).getHeader(); + Header tokenInfo = oidcUtil.getUnsignedTokenClaims(token, new String[] {DEV_AUD, AUD}, ISS).getHeader(); String kid = (String) tokenInfo.get(KID); String alg = (String) tokenInfo.get(ALG); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 16406861..e53b525e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,7 +83,6 @@ jwt: iss: ${APPLE_ISS} aud: ${APPLE_AUD} dev-aud: ${APPLE_DEV_AUD} - preview-aud: ${APPLE_PREVIEW_AUD:} admin: token: ${JWT_ADMIN_TOKEN} From 60bcd096cfc1d0dfa5dd02d0989135ad8551a2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 06:39:24 +0900 Subject: [PATCH 53/54] fix: target dev and preview from dev server --- src/main/resources/application.yml | 2 +- .../notification/service/PushEventFlagServiceTest.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e53b525e..4e0a16d6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -165,7 +165,7 @@ push: connect-timeout: 3s read-timeout: 10s event: - # Dev server sets PREVIEW; production safely defaults to PRODUCTION. + # Dev server sets DEV,PREVIEW; production safely defaults to PRODUCTION. target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:PRODUCTION} crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java index efca762d..68063437 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -34,17 +34,17 @@ void setUp() { ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); - ReflectionTestUtils.setField(service, "targetAppVariants", "PREVIEW"); + ReflectionTestUtils.setField(service, "targetAppVariants", "DEV,PREVIEW"); when(redisTemplate.opsForValue()).thenReturn(valueOperations); } @Test - void returnsConfiguredPreviewTargetVariant() { + void returnsConfiguredDevelopmentTargetVariants() { service.isEnabled(PushEventType.CROWD); assertThat(service.getTargetAppVariants()) - .containsExactly(AppVariant.PREVIEW); + .containsExactly(AppVariant.DEV, AppVariant.PREVIEW); } @Test From 421afc665e3b1131c55f4e763e95f478605c5961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 12:42:06 +0900 Subject: [PATCH 54/54] fix: handle Kakao accounts without email --- .../domain/user/validator/KakaoValidator.java | 16 ++++- .../user/validator/KakaoValidatorTest.java | 65 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java index 5de8f149..7b3826d1 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java @@ -56,7 +56,7 @@ private String validateToken(String token, String audience) { OIDCDecodePayload payload = oidcUtil.getOIDCTokenBody(token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE()); - return payload.getEmail(); + return resolveEmail(payload); } catch(GlobalException e) { redisUtil.deleteCache("kakao::data"); throw new GlobalException(e.getResultCode()); @@ -64,4 +64,18 @@ private String validateToken(String token, String audience) { throw new GlobalException(INVALID_TOKEN); } } + + String resolveEmail(OIDCDecodePayload payload) { + String email = payload.getEmail(); + if (email != null && !email.isBlank()) { + return email; + } + + String subject = payload.getSub(); + if (subject == null || subject.isBlank()) { + throw new GlobalException(INVALID_TOKEN); + } + + return "kakao_" + subject + "@noemail.kodaero.invalid"; + } } diff --git a/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java new file mode 100644 index 00000000..7cb264a7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.user.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import devkor.com.teamcback.domain.user.validator.client.KakaoClient; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.jwt.OIDC.OIDCUtil; +import devkor.com.teamcback.global.jwt.OIDC.dto.OIDCDecodePayload; +import devkor.com.teamcback.global.redis.RedisUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class KakaoValidatorTest { + KakaoValidator kakaoValidator; + + @BeforeEach + void setUp() { + kakaoValidator = new KakaoValidator( + Mockito.mock(OIDCUtil.class), + Mockito.mock(KakaoClient.class), + Mockito.mock(RedisUtil.class) + ); + } + + @Test + void keepsEmailFromVerifiedToken() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + "123456789", + "member@example.com" + ); + + assertEquals("member@example.com", kakaoValidator.resolveEmail(payload)); + } + + @Test + void createsStableInternalEmailWhenKakaoEmailIsMissing() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + "123456789", + null + ); + + assertEquals( + "kakao_123456789@noemail.kodaero.invalid", + kakaoValidator.resolveEmail(payload) + ); + } + + @Test + void rejectsTokenWithoutEmailAndSubject() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + null, + null + ); + + assertThrows(GlobalException.class, () -> kakaoValidator.resolveEmail(payload)); + } +}