From 5594d2cca553299f81cc8fd70a1a72a3eebae98f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 14:53:21 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/api/AuthController.java | 60 ++++++++++++++++++- .../auth/api/dto/request/LogoutRequest.java | 17 ++++++ .../auth/application/LogoutService.java | 54 +++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java index 40d18a11..1f5176c2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java @@ -5,18 +5,22 @@ 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.AppleLoginRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.KakaoLoginRequest; +import org.devkor.apu.saerok_server.domain.auth.api.dto.request.LogoutRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.RefreshRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.response.AccessTokenResponse; import org.devkor.apu.saerok_server.domain.auth.application.AppleLoginService; import org.devkor.apu.saerok_server.domain.auth.application.LoginResult; +import org.devkor.apu.saerok_server.domain.auth.application.LogoutService; import org.devkor.apu.saerok_server.domain.auth.application.KakaoLoginService; import org.devkor.apu.saerok_server.domain.auth.application.TokenRefreshService; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.devkor.apu.saerok_server.global.shared.exception.UnauthorizedException; import org.devkor.apu.saerok_server.global.shared.util.ClientInfoExtractor; @@ -25,6 +29,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Tag(name = "Auth API", description = "소셜 인증 관련 API") @@ -33,10 +39,14 @@ @RequestMapping("${api_prefix}/auth/") public class AuthController { + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/v1/auth/refresh"; + private final AppleLoginService appleAuthService; private final KakaoLoginService kakaoAuthService; private final TokenRefreshService tokenRefreshService; private final ClientInfoExtractor clientInfoExtractor; + private final LogoutService logoutService; @Value("${app.cookie.secure}") private boolean isCookieSecure; @@ -169,7 +179,7 @@ public ResponseEntity kakaoLogin( ) public ResponseEntity refresh( @Parameter(hidden = true) - @CookieValue(name = "refreshToken", required = false) String refreshTokenCookie, + @CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie, @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "쿠키에 리프레시 토큰이 없을 때, JSON 바디로 전달된 리프레시 토큰 " + "(iOS App에서 요청할 때 쓰면 편리. 웹 브라우저는 알아서 쿠키를 서버와 주고받으므로 이것을 사용할 필요 없음)" @@ -187,6 +197,40 @@ public ResponseEntity refresh( return toAuthResponse(loginResult); } + @PostMapping("/logout") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "로그아웃", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 현재 인증된 사용자의 로그아웃을 처리합니다.
+ refreshToken이 요청에 포함되면 해당 세션을 revoke하고, + deviceId가 포함되면 현재 디바이스의 푸시 토큰도 삭제합니다.
+ 모바일 앱은 refreshTokenJson을 사용할 수 있고, + refreshToken 쿠키가 요청에 포함된 경우에도 해당 값을 사용합니다. + """, + responses = { + @ApiResponse(responseCode = "204", description = "로그아웃 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + } + ) + public ResponseEntity logout( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Parameter(hidden = true) + @CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie, + @RequestBody(required = false) LogoutRequest request + ) { + LogoutRequest body = request != null ? request : new LogoutRequest(null, null, null); + String refreshToken = refreshTokenCookie != null ? refreshTokenCookie : body.refreshTokenJson(); + + logoutService.logout(userPrincipal.getId(), refreshToken, body.deviceId(), body.platform()); + + return ResponseEntity + .noContent() + .header(HttpHeaders.SET_COOKIE, clearRefreshTokenCookie().toString()) + .build(); + } + /** * AuthResult(비즈니스 결과)를 HTTP 응답(ResponseEntity + 쿠키)로 매핑. */ @@ -204,12 +248,22 @@ private ResponseEntity toAuthResponse(LoginResult loginResu } private ResponseCookie createRefreshTokenCookie(String refreshToken) { - return ResponseCookie.from("refreshToken", refreshToken) + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) .httpOnly(true) .secure(isCookieSecure) .sameSite("Lax") - .path("/api/v1/auth/refresh") + .path(REFRESH_TOKEN_COOKIE_PATH) .maxAge(RefreshTokenProvider.validDuration) .build(); } + + private ResponseCookie clearRefreshTokenCookie() { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "") + .httpOnly(true) + .secure(isCookieSecure) + .sameSite("Lax") + .path(REFRESH_TOKEN_COOKIE_PATH) + .maxAge(0) + .build(); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java new file mode 100644 index 00000000..62a3abde --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.auth.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; + +@Schema(description = "로그아웃 요청 DTO") +public record LogoutRequest( + @Schema(description = "쿠키 대신 JSON Body로 전달하는 Refresh Token (모바일 앱용)") + String refreshTokenJson, + + @Schema(description = "현재 로그아웃하는 디바이스 식별자") + String deviceId, + + @Schema(description = "현재 로그아웃하는 디바이스 플랫폼", example = "IOS") + DevicePlatform platform +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java new file mode 100644 index 00000000..34d0b87a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java @@ -0,0 +1,54 @@ +package org.devkor.apu.saerok_server.domain.auth.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class LogoutService { + + private final RefreshTokenProvider refreshTokenProvider; + private final UserRefreshTokenRepository userRefreshTokenRepository; + private final UserDeviceRepository userDeviceRepository; + + public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) { + revokeRefreshToken(userId, refreshToken); + deleteCurrentDevice(userId, deviceId, platform); + } + + private void revokeRefreshToken(Long userId, String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + return; + } + + userRefreshTokenRepository.findByRefreshTokenHash(refreshTokenProvider.hash(refreshToken)) + .ifPresent(token -> { + if (!token.getUser().getId().equals(userId)) { + log.warn("리프레시 토큰 소유주가 일치하지 않습니다: authenticatedUserId={}, tokenUserId={}", + userId, token.getUser().getId()); + return; + } + + if (token.getRevokedAt() == null) { + token.revoke(); + } + }); + } + + private void deleteCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { + if (deviceId == null || deviceId.isBlank()) { + return; + } + + DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; + userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform); + } +} From 3b7268528273db6958efb3b86c6bd243cb9df5e4 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 15:07:41 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=A0=95=EB=A6=AC=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 --- .../application/UserDeviceCommandService.java | 19 +++ .../core/repository/UserDeviceRepository.java | 20 ++++ .../infra/fcm/FcmMessageClient.java | 6 +- .../auth/application/LogoutServiceTest.java | 111 ++++++++++++++++++ .../repository/UserDeviceRepositoryTest.java | 49 ++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java index 05589ba2..b41a5689 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java @@ -16,6 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional @RequiredArgsConstructor @@ -38,6 +40,13 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS; + userDeviceRepository.deleteConflictingDevicesForRegistration( + command.userId(), + command.deviceId(), + platform, + command.token() + ); + UserDevice userDevice = userDeviceRepository .findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform) .map(existing -> { @@ -71,4 +80,14 @@ public void deleteAllTokens(Long userId) { notificationSettingRepository.deleteByUserId(userId); userDeviceRepository.deleteByUserId(userId); } + + public void deleteInvalidTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) { + return; + } + + tokens.stream() + .distinct() + .forEach(userDeviceRepository::deleteByToken); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java index e88486d7..636d2461 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java @@ -42,6 +42,26 @@ public void deleteByToken(String token) { .executeUpdate(); } + public int deleteConflictingDevicesForRegistration(Long userId, String deviceId, DevicePlatform platform, String token) { + return em.createQuery(""" + DELETE FROM UserDevice ud + WHERE ( + ud.token = :token + OR (ud.deviceId = :deviceId AND ud.platform = :platform) + ) + AND NOT ( + ud.user.id = :userId + AND ud.deviceId = :deviceId + AND ud.platform = :platform + ) + """) + .setParameter("userId", userId) + .setParameter("deviceId", deviceId) + .setParameter("platform", platform) + .setParameter("token", token) + .executeUpdate(); + } + // ID로 디바이스 조회 public Optional findById(Long id) { List results = em.createQuery("SELECT ud FROM UserDevice ud WHERE ud.id = :id", UserDevice.class) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java index 2f7f238d..a6c23ec4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java @@ -3,8 +3,8 @@ import com.google.firebase.messaging.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -19,7 +19,7 @@ public class FcmMessageClient { private final FirebaseMessaging firebaseMessaging; - private final UserDeviceRepository userDeviceRepository; + private final UserDeviceCommandService userDeviceCommandService; @Async("pushNotificationExecutor") public void sendToDevices(List fcmTokens, PushMessageCommand cmd) { @@ -147,7 +147,7 @@ private void cleanupInvalidTokens(List fcmTokens, BatchResponse response if (!invalid.isEmpty()) { try { - invalid.forEach(userDeviceRepository::deleteByToken); + userDeviceCommandService.deleteInvalidTokens(invalid); } catch (Exception e) { log.warn("Invalid FCM tokens cleanup failed ({} tokens): {}", invalid.size(), e.getMessage()); } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java new file mode 100644 index 00000000..2a1cca74 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java @@ -0,0 +1,111 @@ +package org.devkor.apu.saerok_server.domain.auth.application; + +import org.devkor.apu.saerok_server.domain.auth.core.entity.UserRefreshToken; +import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; +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; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +class LogoutServiceTest { + + @Mock RefreshTokenProvider refreshTokenProvider; + @Mock UserRefreshTokenRepository userRefreshTokenRepository; + @Mock UserDeviceRepository userDeviceRepository; + + @InjectMocks LogoutService logoutService; + + @Test + @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 삭제한다") + void logout_revokesRefreshTokenAndDeletesDevice() { + UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); + + assertThat(token.getRevokedAt()).isNotNull(); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token이 이미 revoke되어 있으면 다시 revoke하지 않고 디바이스만 정리한다") + void logout_alreadyRevokedRefreshToken() { + UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); + token.revoke(); + var revokedAt = token.getRevokedAt(); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", null); + + assertThat(token.getRevokedAt()).isEqualTo(revokedAt); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token의 유저가 달라도 해당 토큰은 무시하고 디바이스 정리는 계속한다") + void logout_refreshTokenOwnerMismatch() { + UserRefreshToken token = refreshTokenFor(user(99L), "refresh-hash"); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); + + assertThat(token.getRevokedAt()).isNull(); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token과 deviceId가 없으면 idempotent하게 아무 작업도 하지 않는다") + void logout_withoutRefreshTokenAndDevice() { + logoutService.logout(42L, null, null, null); + + verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceRepository); + } + + @Test + @DisplayName("존재하지 않는 refresh token은 무시하고 디바이스 정리는 수행한다") + void logout_missingRefreshTokenRow() { + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.empty()); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.ANDROID); + + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.ANDROID); + verifyNoMoreInteractions(userDeviceRepository); + } + + private User user(Long id) { + User user = User.createUser("user" + id + "@example.com"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private UserRefreshToken refreshTokenFor(User user, String hash) { + return UserRefreshToken.create( + user, + hash, + "Mozilla/5.0", + "127.0.0.1", + Duration.ofDays(30) + ); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java index 427a532d..782d6602 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java @@ -93,6 +93,55 @@ void deleteByToken_removesDevice() { assertThat(devices.getFirst().getToken()).isEqualTo("token-2"); } + @Test @DisplayName("deleteConflictingDevicesForRegistration - 등록 대상 외 같은 token 또는 같은 device/platform row 삭제") + void deleteConflictingDevicesForRegistration_removesStaleRows() { + User currentUser = user(); + User otherUser = user(); + device(currentUser, "device-1", "token-1"); + device(currentUser, "device-3", "token-1"); + device(otherUser, "device-2", "token-1"); + device(otherUser, "device-1", "token-2"); + device(otherUser, "device-4", "token-4"); + repo.flush(); em.clear(); + + int deleted = repo.deleteConflictingDevicesForRegistration( + currentUser.getId(), + "device-1", + DevicePlatform.IOS, + "token-1" + ); + repo.flush(); em.clear(); + + assertThat(deleted).isEqualTo(3); + assertThat(repo.findAllByUserId(currentUser.getId())) + .extracting(UserDevice::getDeviceId) + .containsExactly("device-1"); + assertThat(repo.findAllByUserId(otherUser.getId())) + .extracting(UserDevice::getDeviceId) + .containsExactly("device-4"); + } + + @Test @DisplayName("deleteConflictingDevicesForRegistration - platform이 다른 같은 deviceId row는 token이 다르면 유지") + void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { + User currentUser = user(); + User otherUser = user(); + UserDevice androidDevice = UserDevice.create(otherUser, "device-1", "token-android", DevicePlatform.ANDROID); + repo.save(androidDevice); + repo.flush(); em.clear(); + + int deleted = repo.deleteConflictingDevicesForRegistration( + currentUser.getId(), + "device-1", + DevicePlatform.IOS, + "token-ios" + ); + repo.flush(); em.clear(); + + assertThat(deleted).isZero(); + assertThat(repo.findByUserIdAndDeviceIdAndPlatform(otherUser.getId(), "device-1", DevicePlatform.ANDROID)) + .isPresent(); + } + @Test @DisplayName("deleteByUserId") void deleteByUserId_removesAllDevices() { User user1 = user(); From c79a46e5c28526c4168906199ec77e82c1549148 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 16:17:50 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20fcm=20=ED=86=A0=ED=81=B0=EC=9D=84?= =?UTF-8?q?=20nullable=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20id=20=ED=96=89=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20->=20=ED=86=A0=ED=81=B0=EB=A7=8C=20null?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=EB=A1=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/LogoutService.java | 11 +- .../api/UserDeviceController.java | 50 ------- .../application/UserDeviceCommandService.java | 41 +++--- .../notification/core/entity/UserDevice.java | 15 +- .../NotificationSettingRepository.java | 2 + .../core/repository/UserDeviceRepository.java | 72 +++++---- .../infra/fcm/FcmMessageClient.java | 2 +- .../infra/fcm/FcmPushGateway.java | 4 +- ..._preserve_device_notification_settings.sql | 37 +++++ .../auth/application/LogoutServiceTest.java | 20 +-- .../UserDeviceTokenMigrationTest.java | 139 ++++++++++++++++++ .../NotificationSettingRepositoryTest.java | 7 +- .../repository/UserDeviceRepositoryTest.java | 106 +++++++------ 13 files changed, 335 insertions(+), 171 deletions(-) create mode 100644 src/main/resources/db/migration/V93__preserve_device_notification_settings.sql create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java index 34d0b87a..fa9dcb7a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java @@ -3,8 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,11 +17,11 @@ public class LogoutService { private final RefreshTokenProvider refreshTokenProvider; private final UserRefreshTokenRepository userRefreshTokenRepository; - private final UserDeviceRepository userDeviceRepository; + private final UserDeviceCommandService userDeviceCommandService; public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) { revokeRefreshToken(userId, refreshToken); - deleteCurrentDevice(userId, deviceId, platform); + deactivateCurrentDevice(userId, deviceId, platform); } private void revokeRefreshToken(Long userId, String refreshToken) { @@ -43,12 +43,11 @@ private void revokeRefreshToken(Long userId, String refreshToken) { }); } - private void deleteCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { + private void deactivateCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { if (deviceId == null || deviceId.isBlank()) { return; } - DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; - userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform); + userDeviceCommandService.deactivateDeviceIfPresent(userId, deviceId, platform); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java index dc07399e..156fec7e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java @@ -1,6 +1,5 @@ package org.devkor.apu.saerok_server.domain.notification.api; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,10 +10,8 @@ import org.devkor.apu.saerok_server.domain.notification.api.dto.request.RegisterTokenRequest; import org.devkor.apu.saerok_server.domain.notification.api.dto.response.RegisterUserDeviceResponse; import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; -import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -52,51 +49,4 @@ public RegisterUserDeviceResponse registerUserDevice( userDeviceWebMapper.toRegisterUserDeviceCommand(request, userPrincipal.getId()) ); } - - @Hidden - @DeleteMapping("/{deviceId}") - @PreAuthorize("isAuthenticated()") - @ResponseStatus(HttpStatus.NO_CONTENT) - @Operation( - summary = "특정 디바이스 토큰 삭제", - security = @SecurityRequirement(name = "bearerAuth"), - description = """ - 특정 디바이스의 토큰을 삭제합니다.
- 보통 로그아웃 시 사용됩니다.
- """, - responses = { - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), - @ApiResponse(responseCode = "404", description = "해당 디바이스를 찾을 수 없음", content = @Content) - } - ) - public void deleteDevice( - @AuthenticationPrincipal UserPrincipal userPrincipal, - @PathVariable String deviceId, - @RequestParam(required = false) DevicePlatform platform - ) { - userDeviceCommandService.deleteDevice(userPrincipal.getId(), deviceId, platform); - } - - @Hidden - @DeleteMapping("/all") - @PreAuthorize("isAuthenticated()") - @ResponseStatus(HttpStatus.NO_CONTENT) - @Operation( - summary = "사용자의 모든 디바이스 토큰 삭제", - security = @SecurityRequirement(name = "bearerAuth"), - description = """ - 사용자의 모든 디바이스 토큰을 삭제합니다.
- 보통 회원 탈퇴 시 사용됩니다.
- """, - responses = { - @ApiResponse(responseCode = "204", description = "전체 삭제 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) - } - ) - public void deleteAllTokens( - @AuthenticationPrincipal UserPrincipal userPrincipal - ) { - userDeviceCommandService.deleteAllTokens(userPrincipal.getId()); - } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java index b41a5689..2b3897a7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java @@ -6,7 +6,6 @@ import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; -import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService; import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper; import org.devkor.apu.saerok_server.domain.user.core.entity.User; @@ -24,7 +23,6 @@ public class UserDeviceCommandService { private final UserDeviceRepository userDeviceRepository; - private final NotificationSettingRepository notificationSettingRepository; private final UserRepository userRepository; private final UserDeviceWebMapper userDeviceWebMapper; private final NotificationSettingBackfillService backfillService; @@ -33,14 +31,14 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c User user = userRepository.findById(command.userId()) .orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); - if (command.deviceId() == null || command.deviceId().isEmpty() - || command.token() == null || command.token().isEmpty()) { + if (command.deviceId() == null || command.deviceId().isBlank() + || command.token() == null || command.token().isBlank()) { throw new BadRequestException("deviceId, token은 필수입니다"); } DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS; - userDeviceRepository.deleteConflictingDevicesForRegistration( + userDeviceRepository.deactivateConflictingTokensForRegistration( command.userId(), command.deviceId(), platform, @@ -50,7 +48,7 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c UserDevice userDevice = userDeviceRepository .findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform) .map(existing -> { - existing.updateToken(command.token()); + existing.activateToken(command.token()); return existing; }) .orElseGet(() -> { @@ -65,29 +63,26 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c return userDeviceWebMapper.toRegisterUserDeviceResponse(command, true); } - public void deleteDevice(Long userId, String deviceId, DevicePlatform platform) { - userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); - platform = platform != null ? platform : DevicePlatform.IOS; - userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform) - .orElseThrow(() -> new NotFoundException("해당 디바이스를 찾을 수 없어요")); - - userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform); - } - - public void deleteAllTokens(Long userId) { - userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); + public void deactivateDeviceIfPresent(Long userId, String deviceId, DevicePlatform platform) { + if (deviceId == null || deviceId.isBlank()) { + return; + } - notificationSettingRepository.deleteByUserId(userId); - userDeviceRepository.deleteByUserId(userId); + DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; + userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform) + .ifPresent(UserDevice::deactivateToken); } - public void deleteInvalidTokens(List tokens) { + public void deactivateInvalidTokens(List tokens) { if (tokens == null || tokens.isEmpty()) { return; } - tokens.stream() - .distinct() - .forEach(userDeviceRepository::deleteByToken); + List validTokens = tokens.stream() + .filter(token -> token != null && !token.isBlank()) + .distinct() + .toList(); + + userDeviceRepository.deactivateByTokens(validTokens); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java index ba5ebc16..69d6fed7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java @@ -27,7 +27,7 @@ public class UserDevice extends Auditable { @Column(name = "device_id", nullable = false, length = 256) private String deviceId; - @Column(name = "token", nullable = false, length = 512) + @Column(name = "token", length = 512) private String token; @Enumerated(EnumType.STRING) @@ -38,10 +38,19 @@ public static UserDevice create(User user, String deviceId, String token, Device UserDevice userDevice = new UserDevice(); userDevice.user = user; userDevice.deviceId = deviceId; - userDevice.token = token; userDevice.platform = platform; + userDevice.activateToken(token); return userDevice; } - public void updateToken(String newToken) { this.token = newToken; } + public void activateToken(String newToken) { + if (newToken == null || newToken.isBlank()) { + throw new IllegalArgumentException("FCM token은 비어 있을 수 없습니다"); + } + this.token = newToken; + } + + public void deactivateToken() { + this.token = null; + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java index d432a10e..c1bc9f08 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java @@ -46,6 +46,7 @@ public List findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp where ns.userDevice.user.id = :userId and ns.type = :type and ns.enabled = true + and ns.userDevice.token is not null """, Long.class) .setParameter("userId", userId) .setParameter("type", type) @@ -60,6 +61,7 @@ public List findEnabledDeviceIdsByUserIdsAndType(List userIds, Notif where ns.userDevice.user.id in :userIds and ns.type = :type and ns.enabled = true + and ns.userDevice.token is not null """, Long.class) .setParameter("userIds", userIds) .setParameter("type", type) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java index 636d2461..0ebd2800 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java @@ -19,33 +19,29 @@ public class UserDeviceRepository { public void save(UserDevice userDevice) { em.persist(userDevice); } public void flush() { em.flush(); } - // 특정 디바이스 삭제 - public void deleteByUserIdAndDeviceIdAndPlatform(Long userId, String deviceId, DevicePlatform platform) { - em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId AND ud.deviceId = :deviceId AND ud.platform = :platform") - .setParameter("userId", userId) - .setParameter("deviceId", deviceId) - .setParameter("platform", platform) - .executeUpdate(); - } - - // 모든 토큰 삭제 - public int deleteByUserId(Long userId) { - return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId") - .setParameter("userId", userId) - .executeUpdate(); - } + public int deactivateByTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) { + return 0; + } - // 개별 토큰 삭제 - public void deleteByToken(String token) { - em.createQuery("DELETE FROM UserDevice ud WHERE ud.token = :token") - .setParameter("token", token) + return em.createQuery(""" + UPDATE UserDevice ud + SET ud.token = NULL, + ud.updatedAt = CURRENT_TIMESTAMP + WHERE ud.token IN :tokens + """) + .setParameter("tokens", tokens) .executeUpdate(); } - public int deleteConflictingDevicesForRegistration(Long userId, String deviceId, DevicePlatform platform, String token) { + public int deactivateConflictingTokensForRegistration(Long userId, String deviceId, + DevicePlatform platform, String token) { return em.createQuery(""" - DELETE FROM UserDevice ud - WHERE ( + UPDATE UserDevice ud + SET ud.token = NULL, + ud.updatedAt = CURRENT_TIMESTAMP + WHERE ud.token IS NOT NULL + AND ( ud.token = :token OR (ud.deviceId = :deviceId AND ud.platform = :platform) ) @@ -62,6 +58,13 @@ AND NOT ( .executeUpdate(); } + // 회원 탈퇴 시에만 사용하는 영구 삭제 + public int deleteByUserId(Long userId) { + return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId") + .setParameter("userId", userId) + .executeUpdate(); + } + // ID로 디바이스 조회 public Optional findById(Long id) { List results = em.createQuery("SELECT ud FROM UserDevice ud WHERE ud.id = :id", UserDevice.class) @@ -86,10 +89,13 @@ public Optional findByUserIdAndDeviceIdAndPlatform(Long userId, Stri return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } - public List findAllByUserId(Long userId) { - return em.createQuery( - "SELECT ud FROM UserDevice ud WHERE ud.user.id = :userId", - UserDevice.class) + public List findAllActiveByUserId(Long userId) { + return em.createQuery(""" + SELECT ud + FROM UserDevice ud + WHERE ud.user.id = :userId + AND ud.token IS NOT NULL + """, UserDevice.class) .setParameter("userId", userId) .getResultList(); } @@ -100,14 +106,24 @@ public List findTokensByUserDeviceIds(List userDeviceIds) { return List.of(); } - return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.id IN :userDeviceIds", String.class) + return em.createQuery(""" + SELECT ud.token + FROM UserDevice ud + WHERE ud.id IN :userDeviceIds + AND ud.token IS NOT NULL + """, String.class) .setParameter("userDeviceIds", userDeviceIds) .getResultList(); } // 사용자 ID로 모든 FCM 토큰 조회 (사일런트 푸시용) public List findTokensByUserId(Long userId) { - return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.user.id = :userId", String.class) + return em.createQuery(""" + SELECT ud.token + FROM UserDevice ud + WHERE ud.user.id = :userId + AND ud.token IS NOT NULL + """, String.class) .setParameter("userId", userId) .getResultList(); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java index a6c23ec4..9f9dd78b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java @@ -147,7 +147,7 @@ private void cleanupInvalidTokens(List fcmTokens, BatchResponse response if (!invalid.isEmpty()) { try { - userDeviceCommandService.deleteInvalidTokens(invalid); + userDeviceCommandService.deactivateInvalidTokens(invalid); } catch (Exception e) { log.warn("Invalid FCM tokens cleanup failed ({} tokens): {}", invalid.size(), e.getMessage()); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java index d73d783f..db742a1b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java @@ -30,7 +30,7 @@ public class FcmPushGateway implements PushGateway { @Override public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) { - userDeviceRepository.findAllByUserId(userId) + userDeviceRepository.findAllActiveByUserId(userId) .forEach(backfillService::ensureDefaults); List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); @@ -66,7 +66,7 @@ public void sendToUsersDeduplicated(List targets) { continue; } - userDeviceRepository.findAllByUserId(userId) + userDeviceRepository.findAllActiveByUserId(userId) .forEach(backfillService::ensureDefaults); List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); diff --git a/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql b/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql new file mode 100644 index 00000000..a2b34c56 --- /dev/null +++ b/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql @@ -0,0 +1,37 @@ +ALTER TABLE user_device + ALTER COLUMN token DROP NOT NULL; + +-- 과거 데이터에 빈 token이 있다면 비활성 상태로 정규화한다. +UPDATE user_device +SET token = NULL, + updated_at = now() +WHERE token IS NOT NULL + AND btrim(token) = ''; + +-- partial unique index 생성 전에 중복 token을 안전하게 정리한다. +-- 가장 최근에 갱신된 row 하나만 활성 상태로 유지하고 나머지는 설정과 함께 보존한다. +WITH ranked_tokens AS ( + SELECT id, + row_number() OVER ( + PARTITION BY token + ORDER BY updated_at DESC, id DESC + ) AS row_rank + FROM user_device + WHERE token IS NOT NULL +) +UPDATE user_device ud +SET token = NULL, + updated_at = now() +FROM ranked_tokens ranked +WHERE ud.id = ranked.id + AND ranked.row_rank > 1; + +DROP INDEX IF EXISTS idx_user_device_token; + +CREATE UNIQUE INDEX uq_user_device_active_token + ON user_device(token) + WHERE token IS NOT NULL; + +ALTER TABLE user_device + ADD CONSTRAINT ck_user_device_token_not_blank + CHECK (token IS NULL OR btrim(token) <> ''); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java index 2a1cca74..f058603f 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java @@ -2,8 +2,8 @@ import org.devkor.apu.saerok_server.domain.auth.core.entity.UserRefreshToken; import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.junit.jupiter.api.DisplayName; @@ -28,13 +28,13 @@ class LogoutServiceTest { @Mock RefreshTokenProvider refreshTokenProvider; @Mock UserRefreshTokenRepository userRefreshTokenRepository; - @Mock UserDeviceRepository userDeviceRepository; + @Mock UserDeviceCommandService userDeviceCommandService; @InjectMocks LogoutService logoutService; @Test - @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 삭제한다") - void logout_revokesRefreshTokenAndDeletesDevice() { + @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 비활성화한다") + void logout_revokesRefreshTokenAndDeactivatesDevice() { UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); @@ -42,7 +42,7 @@ void logout_revokesRefreshTokenAndDeletesDevice() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); assertThat(token.getRevokedAt()).isNotNull(); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.IOS); } @Test @@ -57,7 +57,7 @@ void logout_alreadyRevokedRefreshToken() { logoutService.logout(42L, "raw-refresh", "device-1", null); assertThat(token.getRevokedAt()).isEqualTo(revokedAt); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", null); } @Test @@ -70,7 +70,7 @@ void logout_refreshTokenOwnerMismatch() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); assertThat(token.getRevokedAt()).isNull(); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.IOS); } @Test @@ -78,7 +78,7 @@ void logout_refreshTokenOwnerMismatch() { void logout_withoutRefreshTokenAndDevice() { logoutService.logout(42L, null, null, null); - verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceRepository); + verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceCommandService); } @Test @@ -89,8 +89,8 @@ void logout_missingRefreshTokenRow() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.ANDROID); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.ANDROID); - verifyNoMoreInteractions(userDeviceRepository); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.ANDROID); + verifyNoMoreInteractions(userDeviceCommandService); } private User user(Long id) { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java new file mode 100644 index 00000000..5e950c7d --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java @@ -0,0 +1,139 @@ +package org.devkor.apu.saerok_server.domain.notification; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UserDeviceTokenMigrationTest { + + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.5-alpine") + .asCompatibleSubstituteFor("postgres") + ); + + @BeforeAll + static void startPostgres() { + POSTGRES.start(); + } + + @AfterAll + static void stopPostgres() { + POSTGRES.stop(); + } + + @Test + @DisplayName("V93은 중복/빈 token만 비활성화하고 디바이스와 알림 설정을 보존한다") + void migration_preservesRowsAndSettingsWhileNormalizingTokens() throws Exception { + migrateToVersion92(); + + try (Connection connection = connection(); Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + INSERT INTO users (id, joined_at) VALUES + (100001, now()), + (100002, now()) + """); + statement.executeUpdate(""" + INSERT INTO user_device + (id, user_id, device_id, token, platform, created_at, updated_at) + VALUES + (900001, 100001, 'device-old', 'duplicate-token', 'IOS', now(), '2026-01-01T00:00:00Z'), + (900002, 100002, 'device-new', 'duplicate-token', 'IOS', now(), '2026-02-01T00:00:00Z'), + (900003, 100001, 'device-blank', ' ', 'IOS', now(), '2026-03-01T00:00:00Z') + """); + statement.executeUpdate(""" + INSERT INTO notification_setting + (id, user_device_id, type, enabled, created_at, updated_at) + VALUES + (910001, 900001, 'LIKED_ON_COLLECTION', FALSE, now(), now()) + """); + } + + migrateToLatest(); + + try (Connection connection = connection(); Statement statement = connection.createStatement()) { + assertThat(queryLong(statement, "SELECT COUNT(*) FROM user_device")).isEqualTo(3L); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900001")).isNull(); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900002")) + .isEqualTo("duplicate-token"); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900003")).isNull(); + assertThat(queryLong(statement, "SELECT COUNT(*) FROM notification_setting WHERE id = 910001")) + .isEqualTo(1L); + assertThat(queryBoolean(statement, "SELECT enabled FROM notification_setting WHERE id = 910001")) + .isFalse(); + assertThat(queryBoolean(statement, """ + SELECT is_nullable = 'YES' + FROM information_schema.columns + WHERE table_name = 'user_device' AND column_name = 'token' + """)).isTrue(); + + statement.executeUpdate("INSERT INTO users (id, joined_at) VALUES (100003, now())"); + assertThatThrownBy(() -> statement.executeUpdate(""" + INSERT INTO user_device + (id, user_id, device_id, token, platform, created_at, updated_at) + VALUES + (900004, 100003, 'device-duplicate', 'duplicate-token', 'IOS', now(), now()) + """)) + .isInstanceOf(SQLException.class); + } + } + + private void migrateToVersion92() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("92")) + .load() + .migrate(); + } + + private void migrateToLatest() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + private Connection connection() throws SQLException { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), + POSTGRES.getUsername(), + POSTGRES.getPassword() + ); + } + + private long queryLong(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getLong(1); + } + } + + private String queryString(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getString(1); + } + } + + private boolean queryBoolean(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getBoolean(1); + } + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java index e401fcd5..992b6138 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java @@ -81,16 +81,19 @@ void findByUserDeviceIdAndType_returnsMatch() { assertThat(missing).isEmpty(); } - @Test @DisplayName("findEnabledDeviceIdsByUserAndType - enabled devices만 반환") - void findEnabledDeviceIdsByUserAndType_returnsEnabled() { + @Test @DisplayName("findEnabledDeviceIdsByUserAndType - enabled이면서 token이 활성인 디바이스만 반환") + void findEnabledDeviceIdsByUserAndType_returnsEnabledActiveDevices() { User user = user(); User otherUser = user(); UserDevice enabledDevice = device(user, "device-enabled"); UserDevice disabledDevice = device(user, "device-disabled"); + UserDevice inactiveDevice = device(user, "device-inactive"); UserDevice otherDevice = device(otherUser, "device-other"); setting(enabledDevice, NotificationType.COMMENTED_ON_COLLECTION, true); setting(disabledDevice, NotificationType.COMMENTED_ON_COLLECTION, false); + setting(inactiveDevice, NotificationType.COMMENTED_ON_COLLECTION, true); setting(otherDevice, NotificationType.COMMENTED_ON_COLLECTION, true); + inactiveDevice.deactivateToken(); em.flush(); em.clear(); List deviceIds = diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java index 782d6602..02dd2727 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.notification.core.repository; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSetting; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; @@ -65,71 +67,71 @@ void findByUserIdAndDeviceIdAndPlatform_returnsMatch() { assertThat(missingPlatform).isEmpty(); } - @Test @DisplayName("deleteByUserIdAndDeviceIdAndPlatform") - void deleteByUserIdAndDeviceIdAndPlatform_removesDevice() { + @Test @DisplayName("deactivateByTokens - token만 비활성화하고 디바이스와 설정은 유지") + void deactivateByTokens_preservesDeviceAndSettings() { User user = user(); - device(user, "device-1", "token-1"); + UserDevice device = device(user, "device-1", "token-1"); + NotificationSetting setting = NotificationSetting.of(device, NotificationType.LIKED_ON_COLLECTION, false); + em.persist(setting); repo.flush(); em.clear(); - repo.deleteByUserIdAndDeviceIdAndPlatform(user.getId(), "device-1", DevicePlatform.IOS); + int deactivated = repo.deactivateByTokens(List.of("token-1")); repo.flush(); em.clear(); Optional found = repo.findByUserIdAndDeviceIdAndPlatform(user.getId(), "device-1", DevicePlatform.IOS); - assertThat(found).isEmpty(); - } + NotificationSetting preservedSetting = em.find(NotificationSetting.class, setting.getId()); - @Test @DisplayName("deleteByToken") - void deleteByToken_removesDevice() { - User user = user(); - device(user, "device-1", "token-1"); - device(user, "device-2", "token-2"); - repo.flush(); em.clear(); + assertThat(deactivated).isEqualTo(1); + assertThat(found).isPresent(); + assertThat(found.get().getToken()).isNull(); + assertThat(preservedSetting).isNotNull(); + assertThat(preservedSetting.getEnabled()).isFalse(); + assertThat(repo.findTokensByUserId(user.getId())).isEmpty(); + assertThat(repo.findTokensByUserDeviceIds(List.of(found.get().getId()))).isEmpty(); - repo.deleteByToken("token-1"); + found.get().activateToken("token-new"); repo.flush(); em.clear(); - List devices = repo.findAllByUserId(user.getId()); - assertThat(devices).hasSize(1); - assertThat(devices.getFirst().getToken()).isEqualTo("token-2"); + UserDevice reactivated = repo.findById(found.get().getId()).orElseThrow(); + NotificationSetting settingAfterReactivation = em.find(NotificationSetting.class, setting.getId()); + assertThat(reactivated.getToken()).isEqualTo("token-new"); + assertThat(settingAfterReactivation.getEnabled()).isFalse(); } - @Test @DisplayName("deleteConflictingDevicesForRegistration - 등록 대상 외 같은 token 또는 같은 device/platform row 삭제") - void deleteConflictingDevicesForRegistration_removesStaleRows() { + @Test @DisplayName("deactivateConflictingTokensForRegistration - 충돌 row의 token만 비활성화") + void deactivateConflictingTokensForRegistration_preservesRows() { User currentUser = user(); User otherUser = user(); - device(currentUser, "device-1", "token-1"); - device(currentUser, "device-3", "token-1"); - device(otherUser, "device-2", "token-1"); - device(otherUser, "device-1", "token-2"); - device(otherUser, "device-4", "token-4"); + UserDevice currentDevice = device(currentUser, "device-1", "token-current"); + UserDevice sameTokenDevice = device(otherUser, "device-2", "token-next"); + UserDevice samePhysicalDevice = device(otherUser, "device-1", "token-old"); + UserDevice unrelatedDevice = device(otherUser, "device-4", "token-unrelated"); repo.flush(); em.clear(); - int deleted = repo.deleteConflictingDevicesForRegistration( + int deactivated = repo.deactivateConflictingTokensForRegistration( currentUser.getId(), "device-1", DevicePlatform.IOS, - "token-1" + "token-next" ); repo.flush(); em.clear(); - assertThat(deleted).isEqualTo(3); - assertThat(repo.findAllByUserId(currentUser.getId())) - .extracting(UserDevice::getDeviceId) - .containsExactly("device-1"); - assertThat(repo.findAllByUserId(otherUser.getId())) - .extracting(UserDevice::getDeviceId) - .containsExactly("device-4"); + assertThat(deactivated).isEqualTo(2); + assertThat(repo.findById(currentDevice.getId()).orElseThrow().getToken()).isEqualTo("token-current"); + assertThat(repo.findById(sameTokenDevice.getId()).orElseThrow().getToken()).isNull(); + assertThat(repo.findById(samePhysicalDevice.getId()).orElseThrow().getToken()).isNull(); + assertThat(repo.findById(unrelatedDevice.getId()).orElseThrow().getToken()).isEqualTo("token-unrelated"); } - @Test @DisplayName("deleteConflictingDevicesForRegistration - platform이 다른 같은 deviceId row는 token이 다르면 유지") - void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { + @Test @DisplayName("deactivateConflictingTokensForRegistration - platform이 다른 같은 deviceId row는 유지") + void deactivateConflictingTokensForRegistration_keepsDifferentPlatformDevice() { User currentUser = user(); User otherUser = user(); UserDevice androidDevice = UserDevice.create(otherUser, "device-1", "token-android", DevicePlatform.ANDROID); repo.save(androidDevice); repo.flush(); em.clear(); - int deleted = repo.deleteConflictingDevicesForRegistration( + int deactivated = repo.deactivateConflictingTokensForRegistration( currentUser.getId(), "device-1", DevicePlatform.IOS, @@ -137,12 +139,12 @@ void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { ); repo.flush(); em.clear(); - assertThat(deleted).isZero(); + assertThat(deactivated).isZero(); assertThat(repo.findByUserIdAndDeviceIdAndPlatform(otherUser.getId(), "device-1", DevicePlatform.ANDROID)) .isPresent(); } - @Test @DisplayName("deleteByUserId") + @Test @DisplayName("deleteByUserId - 회원 탈퇴 시에는 디바이스를 영구 삭제") void deleteByUserId_removesAllDevices() { User user1 = user(); User user2 = user(); @@ -155,21 +157,33 @@ void deleteByUserId_removesAllDevices() { repo.flush(); em.clear(); assertThat(deleted).isEqualTo(2); - assertThat(repo.findAllByUserId(user1.getId())).isEmpty(); - assertThat(repo.findAllByUserId(user2.getId())).hasSize(1); + assertThat(repo.findAllActiveByUserId(user1.getId())).isEmpty(); + assertThat(repo.findAllActiveByUserId(user2.getId())).hasSize(1); + } + + @Test @DisplayName("partial unique index - 활성 token은 하나의 row에만 귀속") + void activeToken_mustBeUnique() { + User user1 = user(); + User user2 = user(); + device(user1, "device-1", "duplicate-token"); + repo.flush(); + + device(user2, "device-2", "duplicate-token"); + + assertThatThrownBy(repo::flush).isInstanceOf(Exception.class); } - @Test @DisplayName("findAllByUserId") - void findAllByUserId_returnsDevices() { + @Test @DisplayName("findAllActiveByUserId - 비활성(token=null) 디바이스는 제외") + void findAllActiveByUserId_excludesInactive() { User user = user(); - UserDevice first = device(user, "device-1", "token-1"); - UserDevice second = device(user, "device-2", "token-2"); + UserDevice active = device(user, "device-1", "token-1"); + UserDevice inactive = device(user, "device-2", "token-2"); + inactive.deactivateToken(); repo.flush(); em.clear(); - List devices = repo.findAllByUserId(user.getId()); + List devices = repo.findAllActiveByUserId(user.getId()); - assertThat(devices).hasSize(2); assertThat(devices).extracting(UserDevice::getId) - .containsExactlyInAnyOrder(first.getId(), second.getId()); + .containsExactly(active.getId()); } } From c337234a8fa87299f0a6b3d69085b352944200ee Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 3 Jul 2026 18:50:01 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EB=8F=84=EA=B0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=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 --- .../audit/core/entity/AdminAuditAction.java | 2 + .../core/entity/AdminAuditTargetType.java | 1 + .../admin/dex/api/AdminBirdController.java | 87 +++ .../request/AdminBirdImagePresignRequest.java | 11 + .../dto/request/AdminBirdUpsertRequest.java | 154 +++++ .../dto/response/AdminBirdDetailResponse.java | 79 +++ .../AdminBirdImagePresignResponse.java | 12 + .../dto/response/AdminBirdListResponse.java | 26 + .../dex/application/AdminBirdService.java | 556 ++++++++++++++++++ .../application/dto/AdminBirdListCommand.java | 10 + .../domain/dex/bird/core/entity/Bird.java | 30 + .../dex/bird/core/entity/BirdHabitat.java | 11 +- .../dex/bird/core/entity/BirdImage.java | 14 + .../repository/BirdHabitatRepository.java | 23 + .../core/repository/BirdImageRepository.java | 30 + .../bird/core/repository/BirdRepository.java | 26 + .../repository/BirdProfileViewRepository.java | 53 +- .../dex/residency/entity/BirdResidency.java | 16 +- .../residency/entity/RarityTypeEntity.java | 2 + .../residency/entity/ResidencyTypeEntity.java | 2 + .../repository/BirdResidencyRepository.java | 37 ++ .../repository/RarityTypeRepository.java | 24 + .../repository/ResidencyTypeRepository.java | 24 + .../global/security/permission/permissions.md | 9 + .../shared/exception/ConflictException.java | 7 + .../exception/GlobalExceptionHandler.java | 8 + .../V94__add_unique_scientific_name.sql | 3 + 27 files changed, 1252 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/AdminBirdController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdImagePresignRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdUpsertRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdDetailResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdImagePresignResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdListResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/dto/AdminBirdListCommand.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdHabitatRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdImageRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/BirdResidencyRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/RarityTypeRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/ResidencyTypeRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/global/shared/exception/ConflictException.java create mode 100644 src/main/resources/db/migration/V94__add_unique_scientific_name.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java index 58642679..b296f18d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java @@ -19,4 +19,6 @@ public enum AdminAuditAction { FREEBOARD_POST_DELETED, FREEBOARD_COMMENT_DELETED, ADMIN_MESSAGE_SENT, + BIRD_CREATED, + BIRD_UPDATED, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java index d9b600ea..63e7208c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java @@ -14,4 +14,5 @@ public enum AdminAuditTargetType { FREEBOARD_POST, FREEBOARD_COMMENT, ADMIN_MESSAGE, + BIRD, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/AdminBirdController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/AdminBirdController.java new file mode 100644 index 00000000..55fdceac --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/AdminBirdController.java @@ -0,0 +1,87 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request.AdminBirdImagePresignRequest; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request.AdminBirdUpsertRequest; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdDetailResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdImagePresignResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdListResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.application.AdminBirdService; +import org.devkor.apu.saerok_server.domain.admin.dex.application.dto.AdminBirdListCommand; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Dex API", description = "도감 관리용 관리자 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/dex/birds") +public class AdminBirdController { + + private final AdminBirdService adminBirdService; + + @GetMapping + @PreAuthorize("@perm.has('ADMIN_LOGIN')") + @Operation(summary = "관리자 도감 목록 조회", security = @SecurityRequirement(name = "bearerAuth")) + public AdminBirdListResponse listBirds( + @RequestParam(required = false) String q, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size + ) { + AdminBirdListCommand command = new AdminBirdListCommand(page, size, q); + if (!command.hasValidPagination()) { + throw new BadRequestException("page와 size 값이 유효하지 않아요."); + } + return adminBirdService.listBirds(command); + } + + @GetMapping("/{birdId}") + @PreAuthorize("@perm.has('ADMIN_LOGIN')") + @Operation(summary = "관리자 도감 상세 조회", security = @SecurityRequirement(name = "bearerAuth")) + public AdminBirdDetailResponse getBird(@PathVariable Long birdId) { + return adminBirdService.getBird(birdId); + } + + @PostMapping + @PreAuthorize("@perm.has('ADMIN_LOGIN')") + @Operation(summary = "관리자 도감 등록", security = @SecurityRequirement(name = "bearerAuth")) + public AdminBirdDetailResponse createBird( + @Valid @RequestBody AdminBirdUpsertRequest request, + @AuthenticationPrincipal UserPrincipal admin + ) { + return adminBirdService.createBird(admin.getId(), request); + } + + @PutMapping("/{birdId}") + @PreAuthorize("@perm.has('ADMIN_LOGIN')") + @Operation(summary = "관리자 도감 수정", security = @SecurityRequirement(name = "bearerAuth")) + public AdminBirdDetailResponse updateBird( + @PathVariable Long birdId, + @Valid @RequestBody AdminBirdUpsertRequest request, + @AuthenticationPrincipal UserPrincipal admin + ) { + return adminBirdService.updateBird(admin.getId(), birdId, request); + } + + @PostMapping("/image/presign") + @PreAuthorize("@perm.has('ADMIN_LOGIN')") + @Operation(summary = "도감 이미지 Presigned URL 발급", security = @SecurityRequirement(name = "bearerAuth")) + public AdminBirdImagePresignResponse generateImagePresignUrl( + @Valid @RequestBody AdminBirdImagePresignRequest request + ) { + return adminBirdService.generateImagePresignUrl(request.contentType()); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdImagePresignRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdImagePresignRequest.java new file mode 100644 index 00000000..8f680e9d --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdImagePresignRequest.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AdminBirdImagePresignRequest( + @Schema(description = "업로드할 도감 이미지 Content-Type", example = "image/jpeg") + @NotBlank(message = "contentType을 입력해 주세요.") + String contentType +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdUpsertRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdUpsertRequest.java new file mode 100644 index 00000000..c8d3d9c6 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/request/AdminBirdUpsertRequest.java @@ -0,0 +1,154 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; + +public record AdminBirdUpsertRequest( + @Valid + @NotNull(message = "이름 정보를 입력해 주세요.") + Name name, + + @Valid + @NotNull(message = "분류 정보를 입력해 주세요.") + Taxonomy taxonomy, + + @Valid + Description description, + + @Schema(description = "체장(cm)", example = "47.5") + @Positive(message = "체장은 0보다 커야 합니다.") + Double bodyLengthCm, + + @Schema(description = "국립생물자원관 URL") + String nibrUrl, + + @NotNull(message = "보호등급을 선택해 주세요.") + ConservationGrade conservationGrade, + + @NotEmpty(message = "서식지를 하나 이상 선택해 주세요.") + List<@NotNull(message = "서식지를 선택해 주세요.") HabitatType> habitats, + + @Valid + @NotEmpty(message = "체류/희귀도 정보를 하나 이상 입력해 주세요.") + List<@NotNull(message = "체류/희귀도 정보를 입력해 주세요.") @Valid Residency> residencies, + + @Valid + @NotEmpty(message = "대표 이미지를 업로드해 주세요.") + List<@NotNull(message = "이미지 정보를 입력해 주세요.") @Valid Image> images +) { + public record Name( + @Schema(description = "국문명", example = "해오라기") + @NotBlank(message = "국문명을 입력해 주세요.") + String koreanName, + + @Schema(description = "학명", example = "Butorides striata") + @NotBlank(message = "학명을 입력해 주세요.") + String scientificName, + + @Schema(description = "학명 명명자", example = "Linnaeus") + String scientificAuthor, + + @Schema(description = "학명 명명년도", example = "1758") + Integer scientificYear + ) { + } + + public record Taxonomy( + @Schema(description = "문 영문명", example = "Chordata") + @NotBlank(message = "문 영문명을 입력해 주세요.") + String phylumEng, + + @Schema(description = "문 국문명", example = "척삭동물문") + @NotBlank(message = "문 국문명을 입력해 주세요.") + String phylumKor, + + @Schema(description = "강 영문명", example = "Aves") + @NotBlank(message = "강 영문명을 입력해 주세요.") + String classEng, + + @Schema(description = "강 국문명", example = "조강") + @NotBlank(message = "강 국문명을 입력해 주세요.") + String classKor, + + @Schema(description = "목 영문명", example = "Pelecaniformes") + @NotBlank(message = "목 영문명을 입력해 주세요.") + String orderEng, + + @Schema(description = "목 국문명", example = "사다새목") + @NotBlank(message = "목 국문명을 입력해 주세요.") + String orderKor, + + @Schema(description = "과 영문명", example = "Ardeidae") + @NotBlank(message = "과 영문명을 입력해 주세요.") + String familyEng, + + @Schema(description = "과 국문명", example = "왜가리과") + @NotBlank(message = "과 국문명을 입력해 주세요.") + String familyKor, + + @Schema(description = "속 영문명", example = "Butorides") + @NotBlank(message = "속 영문명을 입력해 주세요.") + String genusEng, + + @Schema(description = "속 국문명", example = "해오라기속") + @NotBlank(message = "속 국문명을 입력해 주세요.") + String genusKor, + + @Schema(description = "종 영문명", example = "striata") + @NotBlank(message = "종 영문명을 입력해 주세요.") + String speciesEng, + + @Schema(description = "종 국문명", example = "해오라기") + @NotBlank(message = "종 국문명을 입력해 주세요.") + String speciesKor + ) { + } + + public record Description( + @Schema(description = "도감 설명") + String description, + + @Schema(description = "설명 출처") + String source, + + @Schema(description = "AI 생성 설명 여부") + Boolean isAiGenerated + ) { + } + + public record Residency( + @Schema(description = "체류 형태", example = "RESIDENT") + @NotNull(message = "체류 형태를 선택해 주세요.") + org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyType residencyType, + + @Schema(description = "희귀도", example = "COMMON") + @NotNull(message = "희귀도를 선택해 주세요.") + org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityType rarity, + + @Schema(description = "월 비트마스크. 비우면 체류 형태 기본 월 범위를 사용합니다.", example = "4095") + @Min(value = 0, message = "월 비트마스크는 0 이상이어야 합니다.") + @Max(value = 4095, message = "월 비트마스크는 4095 이하여야 합니다.") + Integer monthBitmask + ) { + } + + public record Image( + @Schema(description = "업로드된 도감 이미지 S3 object key", example = "raw/uuid.jpg") + @NotBlank(message = "이미지를 업로드해 주세요.") + String objectKey, + + @Schema(description = "이미지 원본 출처 URL", example = "https://commons.wikimedia.org/...") + @NotBlank(message = "이미지 원본 출처 URL을 입력해 주세요.") + String originalUrl + ) { + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdDetailResponse.java new file mode 100644 index 00000000..1f18f1c1 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdDetailResponse.java @@ -0,0 +1,79 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response; + +import java.time.OffsetDateTime; +import java.util.List; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyType; + +public record AdminBirdDetailResponse( + Long id, + BirdName name, + BirdTaxonomy taxonomy, + BirdDescription description, + Double bodyLengthCm, + String nibrUrl, + ConservationGrade conservationGrade, + List habitats, + List residencies, + List seasonsWithRarity, + List images, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + public record BirdName( + String koreanName, + String scientificName, + String scientificAuthor, + Integer scientificYear + ) { + } + + public record BirdTaxonomy( + String phylumEng, + String phylumKor, + String classEng, + String classKor, + String orderEng, + String orderKor, + String familyEng, + String familyKor, + String genusEng, + String genusKor, + String speciesEng, + String speciesKor + ) { + } + + public record BirdDescription( + String description, + String source, + Boolean isAiGenerated + ) { + } + + public record SeasonWithRarity( + String season, + String rarity, + Integer priority + ) { + } + + public record Residency( + ResidencyType residencyType, + RarityType rarity, + Integer monthBitmask, + Integer effectiveMonthBitmask + ) { + } + + public record Image( + String objectKey, + String imageUrl, + String originalUrl, + Integer orderIndex, + Boolean isThumb + ) { + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdImagePresignResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdImagePresignResponse.java new file mode 100644 index 00000000..c71ca656 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdImagePresignResponse.java @@ -0,0 +1,12 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminBirdImagePresignResponse( + @Schema(description = "클라이언트가 이미지를 PUT 업로드할 Presigned URL") + String presignedUrl, + + @Schema(description = "업로드된 도감 이미지 S3 object key", example = "raw/uuid.jpg") + String objectKey +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdListResponse.java new file mode 100644 index 00000000..554eed1d --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/api/dto/response/AdminBirdListResponse.java @@ -0,0 +1,26 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response; + +import java.time.OffsetDateTime; +import java.util.List; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; + +public record AdminBirdListResponse( + List birds, + int page, + int size, + long totalElements, + int totalPages +) { + public record Item( + Long id, + String koreanName, + String scientificName, + ConservationGrade conservationGrade, + Double bodyLengthCm, + List habitats, + String thumbImageUrl, + OffsetDateTime updatedAt + ) { + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdService.java new file mode 100644 index 00000000..900aa064 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdService.java @@ -0,0 +1,556 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.application; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; +import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request.AdminBirdUpsertRequest; +import org.devkor.apu.saerok_server.domain.admin.dex.application.dto.AdminBirdListCommand; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdDetailResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdImagePresignResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdListResponse; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdHabitat; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdImage; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdHabitatRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdImageRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.query.repository.BirdProfileViewRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.BirdResidency; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityTypeEntity; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyTypeEntity; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.BirdResidencyRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.RarityTypeRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.ResidencyTypeRepository; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.devkor.apu.saerok_server.global.shared.exception.ConflictException; +import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService; +import org.devkor.apu.saerok_server.global.shared.infra.ImageService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class AdminBirdService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final Map IMAGE_EXTENSIONS = Map.of( + "image/jpeg", "jpg", + "image/jpg", "jpg", + "image/png", "png", + "image/webp", "webp", + "image/gif", "gif" + ); + + private final BirdRepository birdRepository; + private final BirdImageRepository birdImageRepository; + private final BirdHabitatRepository birdHabitatRepository; + private final BirdResidencyRepository birdResidencyRepository; + private final ResidencyTypeRepository residencyTypeRepository; + private final RarityTypeRepository rarityTypeRepository; + private final BirdProfileViewRepository birdProfileViewRepository; + private final ImageService imageService; + private final ImageDomainService imageDomainService; + private final UserRepository userRepository; + private final AdminAuditLogRepository adminAuditLogRepository; + private final PlatformTransactionManager transactionManager; + + @Transactional(readOnly = true) + public AdminBirdListResponse listBirds(AdminBirdListCommand command) { + int normalizedPage = normalizePage(command.page()); + int normalizedSize = normalizeSize(command.size()); + String query = command.q(); + long totalElements = birdProfileViewRepository.countAdmin(query); + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / normalizedSize); + + List birds = birdProfileViewRepository + .findAdminPage(query, normalizedPage, normalizedSize) + .stream() + .map(this::toListItem) + .toList(); + + return new AdminBirdListResponse(birds, normalizedPage, normalizedSize, totalElements, totalPages); + } + + @Transactional(readOnly = true) + public AdminBirdDetailResponse getBird(Long birdId) { + BirdProfileView view = birdProfileViewRepository.findById(birdId) + .orElseThrow(() -> new NotFoundException("해당 ID의 도감이 존재하지 않아요.")); + return toDetailResponse(view); + } + + public AdminBirdDetailResponse createBird(Long adminUserId, AdminBirdUpsertRequest request) { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + Long birdId = transaction.execute(status -> persistBird(adminUserId, request)); + if (birdId == null) { + throw new IllegalStateException("도감 등록 트랜잭션 결과가 없습니다."); + } + + birdProfileViewRepository.refreshMaterializedView(); + return getBird(birdId); + } + + public AdminBirdDetailResponse updateBird(Long adminUserId, Long birdId, AdminBirdUpsertRequest request) { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + List removedObjectKeys = transaction.execute(status -> applyUpdate(adminUserId, birdId, request)); + + birdProfileViewRepository.refreshMaterializedView(); + if (removedObjectKeys != null && !removedObjectKeys.isEmpty()) { + imageService.deleteAll(removedObjectKeys); + } + return getBird(birdId); + } + + private List applyUpdate(Long adminUserId, Long birdId, AdminBirdUpsertRequest request) { + validateBirdRequest(request); + Bird bird = birdRepository.findById(birdId) + .orElseThrow(() -> new NotFoundException("해당 ID의 도감이 존재하지 않아요.")); + + String scientificName = requireText(request.name().scientificName(), "학명을 입력해 주세요."); + if (birdRepository.existsActiveByScientificNameExcludingId(scientificName, birdId)) { + throw new ConflictException("이미 등록된 학명입니다: " + scientificName); + } + + User admin = userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 정보를 찾을 수 없어요.")); + + validateImages(request.images()); + + Map previous = new LinkedHashMap<>(); + previous.put("koreanName", bird.getName().getKoreanName()); + previous.put("scientificName", bird.getName().getScientificName()); + previous.put("conservationGrade", bird.getConservationGrade()); + + List oldObjectKeys = birdImageRepository.findObjectKeysByBirdId(birdId); + + bird.update( + toName(request.name()), + toTaxonomy(request.taxonomy()), + toDescription(request.description()), + request.bodyLengthCm(), + optionalHttpUrl(request.nibrUrl(), "NIBR URL은 http 또는 https URL이어야 합니다."), + request.conservationGrade() + ); + + deleteBirdChildren(birdId); + + persistHabitats(bird, request); + persistResidencies(bird, request); + persistImages(bird, request); + + recordAudit(admin, bird, request, AdminAuditAction.BIRD_UPDATED, Map.of("previous", previous)); + + List newObjectKeys = request.images().stream() + .map(image -> requireText(image.objectKey(), "이미지를 업로드해 주세요.")) + .toList(); + return oldObjectKeys.stream() + .filter(key -> !newObjectKeys.contains(key)) + .distinct() + .toList(); + } + + private void deleteBirdChildren(Long birdId) { + birdImageRepository.deleteByBirdId(birdId); + birdResidencyRepository.deleteByBirdId(birdId); + birdHabitatRepository.deleteByBirdId(birdId); + } + + private Long persistBird(Long adminUserId, AdminBirdUpsertRequest request) { + validateBirdRequest(request); + String scientificName = requireText(request.name().scientificName(), "학명을 입력해 주세요."); + if (birdRepository.existsActiveByScientificName(scientificName)) { + throw new ConflictException("이미 등록된 학명입니다: " + scientificName); + } + + User admin = userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 정보를 찾을 수 없어요.")); + + validateImages(request.images()); + Bird bird = Bird.create( + toName(request.name()), + toTaxonomy(request.taxonomy()), + toDescription(request.description()), + request.bodyLengthCm(), + optionalHttpUrl(request.nibrUrl(), "NIBR URL은 http 또는 https URL이어야 합니다."), + request.conservationGrade() + ); + + birdRepository.save(bird); + + persistHabitats(bird, request); + persistResidencies(bird, request); + persistImages(bird, request); + + recordAudit(admin, bird, request, AdminAuditAction.BIRD_CREATED, null); + return bird.getId(); + } + + private void validateBirdRequest(AdminBirdUpsertRequest request) { + if (request == null) { + throw new BadRequestException("도감 정보를 입력해 주세요."); + } + if (request.name() == null) { + throw new BadRequestException("이름 정보를 입력해 주세요."); + } + if (request.taxonomy() == null) { + throw new BadRequestException("분류 정보를 입력해 주세요."); + } + if (request.conservationGrade() == null) { + throw new BadRequestException("보호등급을 선택해 주세요."); + } + if (request.bodyLengthCm() != null && request.bodyLengthCm() <= 0) { + throw new BadRequestException("체장은 0보다 커야 합니다."); + } + } + + public AdminBirdImagePresignResponse generateImagePresignUrl(String contentType) { + String normalizedContentType = normalizeContentType(contentType); + String extension = IMAGE_EXTENSIONS.get(normalizedContentType); + if (extension == null) { + throw new BadRequestException("지원하지 않는 이미지 타입입니다."); + } + + String objectKey = "raw/" + UUID.randomUUID() + "." + extension; + String presignedUrl = imageService.generateUploadUrl(objectKey, normalizedContentType, 10); + return new AdminBirdImagePresignResponse(presignedUrl, objectKey); + } + + private void persistHabitats(Bird bird, AdminBirdUpsertRequest request) { + LinkedHashSet uniqueHabitats = new LinkedHashSet<>(); + if (request.habitats() != null) { + request.habitats().stream() + .filter(Objects::nonNull) + .forEach(uniqueHabitats::add); + } + if (uniqueHabitats.isEmpty()) { + throw new BadRequestException("서식지를 하나 이상 선택해 주세요."); + } + uniqueHabitats.stream() + .filter(Objects::nonNull) + .forEach(habitatType -> birdHabitatRepository.save(BirdHabitat.of(bird, habitatType))); + } + + private void persistResidencies(Bird bird, AdminBirdUpsertRequest request) { + if (request.residencies() == null || request.residencies().isEmpty()) { + throw new BadRequestException("체류/희귀도 정보를 하나 이상 입력해 주세요."); + } + for (AdminBirdUpsertRequest.Residency residency : request.residencies()) { + if (residency == null) { + throw new BadRequestException("체류/희귀도 정보를 입력해 주세요."); + } + ResidencyTypeEntity residencyType = loadResidencyType(residency.residencyType()); + RarityTypeEntity rarityType = loadRarityType(residency.rarity()); + birdResidencyRepository.save(BirdResidency.of(bird, residencyType, rarityType, normalizeMonthBitmask(residency.monthBitmask()))); + } + } + + private void persistImages(Bird bird, AdminBirdUpsertRequest request) { + int orderIndex = 0; + for (AdminBirdUpsertRequest.Image image : request.images()) { + String objectKey = requireText(image.objectKey(), "이미지를 업로드해 주세요."); + String originalUrl = requireHttpUrl(image.originalUrl(), "이미지 원본 출처 URL은 http 또는 https URL이어야 합니다."); + birdImageRepository.save(BirdImage.of(bird, objectKey, originalUrl, orderIndex, orderIndex == 0)); + orderIndex++; + } + } + + private void validateImages(List images) { + if (images == null || images.isEmpty()) { + throw new BadRequestException("대표 이미지를 업로드해 주세요."); + } + + LinkedHashSet uniqueObjectKeys = new LinkedHashSet<>(); + for (AdminBirdUpsertRequest.Image image : images) { + if (image == null) { + throw new BadRequestException("이미지 정보를 입력해 주세요."); + } + String objectKey = requireText(image.objectKey(), "이미지를 업로드해 주세요."); + requireHttpUrl(image.originalUrl(), "이미지 원본 출처 URL은 http 또는 https URL이어야 합니다."); + if (!uniqueObjectKeys.add(objectKey)) { + throw new BadRequestException("같은 이미지를 중복으로 등록할 수 없어요."); + } + if (!imageService.exists(objectKey)) { + throw new BadRequestException("이미지 업로드가 완료되지 않았습니다."); + } + } + } + + private ResidencyTypeEntity loadResidencyType(ResidencyType residencyType) { + if (residencyType == null) { + throw new BadRequestException("체류 형태를 선택해 주세요."); + } + return residencyTypeRepository.findByCode(residencyType) + .orElseThrow(() -> new BadRequestException("체류 형태 값이 유효하지 않아요.")); + } + + private RarityTypeEntity loadRarityType(RarityType rarityType) { + if (rarityType == null) { + throw new BadRequestException("희귀도를 선택해 주세요."); + } + return rarityTypeRepository.findByCode(rarityType) + .orElseThrow(() -> new BadRequestException("희귀도 값이 유효하지 않아요.")); + } + + private BirdName toName(AdminBirdUpsertRequest.Name request) { + BirdName name = new BirdName(); + name.setKoreanName(requireText(request.koreanName(), "국문명을 입력해 주세요.")); + name.setScientificName(requireText(request.scientificName(), "학명을 입력해 주세요.")); + name.setScientificAuthor(trimToNull(request.scientificAuthor())); + name.setScientificYear(request.scientificYear()); + return name; + } + + private BirdTaxonomy toTaxonomy(AdminBirdUpsertRequest.Taxonomy request) { + BirdTaxonomy taxonomy = new BirdTaxonomy(); + taxonomy.setPhylumEng(requireText(request.phylumEng(), "문 영문명을 입력해 주세요.")); + taxonomy.setPhylumKor(requireText(request.phylumKor(), "문 국문명을 입력해 주세요.")); + taxonomy.setClassEng(requireText(request.classEng(), "강 영문명을 입력해 주세요.")); + taxonomy.setClassKor(requireText(request.classKor(), "강 국문명을 입력해 주세요.")); + taxonomy.setOrderEng(requireText(request.orderEng(), "목 영문명을 입력해 주세요.")); + taxonomy.setOrderKor(requireText(request.orderKor(), "목 국문명을 입력해 주세요.")); + taxonomy.setFamilyEng(requireText(request.familyEng(), "과 영문명을 입력해 주세요.")); + taxonomy.setFamilyKor(requireText(request.familyKor(), "과 국문명을 입력해 주세요.")); + taxonomy.setGenusEng(requireText(request.genusEng(), "속 영문명을 입력해 주세요.")); + taxonomy.setGenusKor(requireText(request.genusKor(), "속 국문명을 입력해 주세요.")); + taxonomy.setSpeciesEng(requireText(request.speciesEng(), "종 영문명을 입력해 주세요.")); + taxonomy.setSpeciesKor(requireText(request.speciesKor(), "종 국문명을 입력해 주세요.")); + return taxonomy; + } + + private BirdDescription toDescription(AdminBirdUpsertRequest.Description request) { + BirdDescription description = new BirdDescription(); + if (request == null) { + return description; + } + description.setDescription(trimToNull(request.description())); + description.setSource(trimToNull(request.source())); + description.setIsAiGenerated(request.isAiGenerated()); + return description; + } + + private AdminBirdListResponse.Item toListItem(BirdProfileView view) { + return new AdminBirdListResponse.Item( + view.getId(), + view.getName().getKoreanName(), + view.getName().getScientificName(), + view.getConservationGrade(), + view.getBodyLengthCm(), + view.getHabitats() == null ? List.of() : view.getHabitats(), + representativeImageUrl(view), + view.getUpdatedAt() + ); + } + + private AdminBirdDetailResponse toDetailResponse(BirdProfileView view) { + var name = view.getName(); + var taxonomy = view.getTaxonomy(); + var description = view.getDescription(); + + return new AdminBirdDetailResponse( + view.getId(), + new AdminBirdDetailResponse.BirdName( + name.getKoreanName(), + name.getScientificName(), + name.getScientificAuthor(), + name.getScientificYear() + ), + new AdminBirdDetailResponse.BirdTaxonomy( + taxonomy.getPhylumEng(), + taxonomy.getPhylumKor(), + taxonomy.getClassEng(), + taxonomy.getClassKor(), + taxonomy.getOrderEng(), + taxonomy.getOrderKor(), + taxonomy.getFamilyEng(), + taxonomy.getFamilyKor(), + taxonomy.getGenusEng(), + taxonomy.getGenusKor(), + taxonomy.getSpeciesEng(), + taxonomy.getSpeciesKor() + ), + new AdminBirdDetailResponse.BirdDescription( + description == null ? null : description.getDescription(), + description == null ? null : description.getSource(), + description == null ? null : description.getIsAiGenerated() + ), + view.getBodyLengthCm(), + view.getNibrUrl(), + view.getConservationGrade(), + view.getHabitats() == null ? List.of() : view.getHabitats(), + findResidencies(view.getId()), + view.getSeasonsWithRarity() == null ? List.of() : view.getSeasonsWithRarity().stream() + .map(season -> new AdminBirdDetailResponse.SeasonWithRarity( + season.getSeason(), + season.getRarity(), + season.getPriority() + )) + .toList(), + view.getImages() == null ? List.of() : view.getImages().stream() + .map(image -> new AdminBirdDetailResponse.Image( + image.getObjectKey(), + image.getObjectKey() == null ? null : imageDomainService.toDexImageUrl(image.getObjectKey()), + image.getOriginalUrl(), + image.getOrderIndex(), + image.getIsThumb() + )) + .toList(), + view.getCreatedAt(), + view.getUpdatedAt() + ); + } + + private List findResidencies(Long birdId) { + return birdResidencyRepository.findByBirdIdWithTypes(birdId) + .stream() + .map(residency -> { + Integer monthBitmask = residency.getMonthBitmask(); + int effectiveMonthBitmask = monthBitmask != null + ? monthBitmask + : residency.getResidencyTypeEntity().getMonthBitmask(); + return new AdminBirdDetailResponse.Residency( + residency.getResidencyTypeEntity().getCode(), + residency.getRarityTypeEntity().getCode(), + monthBitmask, + effectiveMonthBitmask + ); + }) + .toList(); + } + + private String representativeImageUrl(BirdProfileView view) { + if (view.getImages() == null || view.getImages().isEmpty()) { + return null; + } + return view.getImages().stream() + .filter(image -> Boolean.TRUE.equals(image.getIsThumb())) + .findFirst() + .or(() -> view.getImages().stream().findFirst()) + .map(BirdProfileView.Image::getObjectKey) + .filter(StringUtils::hasText) + .map(imageDomainService::toDexImageUrl) + .orElse(null); + } + + private void recordAudit(User admin, + Bird bird, + AdminBirdUpsertRequest request, + AdminAuditAction action, + Map extraMetadata) { + Map metadata = new LinkedHashMap<>(); + metadata.put("koreanName", bird.getName().getKoreanName()); + metadata.put("scientificName", bird.getName().getScientificName()); + metadata.put("conservationGrade", bird.getConservationGrade()); + metadata.put("habitats", request.habitats()); + metadata.put("imageCount", request.images() == null ? 0 : request.images().size()); + if (extraMetadata != null) { + metadata.putAll(extraMetadata); + } + + adminAuditLogRepository.save(AdminAuditLog.of( + admin, + action, + AdminAuditTargetType.BIRD, + bird.getId(), + null, + metadata + )); + } + + private int normalizePage(Integer page) { + if (page == null) { + return 1; + } + if (page < 1) { + throw new BadRequestException("page 값이 유효하지 않아요."); + } + return page; + } + + private int normalizeSize(Integer size) { + if (size == null) { + return DEFAULT_PAGE_SIZE; + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BadRequestException("size 값이 유효하지 않아요."); + } + return size; + } + + private String requireText(String value, String message) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw new BadRequestException(message); + } + return trimmed; + } + + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private Integer normalizeMonthBitmask(Integer monthBitmask) { + if (monthBitmask == null) { + return null; + } + if (monthBitmask < 0 || monthBitmask > 4095) { + throw new BadRequestException("월 비트마스크는 0 이상 4095 이하로 입력해 주세요."); + } + return monthBitmask; + } + + private String normalizeContentType(String contentType) { + String trimmed = requireText(contentType, "contentType을 입력해 주세요."); + int semicolon = trimmed.indexOf(';'); + if (semicolon >= 0) { + trimmed = trimmed.substring(0, semicolon); + } + return trimmed.trim().toLowerCase(Locale.ROOT); + } + + private String optionalHttpUrl(String value, String message) { + String trimmed = trimToNull(value); + return trimmed == null ? null : requireHttpUrl(trimmed, message); + } + + private String requireHttpUrl(String value, String message) { + String trimmed = requireText(value, message); + try { + URI uri = new URI(trimmed); + String scheme = uri.getScheme(); + if (("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && StringUtils.hasText(uri.getHost())) { + return trimmed; + } + } catch (URISyntaxException ignored) { + // Fall through to the domain error below. + } + throw new BadRequestException(message); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/dto/AdminBirdListCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/dto/AdminBirdListCommand.java new file mode 100644 index 00000000..5e339936 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/dex/application/dto/AdminBirdListCommand.java @@ -0,0 +1,10 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.application.dto; + +import org.devkor.apu.saerok_server.global.shared.util.Pageable; + +public record AdminBirdListCommand( + Integer page, + Integer size, + String q +) implements Pageable { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java index fc254471..95449f36 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java @@ -39,6 +39,36 @@ public class Bird extends SoftDeletableAuditable implements HasBodyLength { @OneToMany(mappedBy = "bird", cascade = CascadeType.ALL, orphanRemoval = true) private List images; + public static Bird create(BirdName name, + BirdTaxonomy taxonomy, + BirdDescription description, + Double bodyLengthCm, + String nibrUrl, + ConservationGrade conservationGrade) { + Bird bird = new Bird(); + bird.name = name; + bird.taxonomy = taxonomy; + bird.description = description; + bird.bodyLengthCm = bodyLengthCm; + bird.nibrUrl = nibrUrl; + bird.conservationGrade = conservationGrade == null ? ConservationGrade.NONE : conservationGrade; + return bird; + } + + public void update(BirdName name, + BirdTaxonomy taxonomy, + BirdDescription description, + Double bodyLengthCm, + String nibrUrl, + ConservationGrade conservationGrade) { + this.name = name; + this.taxonomy = taxonomy; + this.description = description; + this.bodyLengthCm = bodyLengthCm; + this.nibrUrl = nibrUrl; + this.conservationGrade = conservationGrade == null ? ConservationGrade.NONE : conservationGrade; + } + @Override public Double getBodyLengthCm() { return bodyLengthCm; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdHabitat.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdHabitat.java index 1d321767..f033ac37 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdHabitat.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdHabitat.java @@ -1,11 +1,11 @@ package org.devkor.apu.saerok_server.domain.dex.bird.core.entity; import jakarta.persistence.*; +import lombok.Getter; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; @Entity +@Getter @Table( uniqueConstraints = @UniqueConstraint( columnNames = {"bird_id", "habitat_type"} @@ -24,4 +24,11 @@ public class BirdHabitat { @Enumerated(EnumType.STRING) @Column(name = "habitat_type") private HabitatType habitatType; + + public static BirdHabitat of(Bird bird, HabitatType habitatType) { + BirdHabitat habitat = new BirdHabitat(); + habitat.bird = bird; + habitat.habitatType = habitatType; + return habitat; + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdImage.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdImage.java index 5d903cd3..32bf32df 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdImage.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/BirdImage.java @@ -27,4 +27,18 @@ public class BirdImage extends Auditable { @Column(name = "is_thumb", nullable = false) private boolean isThumb; + + public static BirdImage of(Bird bird, + String objectKey, + String originalUrl, + int orderIndex, + boolean isThumb) { + BirdImage image = new BirdImage(); + image.bird = bird; + image.objectKey = objectKey; + image.originalUrl = originalUrl; + image.orderIndex = orderIndex; + image.isThumb = isThumb; + return image; + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdHabitatRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdHabitatRepository.java new file mode 100644 index 00000000..d0c84656 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdHabitatRepository.java @@ -0,0 +1,23 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.core.repository; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdHabitat; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BirdHabitatRepository { + + private final EntityManager em; + + public void save(BirdHabitat habitat) { + em.persist(habitat); + } + + public void deleteByBirdId(Long birdId) { + em.createQuery("DELETE FROM BirdHabitat h WHERE h.bird.id = :birdId") + .setParameter("birdId", birdId) + .executeUpdate(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdImageRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdImageRepository.java new file mode 100644 index 00000000..18d80309 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdImageRepository.java @@ -0,0 +1,30 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.core.repository; + +import jakarta.persistence.EntityManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdImage; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BirdImageRepository { + + private final EntityManager em; + + public void save(BirdImage image) { + em.persist(image); + } + + public void deleteByBirdId(Long birdId) { + em.createQuery("DELETE FROM BirdImage i WHERE i.bird.id = :birdId") + .setParameter("birdId", birdId) + .executeUpdate(); + } + + public List findObjectKeysByBirdId(Long birdId) { + return em.createQuery("SELECT i.objectKey FROM BirdImage i WHERE i.bird.id = :birdId", String.class) + .setParameter("birdId", birdId) + .getResultList(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepository.java index bc7cc68e..b7d21f3d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepository.java @@ -27,6 +27,32 @@ public Optional findById(Long id) { .findFirst(); } + public Bird save(Bird bird) { + em.persist(bird); + return bird; + } + + public boolean existsActiveByScientificName(String scientificName) { + return existsActiveByScientificNameExcludingId(scientificName, null); + } + + public boolean existsActiveByScientificNameExcludingId(String scientificName, Long excludedId) { + if (scientificName == null || scientificName.isBlank()) { + return false; + } + String normalized = scientificName.trim().toLowerCase(Locale.ROOT); + return em.createQuery(""" + SELECT COUNT(b) + FROM Bird b + WHERE b.deletedAt IS NULL + AND LOWER(TRIM(b.name.scientificName)) = :scientificName + AND (:excludedId IS NULL OR b.id <> :excludedId) + """, Long.class) + .setParameter("scientificName", normalized) + .setParameter("excludedId", excludedId) + .getSingleResult() > 0; + } + /** * 검색–PostgreSQL 네이티브 쿼리 */ diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepository.java index 34c1ab17..066bc64c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepository.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -13,6 +14,7 @@ @Repository @RequiredArgsConstructor +@Slf4j public class BirdProfileViewRepository { private final EntityManager em; @@ -26,6 +28,51 @@ public List findAll() { return em.createQuery("SELECT b FROM BirdProfileView b", BirdProfileView.class).getResultList(); } + public List findAdminPage(String query, int page, int size) { + String jpql = """ + SELECT b FROM BirdProfileView b + WHERE b.deletedAt IS NULL + """; + if (query != null && !query.isBlank()) { + jpql += """ + AND ( + LOWER(b.name.koreanName) LIKE :query + OR LOWER(b.name.scientificName) LIKE :query + ) + """; + } + jpql += " ORDER BY b.updatedAt DESC, b.id DESC"; + + var typedQuery = em.createQuery(jpql, BirdProfileView.class) + .setFirstResult((page - 1) * size) + .setMaxResults(size); + if (query != null && !query.isBlank()) { + typedQuery.setParameter("query", "%" + query.trim().toLowerCase() + "%"); + } + return typedQuery.getResultList(); + } + + public long countAdmin(String query) { + String jpql = """ + SELECT COUNT(b) FROM BirdProfileView b + WHERE b.deletedAt IS NULL + """; + if (query != null && !query.isBlank()) { + jpql += """ + AND ( + LOWER(b.name.koreanName) LIKE :query + OR LOWER(b.name.scientificName) LIKE :query + ) + """; + } + + var typedQuery = em.createQuery(jpql, Long.class); + if (query != null && !query.isBlank()) { + typedQuery.setParameter("query", "%" + query.trim().toLowerCase() + "%"); + } + return typedQuery.getSingleResult(); + } + public List findByCreatedAtAfter(OffsetDateTime since) { return em.createQuery("SELECT b FROM BirdProfileView b WHERE b.createdAt > :since " + "AND b.deletedAt IS NULL", BirdProfileView.class) @@ -76,7 +123,9 @@ public List findKoreanNamesByKeyword(String keyword) { @Transactional public void refreshMaterializedView() { - em.createNativeQuery("REFRESH MATERIALIZED VIEW bird_profile_mv") + long startedAt = System.nanoTime(); + em.createNativeQuery("REFRESH MATERIALIZED VIEW CONCURRENTLY bird_profile_mv") .executeUpdate(); + log.info("Refreshed bird_profile_mv concurrently in {} ms", (System.nanoTime() - startedAt) / 1_000_000); } -} \ No newline at end of file +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/BirdResidency.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/BirdResidency.java index 1652d92f..8ef42e45 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/BirdResidency.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/BirdResidency.java @@ -1,14 +1,16 @@ package org.devkor.apu.saerok_server.domain.dex.residency.entity; import jakarta.persistence.*; +import lombok.Getter; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.global.shared.entity.Auditable; @Entity +@Getter public class BirdResidency extends Auditable { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id; @ManyToOne(fetch = FetchType.LAZY) @@ -25,4 +27,16 @@ public class BirdResidency extends Auditable { @Column(name = "month_bitmask") private Integer monthBitmask; + + public static BirdResidency of(Bird bird, + ResidencyTypeEntity residencyTypeEntity, + RarityTypeEntity rarityTypeEntity, + Integer monthBitmask) { + BirdResidency residency = new BirdResidency(); + residency.bird = bird; + residency.residencyTypeEntity = residencyTypeEntity; + residency.rarityTypeEntity = rarityTypeEntity; + residency.monthBitmask = monthBitmask; + return residency; + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/RarityTypeEntity.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/RarityTypeEntity.java index 4f544e09..e0f4d492 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/RarityTypeEntity.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/RarityTypeEntity.java @@ -1,11 +1,13 @@ package org.devkor.apu.saerok_server.domain.dex.residency.entity; import jakarta.persistence.*; +import lombok.Getter; import org.hibernate.annotations.Immutable; @Entity @Immutable @Table(name = "rarity_type") +@Getter public class RarityTypeEntity { @Id diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/ResidencyTypeEntity.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/ResidencyTypeEntity.java index a34e0c77..d3539b8a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/ResidencyTypeEntity.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/entity/ResidencyTypeEntity.java @@ -1,11 +1,13 @@ package org.devkor.apu.saerok_server.domain.dex.residency.entity; import jakarta.persistence.*; +import lombok.Getter; import org.hibernate.annotations.Immutable; @Entity @Immutable @Table(name = "residency_type") +@Getter public class ResidencyTypeEntity { @Id diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/BirdResidencyRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/BirdResidencyRepository.java new file mode 100644 index 00000000..a7eb2942 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/BirdResidencyRepository.java @@ -0,0 +1,37 @@ +package org.devkor.apu.saerok_server.domain.dex.residency.repository; + +import jakarta.persistence.EntityManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.BirdResidency; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BirdResidencyRepository { + + private final EntityManager em; + + public void save(BirdResidency residency) { + em.persist(residency); + } + + public void deleteByBirdId(Long birdId) { + em.createQuery("DELETE FROM BirdResidency r WHERE r.bird.id = :birdId") + .setParameter("birdId", birdId) + .executeUpdate(); + } + + public List findByBirdIdWithTypes(Long birdId) { + return em.createQuery(""" + SELECT r + FROM BirdResidency r + JOIN FETCH r.residencyTypeEntity + JOIN FETCH r.rarityTypeEntity + WHERE r.bird.id = :birdId + ORDER BY r.id ASC + """, BirdResidency.class) + .setParameter("birdId", birdId) + .getResultList(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/RarityTypeRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/RarityTypeRepository.java new file mode 100644 index 00000000..d877d5a4 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/RarityTypeRepository.java @@ -0,0 +1,24 @@ +package org.devkor.apu.saerok_server.domain.dex.residency.repository; + +import jakarta.persistence.EntityManager; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityTypeEntity; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RarityTypeRepository { + + private final EntityManager em; + + public Optional findByCode(RarityType code) { + return em.createQuery( + "SELECT r FROM RarityTypeEntity r WHERE r.code = :code", + RarityTypeEntity.class) + .setParameter("code", code) + .getResultStream() + .findFirst(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/ResidencyTypeRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/ResidencyTypeRepository.java new file mode 100644 index 00000000..c32b0652 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/residency/repository/ResidencyTypeRepository.java @@ -0,0 +1,24 @@ +package org.devkor.apu.saerok_server.domain.dex.residency.repository; + +import jakarta.persistence.EntityManager; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyTypeEntity; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ResidencyTypeRepository { + + private final EntityManager em; + + public Optional findByCode(ResidencyType code) { + return em.createQuery( + "SELECT r FROM ResidencyTypeEntity r WHERE r.code = :code", + ResidencyTypeEntity.class) + .setParameter("code", code) + .getResultStream() + .findFirst(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md index 1bfc48fa..76b425b8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md +++ b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md @@ -98,6 +98,15 @@ - AdminAnnouncementController.deleteAnnouncement() - AdminAnnouncementController.generateImagePresignUrl() +## 도감 (Dex) + +- 도감 관리(조회/등록/이미지 발급)는 별도 권한 없이 ADMIN_LOGIN 보유 어드민이면 누구나 가능 + - 관련 메서드: + - AdminBirdController.listBirds() + - AdminBirdController.getBird() + - AdminBirdController.createBird() + - AdminBirdController.generateImagePresignUrl() + ## 관리자 역할 관리 (Role Management) - KEY: ADMIN_ROLE_MY_READ diff --git a/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/ConflictException.java b/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/ConflictException.java new file mode 100644 index 00000000..bf57a85b --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/ConflictException.java @@ -0,0 +1,7 @@ +package org.devkor.apu.saerok_server.global.shared.exception; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/GlobalExceptionHandler.java b/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/GlobalExceptionHandler.java index 7666cb3c..fde1068d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/shared/exception/GlobalExceptionHandler.java @@ -34,6 +34,14 @@ public ResponseEntity handleNotFoundException(NotFoundException e .body(error); } + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflictException(ConflictException ex) { + ErrorResponse error = new ErrorResponse(HttpStatus.CONFLICT.value(), ex.getMessage()); + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(error); + } + @ExceptionHandler(ForbiddenException.class) public ResponseEntity handleForbiddenException(ForbiddenException ex) { ErrorResponse error = new ErrorResponse(HttpStatus.FORBIDDEN.value(), ex.getMessage()); diff --git a/src/main/resources/db/migration/V94__add_unique_scientific_name.sql b/src/main/resources/db/migration/V94__add_unique_scientific_name.sql new file mode 100644 index 00000000..994635ca --- /dev/null +++ b/src/main/resources/db/migration/V94__add_unique_scientific_name.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS uq_bird_scientific_name_active_ci + ON bird (LOWER(BTRIM(scientific_name))) + WHERE deleted_at IS NULL; From f3580e6c4195cf95e3de97706fcfa02879b20d15 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 3 Jul 2026 18:52:13 +0900 Subject: [PATCH 05/15] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminBirdServiceIntegrationTest.java | 454 ++++++++++++++++++ .../BirdIdSuggestionRepositoryTest.java | 18 +- .../repository/BookmarkRepositoryTest.java | 2 +- .../UserDeviceLifecycleScenarioTest.java | 179 +++++++ .../testsupport/builder/BirdBuilder.java | 7 +- 5 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdServiceIntegrationTest.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceLifecycleScenarioTest.java diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdServiceIntegrationTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdServiceIntegrationTest.java new file mode 100644 index 00000000..50fdf014 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/dex/application/AdminBirdServiceIntegrationTest.java @@ -0,0 +1,454 @@ +package org.devkor.apu.saerok_server.domain.admin.dex.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; +import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.request.AdminBirdUpsertRequest; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdDetailResponse; +import org.devkor.apu.saerok_server.domain.admin.dex.api.dto.response.AdminBirdImagePresignResponse; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdHabitatRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdImageRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.query.repository.BirdProfileViewRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.BirdResidencyRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.RarityTypeRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.repository.ResidencyTypeRepository; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.RarityType; +import org.devkor.apu.saerok_server.domain.dex.residency.entity.ResidencyType; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.devkor.apu.saerok_server.global.shared.exception.ConflictException; +import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService; +import org.devkor.apu.saerok_server.global.shared.infra.ImageService; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +@DataJpaTest +@Import({ + AdminBirdService.class, + BirdRepository.class, + BirdImageRepository.class, + BirdHabitatRepository.class, + BirdResidencyRepository.class, + ResidencyTypeRepository.class, + RarityTypeRepository.class, + BirdProfileViewRepository.class, + UserRepository.class, + AdminAuditLogRepository.class, + ImageDomainService.class, + AdminBirdServiceIntegrationTest.ImageServiceTestConfig.class +}) +@ActiveProfiles("test") +@TestPropertySource(properties = { + "aws.cloudfront.dex-image-domain=https://dex-cdn.example", + "aws.cloudfront.upload-image-domain=https://upload-cdn.example" +}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class AdminBirdServiceIntegrationTest extends AbstractPostgresContainerTest { + + @Autowired + AdminBirdService service; + + @Autowired + AdminAuditLogRepository adminAuditLogRepository; + + @Autowired + TestEntityManager testEm; + + @Autowired + TestImageService imageService; + + @Autowired + BirdProfileViewRepository birdProfileViewRepository; + + @Autowired + PlatformTransactionManager transactionManager; + + private final Set createdAdminUserIds = new HashSet<>(); + + @AfterEach + void cleanUpCommittedFixtures() { + boolean[] deletedBird = {false}; + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + EntityManager entityManager = testEm.getEntityManager(); + for (Long adminUserId : createdAdminUserIds) { + @SuppressWarnings("unchecked") + List birdIds = entityManager.createNativeQuery(""" + SELECT target_id + FROM admin_audit_log + WHERE admin_user_id = :adminUserId + AND target_type = 'BIRD' + AND target_id IS NOT NULL + """) + .setParameter("adminUserId", adminUserId) + .getResultList(); + for (Number birdId : birdIds) { + deleteBirdRelations(entityManager, birdId.longValue()); + deletedBird[0] = true; + } + entityManager.createNativeQuery("DELETE FROM admin_audit_log WHERE admin_user_id = :adminUserId") + .setParameter("adminUserId", adminUserId) + .executeUpdate(); + entityManager.createNativeQuery("DELETE FROM users WHERE id = :adminUserId") + .setParameter("adminUserId", adminUserId) + .executeUpdate(); + } + }); + if (deletedBird[0]) { + birdProfileViewRepository.refreshMaterializedView(); + } + createdAdminUserIds.clear(); + imageService.reset(); + } + + @Test + void createBird_persistsBirdRelationsRefreshesMaterializedViewAndRecordsAudit() { + Long adminUserId = persistAdminUser(); + String scientificName = uniqueScientificName(); + String objectKey = issueUploadedImage(); + + AdminBirdDetailResponse response = service.createBird(adminUserId, createRequest(scientificName, objectKey)); + + assertThat(response.id()).isNotNull(); + assertThat(response.name().scientificName()).isEqualTo(scientificName); + assertThat(response.habitats()).containsExactly(HabitatType.RIVER_LAKE); + assertThat(response.images()).hasSize(1); + assertThat(response.images().getFirst().objectKey()).isEqualTo(objectKey); + assertThat(response.images().getFirst().imageUrl()).isEqualTo("https://dex-cdn.example/" + objectKey); + assertThat(response.residencies()).singleElement().satisfies(residency -> { + assertThat(residency.residencyType()).isEqualTo(ResidencyType.RESIDENT); + assertThat(residency.rarity()).isEqualTo(RarityType.COMMON); + assertThat(residency.monthBitmask()).isEqualTo(4095); + assertThat(residency.effectiveMonthBitmask()).isEqualTo(4095); + }); + assertThat(countRows("bird_habitat", response.id())).isEqualTo(1L); + assertThat(countRows("bird_residency", response.id())).isEqualTo(1L); + assertThat(countRows("bird_image", response.id())).isEqualTo(1L); + + var auditLogs = adminAuditLogRepository.findAllOrderByCreatedAtDesc(); + assertThat(auditLogs).anySatisfy(log -> { + assertThat(log.getAction()).isEqualTo(AdminAuditAction.BIRD_CREATED); + assertThat(log.getTargetType()).isEqualTo(AdminAuditTargetType.BIRD); + assertThat(log.getTargetId()).isEqualTo(response.id()); + }); + } + + @Test + void updateBird_replacesRelationsAndImagesDeletesOldObjectAndRecordsAudit() { + Long adminUserId = persistAdminUser(); + String scientificName = uniqueScientificName(); + String oldObjectKey = issueUploadedImage(); + AdminBirdDetailResponse created = service.createBird(adminUserId, createRequest(scientificName, oldObjectKey)); + + String newObjectKey = issueUploadedImage(); + AdminBirdUpsertRequest updateRequest = createRequest( + scientificName, // 자기 자신은 학명 중복으로 막히면 안 됨 + List.of(HabitatType.FOREST), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.SUMMER, RarityType.RARE, 4095)), + List.of(new AdminBirdUpsertRequest.Image(newObjectKey, "https://source.example/updated")) + ); + + AdminBirdDetailResponse updated = service.updateBird(adminUserId, created.id(), updateRequest); + + assertThat(updated.id()).isEqualTo(created.id()); + assertThat(updated.habitats()).containsExactly(HabitatType.FOREST); + assertThat(updated.images()).singleElement().satisfies(image -> { + assertThat(image.objectKey()).isEqualTo(newObjectKey); + assertThat(image.originalUrl()).isEqualTo("https://source.example/updated"); + }); + assertThat(updated.residencies()).singleElement().satisfies(residency -> { + assertThat(residency.residencyType()).isEqualTo(ResidencyType.SUMMER); + assertThat(residency.rarity()).isEqualTo(RarityType.RARE); + }); + // 관계는 append가 아니라 전량 교체되어야 함 + assertThat(countRows("bird_habitat", created.id())).isEqualTo(1L); + assertThat(countRows("bird_residency", created.id())).isEqualTo(1L); + assertThat(countRows("bird_image", created.id())).isEqualTo(1L); + // 더 이상 참조되지 않는 이전 이미지 객체는 S3에서 삭제 + assertThat(imageService.deletedObjectKeys).contains(oldObjectKey); + + var auditLogs = adminAuditLogRepository.findAllOrderByCreatedAtDesc(); + assertThat(auditLogs).anySatisfy(log -> { + assertThat(log.getAction()).isEqualTo(AdminAuditAction.BIRD_UPDATED); + assertThat(log.getTargetType()).isEqualTo(AdminAuditTargetType.BIRD); + assertThat(log.getTargetId()).isEqualTo(created.id()); + }); + } + + @Test + void updateBird_rejectsDuplicateScientificNameOfAnotherBird() { + Long adminUserId = persistAdminUser(); + String otherScientificName = uniqueScientificName(); + service.createBird(adminUserId, createRequest(otherScientificName, issueUploadedImage())); + AdminBirdDetailResponse target = service.createBird( + adminUserId, createRequest(uniqueScientificName(), issueUploadedImage())); + + assertThatThrownBy(() -> service.updateBird(adminUserId, target.id(), + createRequest(otherScientificName, issueUploadedImage()))) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("이미 등록된 학명입니다"); + } + + @Test + void createBird_rejectsDuplicateScientificNameCaseInsensitively() { + Long adminUserId = persistAdminUser(); + String scientificName = uniqueScientificName(); + service.createBird(adminUserId, createRequest(scientificName, issueUploadedImage())); + + assertThatThrownBy(() -> service.createBird( + adminUserId, + createRequest(" " + scientificName.toUpperCase() + " ", issueUploadedImage()))) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("이미 등록된 학명입니다"); + } + + @Test + void createBird_rejectsMissingHabitatImageAndInvalidMonthBitmask() { + Long adminUserId = persistAdminUser(); + String objectKey = issueUploadedImage(); + + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, 4095)), + List.of(new AdminBirdUpsertRequest.Image(objectKey, "https://source.example/bird")) + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("서식지"); + + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, 4095)), + List.of() + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("대표 이미지"); + + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, 4096)), + List.of(new AdminBirdUpsertRequest.Image(objectKey, "https://source.example/bird")) + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("월 비트마스크"); + } + + @Test + void generateImagePresignUrl_returnsDexRawObjectKey() { + AdminBirdImagePresignResponse response = service.generateImagePresignUrl("image/png; charset=binary"); + + assertThat(response.presignedUrl()).startsWith("https://s3.example/raw/"); + assertThat(response.objectKey()).matches("raw/[0-9a-f\\-]{36}\\.png"); + } + + @Test + void createBird_rejectsInvalidImageRowsUrlsAndUnissuedObjects() { + Long adminUserId = persistAdminUser(); + + List nullResidency = new ArrayList<>(); + nullResidency.add(null); + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + nullResidency, + List.of(new AdminBirdUpsertRequest.Image(issueUploadedImage(), "https://source.example/bird")) + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("체류/희귀도 정보"); + + List nullImage = new ArrayList<>(); + nullImage.add(null); + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, null)), + nullImage + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("이미지 정보"); + + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, null)), + List.of(new AdminBirdUpsertRequest.Image("raw/not-uploaded.webp", "https://source.example/bird")) + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("완료되지 않았습니다"); + + String objectKey = issueUploadedImage(); + assertThatThrownBy(() -> service.createBird(adminUserId, createRequest( + uniqueScientificName(), + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, null)), + List.of(new AdminBirdUpsertRequest.Image(objectKey, "javascript:alert(1)")) + ))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("http 또는 https"); + } + + @Test + void createBird_rejectsIncompleteUpload() { + Long adminUserId = persistAdminUser(); + + AdminBirdImagePresignResponse incomplete = service.generateImagePresignUrl("image/webp"); + assertThatThrownBy(() -> service.createBird( + adminUserId, + createRequest(uniqueScientificName(), incomplete.objectKey()))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("완료되지 않았습니다"); + } + + private Long persistAdminUser() { + Long adminUserId = new TransactionTemplate(transactionManager).execute(status -> { + String suffix = Long.toString(System.nanoTime()); + User admin = User.createUser("admin-" + suffix + "@saerok.app"); + admin.setNickname("운영자-" + suffix); + testEm.persist(admin); + testEm.flush(); + return admin.getId(); + }); + createdAdminUserIds.add(adminUserId); + return adminUserId; + } + + private AdminBirdUpsertRequest createRequest(String scientificName, String objectKey) { + return createRequest( + scientificName, + List.of(HabitatType.RIVER_LAKE), + List.of(new AdminBirdUpsertRequest.Residency(ResidencyType.RESIDENT, RarityType.COMMON, 4095)), + List.of(new AdminBirdUpsertRequest.Image(objectKey, "https://source.example/bird")) + ); + } + + private AdminBirdUpsertRequest createRequest(String scientificName, + List habitats, + List residencies, + List images) { + return new AdminBirdUpsertRequest( + new AdminBirdUpsertRequest.Name("통합테스트새", scientificName, "Linnaeus", 1758), + new AdminBirdUpsertRequest.Taxonomy( + "Chordata", + "척삭동물문", + "Aves", + "조강", + "Pelecaniformes", + "사다새목", + "Ardeidae", + "왜가리과", + "Butorides", + "해오라기속", + "striata", + "통합테스트새" + ), + new AdminBirdUpsertRequest.Description("설명", "출처", false), + 45.0, + "https://species.example/bird", + ConservationGrade.NONE, + habitats, + residencies, + images + ); + } + + private long countRows(String tableName, Long birdId) { + return new TransactionTemplate(transactionManager).execute(status -> { + EntityManager entityManager = testEm.getEntityManager(); + Number count = (Number) entityManager.createNativeQuery("SELECT COUNT(*) FROM " + tableName + " WHERE bird_id = :birdId") + .setParameter("birdId", birdId) + .getSingleResult(); + return count.longValue(); + }); + } + + private void deleteBirdRelations(EntityManager entityManager, Long birdId) { + for (String table : List.of("bird_image", "bird_habitat", "bird_residency")) { + entityManager.createNativeQuery("DELETE FROM " + table + " WHERE bird_id = :birdId") + .setParameter("birdId", birdId) + .executeUpdate(); + } + entityManager.createNativeQuery("DELETE FROM bird WHERE id = :birdId") + .setParameter("birdId", birdId) + .executeUpdate(); + } + + private String issueUploadedImage() { + AdminBirdImagePresignResponse response = service.generateImagePresignUrl("image/webp"); + imageService.markUploaded(response.objectKey()); + return response.objectKey(); + } + + private String uniqueScientificName() { + return "Codex testus " + System.nanoTime(); + } + + @TestConfiguration + static class ImageServiceTestConfig { + @Bean + TestImageService imageService() { + return new TestImageService(); + } + } + + static class TestImageService implements ImageService { + private final Set uploadedObjectKeys = new HashSet<>(); + private final Set deletedObjectKeys = new HashSet<>(); + + @Override + public String generateUploadUrl(String objectKey, String contentType, long expireMinutes) { + return "https://s3.example/" + objectKey; + } + + @Override + public void delete(String objectKey) { + deletedObjectKeys.add(objectKey); + uploadedObjectKeys.remove(objectKey); + } + + @Override + public void deleteAll(List objectKeys) { + deletedObjectKeys.addAll(objectKeys); + uploadedObjectKeys.removeAll(objectKeys); + } + + @Override + public boolean exists(String objectKey) { + return uploadedObjectKeys.contains(objectKey); + } + + void markUploaded(String objectKey) { + uploadedObjectKeys.add(objectKey); + } + + void reset() { + uploadedObjectKeys.clear(); + deletedObjectKeys.clear(); + } + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/BirdIdSuggestionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/BirdIdSuggestionRepositoryTest.java index 4ceb72bd..459771b4 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/BirdIdSuggestionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/BirdIdSuggestionRepositoryTest.java @@ -45,7 +45,7 @@ void save_find_remove() { UserBirdCollection col = new CollectionBuilder(em).owner(u).build(); Bird b = new BirdBuilder(em) .korName("까치") - .sciName("Pica pica") + .sciName("Pica pica fixture") .build(); BirdIdSuggestion s = new SuggestionBuilder(repo, em) @@ -77,7 +77,7 @@ void exists_check() { UserBirdCollection col = new CollectionBuilder(em).owner(u).build(); Bird b = new BirdBuilder(em) .korName("까마귀") - .sciName("Corvus corone") + .sciName("Corvus corone fixture") .build(); boolean before = repo.existsByUserIdAndCollectionIdAndBirdIdAndType(u.getId(), col.getId(), b.getId(), SuggestionType.AGREE); @@ -102,11 +102,11 @@ void delete_all_by_collection() { Bird b1 = new BirdBuilder(em) .korName("참새") - .sciName("Passer montanus") + .sciName("Passer montanus fixture") .build(); Bird b2 = new BirdBuilder(em) .korName("비둘기") - .sciName("Columba livia") + .sciName("Columba livia fixture") .build(); new SuggestionBuilder(repo, em).user(u1).collection(col).bird(b1).build(); @@ -135,7 +135,7 @@ void toggle_status() { Bird b1 = new BirdBuilder(em) .korName("직박구리") - .sciName("Hypsipetes amaurotis") + .sciName("Hypsipetes amaurotis fixture") .build(); // b1: 제안(current), 동의(current, other1), 비동의(other2) @@ -167,7 +167,7 @@ void toggle_status_empty() { UserBirdCollection col = new CollectionBuilder(em).owner(current).build(); Bird b1 = new BirdBuilder(em) .korName("참새") - .sciName("Passer montanus") + .sciName("Passer montanus fixture") .build(); em.flush(); @@ -196,15 +196,15 @@ void summary() { Bird b1 = new BirdBuilder(em) .korName("직박구리") - .sciName("Hypsipetes amaurotis") + .sciName("Hypsipetes amaurotis fixture") .build(); Bird b2 = new BirdBuilder(em) .korName("박새") - .sciName("Parus major") + .sciName("Parus major fixture") .build(); Bird b3 = new BirdBuilder(em) .korName("까치") - .sciName("Pica Serica") + .sciName("Pica Serica fixture") .build(); // 제안만 되고 아무도 동의/비동의 안함 // b1: 제안(current), 동의(current, other1), 비동의(other2) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bookmark/core/repository/BookmarkRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bookmark/core/repository/BookmarkRepositoryTest.java index 6ff2efa4..20ec6605 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bookmark/core/repository/BookmarkRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bookmark/core/repository/BookmarkRepositoryTest.java @@ -63,7 +63,7 @@ private Bird newBird() throws IllegalAccessException { // BirdName 설정 (NOT NULL 필드들) BirdName birdName = new BirdName(); birdName.setKoreanName("테스트새"); - birdName.setScientificName("Test bird"); + birdName.setScientificName("Test bird " + System.nanoTime()); birdNameField.set(b, birdName); // BirdTaxonomy 설정 (모든 필드가 NOT NULL) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceLifecycleScenarioTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceLifecycleScenarioTest.java new file mode 100644 index 00000000..00d45b54 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceLifecycleScenarioTest.java @@ -0,0 +1,179 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import org.devkor.apu.saerok_server.domain.notification.application.dto.RegisterUserDeviceCommand; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSetting; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeSchema; +import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapperImpl; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 디바이스 토큰 생애주기 시나리오를 실제 서비스 구동으로 검증한다. + * + *

로그아웃의 디바이스 측 효과는 {@code LogoutService}가 호출하는 + * {@link UserDeviceCommandService#deactivateDeviceIfPresent}로 그대로 재현한다. + */ +@DataJpaTest +@Import({ + UserDeviceCommandService.class, + UserDeviceRepository.class, + UserRepository.class, + UserDeviceWebMapperImpl.class, + NotificationSettingBackfillService.class, + NotificationTypeSchema.class, + NotificationSettingRepository.class +}) +@ActiveProfiles("test") +class UserDeviceLifecycleScenarioTest extends AbstractPostgresContainerTest { + + @Autowired TestEntityManager em; + @Autowired UserDeviceCommandService service; + @Autowired UserDeviceRepository userDeviceRepository; + @Autowired NotificationSettingRepository settingRepository; + + private static final DevicePlatform IOS = DevicePlatform.IOS; + + /* ----------------------------- helpers ----------------------------- */ + + private User newUser() { + User u = new UserBuilder(em).build(); + em.flush(); + return u; + } + + /** 로그인 후 앱이 호출하는 디바이스 등록. 등록된 row id 반환. */ + private Long register(Long userId, String deviceId, String token) { + service.registerUserDevice(new RegisterUserDeviceCommand(userId, deviceId, token, IOS)); + em.flush(); em.clear(); + return row(userId, deviceId).orElseThrow().getId(); + } + + /** 로그아웃의 디바이스 측 효과(토큰 비활성화). */ + private void logout(Long userId, String deviceId) { + service.deactivateDeviceIfPresent(userId, deviceId, IOS); + em.flush(); em.clear(); + } + + private Optional row(Long userId, String deviceId) { + return userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, IOS); + } + + private String token(Long userId, String deviceId) { + return row(userId, deviceId).orElseThrow().getToken(); + } + + /* ----------------------------- scenarios ----------------------------- */ + + @Test + @DisplayName("① A 로그아웃 → 같은 기기에 B 로그인: A는 휴면(token=null), B만 활성") + void scenario1_bTakesOverAfterLogout() { + User a = newUser(); + User b = newUser(); + + register(a.getId(), "D", "T"); + logout(a.getId(), "D"); + register(b.getId(), "D", "T"); // 같은 deviceId + 같은 FCM 토큰 + + assertThat(row(a.getId(), "D")).isPresent(); // 행은 보존 + assertThat(token(a.getId(), "D")).isNull(); // 비활성 + assertThat(token(b.getId(), "D")).isEqualTo("T"); // B 활성 + assertThat(userDeviceRepository.findAllActiveByUserId(a.getId())).isEmpty(); + assertThat(userDeviceRepository.findAllActiveByUserId(b.getId())).hasSize(1); + } + + @Test + @DisplayName("② A 로그아웃 → A 재로그인(같은 기기): 같은 행 재활성 + 알림 설정 보존") + void scenario2_sameUserReloginPreservesSettings() { + User a = newUser(); + Long deviceRowId = register(a.getId(), "D", "T"); + + // A가 특정 알림을 끈다 + NotificationSetting s = settingRepository + .findByUserDeviceIdAndType(deviceRowId, NotificationType.LIKED_ON_COLLECTION) + .orElseThrow(); + if (Boolean.TRUE.equals(s.getEnabled())) s.toggle(); + em.flush(); em.clear(); + assertThat(settingRepository + .findByUserDeviceIdAndType(deviceRowId, NotificationType.LIKED_ON_COLLECTION) + .orElseThrow().getEnabled()).isFalse(); + + logout(a.getId(), "D"); + assertThat(token(a.getId(), "D")).isNull(); + + Long reRowId = register(a.getId(), "D", "T"); + + assertThat(reRowId).isEqualTo(deviceRowId); // 새 행 생성 아님, 같은 행 재사용 + assertThat(token(a.getId(), "D")).isEqualTo("T"); // 재활성화 + assertThat(settingRepository // 끈 설정 그대로 보존 + .findByUserDeviceIdAndType(deviceRowId, NotificationType.LIKED_ON_COLLECTION) + .orElseThrow().getEnabled()).isFalse(); + } + + @Test + @DisplayName("③ A 로그아웃 → A가 다른 기기로 로그인: 옛 기기는 휴면(행/설정 보존), 새 기기 활성") + void scenario3_sameUserDifferentDevice() { + User a = newUser(); + Long dRow = register(a.getId(), "D", "T1"); + logout(a.getId(), "D"); + Long eRow = register(a.getId(), "E", "T2"); + + assertThat(eRow).isNotEqualTo(dRow); + assertThat(token(a.getId(), "D")).isNull(); // 옛 기기 휴면 + assertThat(settingRepository.findByUserDeviceId(dRow)).isNotEmpty(); // 옛 기기 설정 보존 + assertThat(token(a.getId(), "E")).isEqualTo("T2"); // 새 기기 활성 + assertThat(userDeviceRepository.findAllActiveByUserId(a.getId())) + .extracting(UserDevice::getId) + .containsExactly(eRow); + } + + @Test + @DisplayName("④ 두 기기 로그인 중 한 기기만 로그아웃: 나머지 기기는 계속 활성") + void scenario4_multiDeviceLogoutOne() { + User a = newUser(); + register(a.getId(), "D", "T1"); + Long eRow = register(a.getId(), "E", "T2"); + + logout(a.getId(), "D"); + + assertThat(token(a.getId(), "D")).isNull(); + assertThat(token(a.getId(), "E")).isEqualTo("T2"); + assertThat(userDeviceRepository.findAllActiveByUserId(a.getId())) + .extracting(UserDevice::getId) + .containsExactly(eRow); + } + + @Test + @DisplayName("⑤ A가 로그아웃 안 하고 같은 기기에 B 로그인: 등록 충돌정리가 A 토큰을 비활성화") + void scenario5_bTakesOverWithoutLogout() { + User a = newUser(); + User b = newUser(); + + register(a.getId(), "D", "T"); + // A는 로그아웃하지 않음 + register(b.getId(), "D", "T"); + + assertThat(token(a.getId(), "D")).isNull(); // 충돌정리(token=T 일치)로 비활성화 + assertThat(token(b.getId(), "D")).isEqualTo("T"); + assertThat(userDeviceRepository.findAllActiveByUserId(a.getId())).isEmpty(); + assertThat(userDeviceRepository.findAllActiveByUserId(b.getId())).hasSize(1); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java index 07ea8535..4a9f3404 100644 --- a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java @@ -5,8 +5,10 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.UUID; /** * Builder for creating and persisting Bird fixtures in tests. @@ -14,7 +16,7 @@ public class BirdBuilder { private final TestEntityManager em; private String korName = "까치"; - private String sciName = "Pica pica"; + private String sciName = "Pica pica test " + UUID.randomUUID(); private BirdTaxonomy taxonomy; private Double bodyLengthCm = 25.0; private String nibrUrl = null; @@ -91,4 +93,5 @@ public Bird build() { em.flush(); return bird; } + } From 20d5562bed8b8b66cd90691a307e68dc0eff1095 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 11 Jul 2026 16:42:46 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EB=8F=99=EC=A0=95=20=EB=8F=95?= =?UTF-8?q?=EA=B8=B0=20=EC=95=88=20=EB=B0=9B=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BirdIdRequestHistoryRecorder.java | 18 ++- .../dto/request/CreateCollectionRequest.java | 3 + .../dto/request/UpdateCollectionRequest.java | 3 + .../response/GetCollectionDetailResponse.java | 3 + .../GetCollectionEditDataResponse.java | 3 + .../response/UpdateCollectionResponse.java | 5 +- .../BirdIdSuggestionCommandService.java | 9 ++ .../application/CollectionCommandService.java | 28 ++-- .../dto/CreateCollectionCommand.java | 3 +- .../dto/UpdateCollectionCommand.java | 3 +- .../core/entity/UserBirdCollection.java | 17 ++- .../core/repository/CollectionRepository.java | 4 +- .../mapper/CollectionWebMapper.java | 1 + .../dto/common/CommunityCollectionInfo.java | 3 + .../application/CommunityDataAssembler.java | 4 +- .../core/repository/CommunityRepository.java | 4 +- .../community/mapper/CommunityWebMapper.java | 1 + ...rd_id_suggestion_enabled_to_collection.sql | 2 + .../BirdIdSuggestionCommandServiceTest.java | 18 +++ .../CollectionCommandServiceTest.java | 136 +++++++++++++++++- .../CommunityQueryServiceTest.java | 6 +- .../repository/CommunityRepositoryTest.java | 4 + 22 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java index e346ee9f..d80803a3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java @@ -23,12 +23,22 @@ public class BirdIdRequestHistoryRecorder { /** 컬렉션 생성 직후, bird가 비어있고 PUBLIC인 경우 pending 시작을 기록 */ public void onCollectionCreatedIfPending(UserBirdCollection collection, OffsetDateTime startedAt) { - if (collection.getBird() != null) return; - if (collection.getAccessLevel() != PUBLIC) return; + if (!collection.canReceiveBirdIdSuggestions()) return; if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return; repo.save(BirdIdRequestHistory.start(collection, startedAt)); } + /** 현재 컬렉션 상태를 기준으로 열린 동정 요청 이력을 생성하거나 취소 */ + public void syncOpenState(UserBirdCollection collection, OffsetDateTime now) { + if (collection.canReceiveBirdIdSuggestions()) { + if (repo.findOpenByCollectionId(collection.getId()).isEmpty()) { + repo.save(BirdIdRequestHistory.start(collection, now)); + } + return; + } + repo.deleteOpenByCollectionId(collection.getId()); + } + /** 채택(ADOPT)으로 해결된 순간 */ public void onResolvedByAdopt(UserBirdCollection collection, OffsetDateTime resolvedAt) { repo.findOpenByCollectionId(collection.getId()) @@ -42,7 +52,7 @@ public void onResolvedByEdit(UserBirdCollection collection) { /** not null -> null 로 바뀌는 순간: PUBLIC이면 새 pending 시작 */ public void onBirdSetToUnknown(UserBirdCollection collection, OffsetDateTime startedAt) { - if (collection.getAccessLevel() != PUBLIC) return; + if (!collection.canReceiveBirdIdSuggestions()) return; if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return; repo.save(BirdIdRequestHistory.start(collection, startedAt)); } @@ -55,7 +65,7 @@ public void onAccessLevelChanged(UserBirdCollection collection, AccessLevelType repo.deleteOpenByCollectionId(collection.getId()); } else if (oldLevel == PRIVATE && newLevel == PUBLIC) { // 공개로 바뀌었고 아직 미식별이면 새로 오픈 - if (collection.getBird() == null && repo.findOpenByCollectionId(collection.getId()).isEmpty()) { + if (collection.canReceiveBirdIdSuggestions() && repo.findOpenByCollectionId(collection.getId()).isEmpty()) { repo.save(BirdIdRequestHistory.start(collection, now)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java index 9b214b6a..9d27858c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java @@ -35,4 +35,7 @@ public class CreateCollectionRequest { @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) private AccessLevelType accessLevel; + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부. 생략 시 true", example = "true", nullable = true) + private Boolean birdIdSuggestionEnabled; } \ No newline at end of file diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java index f4759933..cdd407b8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java @@ -38,4 +38,7 @@ public class UpdateCollectionRequest { @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) private AccessLevelType accessLevel; + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부. null이면 변경하지 않음", example = "false", nullable = true) + private Boolean birdIdSuggestionEnabled; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java index aa03ac61..cfec44fb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java @@ -53,6 +53,9 @@ public class GetCollectionDetailResponse { @Schema(description = "내 컬렉션인지 여부", example = "false") private Boolean isMine; + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + private Boolean canSuggestBirdId; + @Schema(description = "새 정보") private BirdInfo bird; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java index 4aee4c6a..8b567046 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java @@ -34,6 +34,9 @@ public class GetCollectionEditDataResponse { @Schema(description = "컬렉션 공개 범위 (공개/비공개)") private AccessLevelType accessLevel; + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") + private Boolean birdIdSuggestionEnabled; + @Schema(description = "이미지 ID", example = "300") private Long imageId; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java index d6c69fb6..16daacb3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java @@ -34,5 +34,8 @@ public record UpdateCollectionResponse( String imageUrl, @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) - AccessLevelType accessLevel + AccessLevelType accessLevel, + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") + Boolean birdIdSuggestionEnabled ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java index 0140728c..0e34e581 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java @@ -41,6 +41,9 @@ public SuggestBirdIdResponse suggest(Long userId, Long collectionId, Long birdId if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 동정 의견을 제안할 수 없어요"); @@ -106,6 +109,9 @@ public ToggleStatusResponse toggleAgree(Long userId, Long collectionId, Long bir if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 동의할 수 없어요"); @@ -156,6 +162,9 @@ public ToggleStatusResponse toggleDisagree(Long userId, Long collectionId, Long if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 비동의할 수 없어요"); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java index 0124445f..3025d312 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java @@ -5,7 +5,6 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.UpdateCollectionCommand; -import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionImageRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; @@ -60,6 +59,7 @@ public Long createCollection(CreateCollectionCommand command) { throw new BadRequestException("한 줄 평 길이는 " + UserBirdCollection.NOTE_MAX_LENGTH + "자 이하여야 해요"); Point location = PointFactory.create(command.latitude(), command.longitude()); + boolean birdIdSuggestionEnabled = bird == null && !Boolean.FALSE.equals(command.birdIdSuggestionEnabled()); UserBirdCollection collection = UserBirdCollection.builder() .user(user) @@ -71,12 +71,12 @@ public Long createCollection(CreateCollectionCommand command) { .address(command.address()) .note(command.note()) .accessLevel(command.accessLevel()) + .birdIdSuggestionEnabled(birdIdSuggestionEnabled) .build(); Long id = collectionRepository.save(collection); - // 생성 직후 bird가 비어 있고 PUBLIC이면 '대기 시작' 기록 - birdReqHistory.onCollectionCreatedIfPending(collection, collection.getCreatedAt()); + birdReqHistory.syncOpenState(collection, collection.getCreatedAt()); return id; } @@ -110,8 +110,6 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command } OffsetDateTime now = OffsetDateTime.now(); - // 변경 전 상태 스냅샷 - AccessLevelType oldLevel = collection.getAccessLevel(); // 새 ID 변경 if (Boolean.TRUE.equals(command.isBirdIdUpdated())) { @@ -119,16 +117,12 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command Bird after = (command.birdId() != null) ? birdRepository.findById(command.birdId()).orElseThrow(() -> new NotFoundException("존재하지 않는 조류 id예요")) : null; - - if (before == null && after != null) { - // null -> not null : EDIT로 해결 → 열린 기록 삭제 - birdReqHistory.onResolvedByEdit(collection); - } else if (before != null && after == null) { - // not null -> null : PUBLIC이면 다시 대기 시작 - birdReqHistory.onBirdSetToUnknown(collection, now); - } + boolean becameUnknown = before != null && after == null; collection.changeBird(after); + if (becameUnknown && command.birdIdSuggestionEnabled() == null) { + collection.changeBirdIdSuggestionEnabled(true); + } } if (command.discoveredDate() != null) collection.setDiscoveredDate(command.discoveredDate()); @@ -150,12 +144,16 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command collection.setNote(command.note()); } - // 액세스 레벨 변경 처리 (전/후 비교) if (command.accessLevel() != null) { collection.setAccessLevel(command.accessLevel()); - birdReqHistory.onAccessLevelChanged(collection, oldLevel, now); } + if (command.birdIdSuggestionEnabled() != null) { + collection.changeBirdIdSuggestionEnabled(command.birdIdSuggestionEnabled()); + } + + birdReqHistory.syncOpenState(collection, now); + String imageUrl = collectionImageRepository.findObjectKeysByCollectionId(command.collectionId()).stream() .map(imageDomainService::toUploadImageUrl) .findFirst() diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java index 735771aa..16a9e05b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java @@ -13,6 +13,7 @@ public record CreateCollectionCommand ( String locationAlias, String address, String note, - AccessLevelType accessLevel + AccessLevelType accessLevel, + Boolean birdIdSuggestionEnabled ){ } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java index d249b1ab..cce5cca4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java @@ -15,6 +15,7 @@ public record UpdateCollectionCommand ( String locationAlias, String address, String note, - AccessLevelType accessLevel + AccessLevelType accessLevel, + Boolean birdIdSuggestionEnabled ){ } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java index 997a339d..cf97ca04 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java @@ -62,8 +62,11 @@ public class UserBirdCollection extends Auditable { @Setter private AccessLevelType accessLevel; + @Column(name = "bird_id_suggestion_enabled", nullable = false) + private boolean birdIdSuggestionEnabled = true; + @Builder - public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate discoveredDate, Point location, String locationAlias, String address, String note, boolean isPinned, AccessLevelType accessLevel) { + public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate discoveredDate, Point location, String locationAlias, String address, String note, boolean isPinned, AccessLevelType accessLevel, Boolean birdIdSuggestionEnabled) { if (user == null) throw new IllegalArgumentException("user는 null일 수 없습니다."); if (discoveredDate == null) throw new IllegalArgumentException("discoveredDate는 null일 수 없습니다."); @@ -79,11 +82,23 @@ public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate d this.note = note; this.isPinned = isPinned; this.accessLevel = accessLevel == null ? AccessLevelType.PUBLIC : accessLevel; + this.birdIdSuggestionEnabled = bird == null && (birdIdSuggestionEnabled == null || birdIdSuggestionEnabled); } /** 단순 변경: 동정 요청 기록 열고/닫기는 별도 Recorder가 처리 */ public void changeBird(Bird newBird) { this.bird = newBird; + if (newBird != null) { + this.birdIdSuggestionEnabled = false; + } + } + + public void changeBirdIdSuggestionEnabled(boolean enabled) { + this.birdIdSuggestionEnabled = getBird() == null && enabled; + } + + public boolean canReceiveBirdIdSuggestions() { + return bird == null && accessLevel == AccessLevelType.PUBLIC && birdIdSuggestionEnabled; } public double getLongitude() { return location.getX(); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index 3af7c454..e5f1c80a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -274,7 +274,9 @@ public List findPublicPendingCollections() { SELECT c FROM UserBirdCollection c JOIN FETCH c.user u JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL - WHERE c.accessLevel = :public AND c.bird IS NULL + WHERE c.accessLevel = :public + AND c.bird IS NULL + AND c.birdIdSuggestionEnabled = true ORDER BY h.startedAt DESC """, UserBirdCollection.class) .setParameter("public", AccessLevelType.PUBLIC) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java index b511dcbf..490ce138 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java @@ -60,6 +60,7 @@ public interface CollectionWebMapper { @Mapping(target = "commentCount", source = "commentCount") @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isMine", source = "isMine") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") @Mapping(target = "latitude", expression = "java(CollectionLocationMasker.latitude(collection, isMine))") @Mapping(target = "longitude", expression = "java(CollectionLocationMasker.longitude(collection, isMine))") @Mapping(target = "locationAlias", expression = "java(CollectionLocationMasker.locationAlias(collection, isMine))") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java index f493d551..5027ab32 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java @@ -51,6 +51,9 @@ public record CommunityCollectionInfo( @Schema(description = "동정 돕기에 참여한 유저 수 (동정 요청 컬렉션인 경우에만)", example = "5", nullable = true) Long suggestionUserCount, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + Boolean canSuggestBirdId, @Schema(description = "새 정보") BirdInfo bird, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java index 8b6cbecf..a7097935 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java @@ -43,7 +43,7 @@ public List toCollectionInfos(List Map popularStatusMap = popularCollectionRepository.existsByCollectionIds(collectionIds); List pendingCollectionIds = collections.stream() - .filter(c -> c.getBird() == null) + .filter(UserBirdCollection::canReceiveBirdIdSuggestions) .map(UserBirdCollection::getId) .toList(); Map suggestionUserCounts = pendingCollectionIds.isEmpty() @@ -62,7 +62,7 @@ public List toCollectionInfos(List boolean isPopular = popularStatusMap.getOrDefault(collection.getId(), false); boolean isMine = userId != null && userId.equals(collection.getUser().getId()); - Long suggestionUserCount = collection.getBird() == null + Long suggestionUserCount = collection.canReceiveBirdIdSuggestions() ? suggestionUserCounts.getOrDefault(collection.getId(), 0L) : null; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java index f240c98f..41b05195 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java @@ -54,7 +54,9 @@ public List findPendingBirdIdCollections(CommunityQueryComma SELECT c FROM UserBirdCollection c JOIN FETCH c.user u JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL - WHERE c.accessLevel = :public AND c.bird IS NULL + WHERE c.accessLevel = :public + AND c.bird IS NULL + AND c.birdIdSuggestionEnabled = true ORDER BY h.startedAt DESC """, UserBirdCollection.class) .setParameter("public", AccessLevelType.PUBLIC); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java index c50669d1..f2272bea 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java @@ -30,6 +30,7 @@ public interface CommunityWebMapper { @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isPopular", source = "isPopular") @Mapping(target = "suggestionUserCount", source = "suggestionUserCount") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") @Mapping(target = "bird", expression = "java(mapBirdInfo(collection))") @Mapping(target = "user", expression = "java(mapUserInfo(collection, userProfileImageUrl, thumbnailProfileImageUrl))") CommunityCollectionInfo toCommunityCollectionInfo( diff --git a/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql b/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql new file mode 100644 index 00000000..a9b11a55 --- /dev/null +++ b/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_bird_collection + ADD COLUMN bird_id_suggestion_enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java index 0a84b6e5..c56cbf5e 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.collection.application; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SuggestBirdIdResponse; +import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.BirdIdSuggestion; import org.devkor.apu.saerok_server.domain.collection.core.entity.BirdIdSuggestion.SuggestionType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -58,6 +59,7 @@ private UserBirdCollection collection(long id, User owner) { UserBirdCollection c = new UserBirdCollection(); ReflectionTestUtils.setField(c, "id", id); ReflectionTestUtils.setField(c, "user", owner); + c.setAccessLevel(AccessLevelType.PUBLIC); return c; } @@ -144,6 +146,22 @@ void userNotFound() { assertThatThrownBy(() -> sut.suggest(1L, 100L, 5L)).isInstanceOf(org.devkor.apu.saerok_server.global.shared.exception.NotFoundException.class); } + @Test @DisplayName("동정요청 비활성 컬렉션이면 제안 불가") + void suggestionDisabled() { + User u = user(1L); + UserBirdCollection col = collection(100L, user(2L)); + col.changeBirdIdSuggestionEnabled(false); + + when(userRepo.findById(1L)).thenReturn(Optional.of(u)); + when(collectionRepo.findById(100L)).thenReturn(Optional.of(col)); + + assertThatThrownBy(() -> sut.suggest(1L, 100L, 5L)) + .isInstanceOf(org.devkor.apu.saerok_server.global.shared.exception.BadRequestException.class) + .hasMessage("동정 의견을 받지 않는 컬렉션이에요"); + + verifyNoInteractions(birdRepo); + } + // … 이하 생략 (원본과 동일) } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java index 6f5a2236..f0ae911c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java @@ -2,6 +2,7 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.UpdateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionImageRepository; @@ -93,7 +94,7 @@ void createCollection_success_withBird() { ReflectionTestUtils.setField(bird, "id", birdId); CreateCollectionCommand command = new CreateCollectionCommand( - userId, birdId, date, lat, lon, alias, address, note, accessLevel + userId, birdId, date, lat, lon, alias, address, note, accessLevel, null ); given(userRepository.findById(userId)).willReturn(Optional.of(user)); @@ -121,15 +122,74 @@ void createCollection_success_withBird() { assertThat(saved.getAddress()).isEqualTo(address); assertThat(saved.getNote()).isEqualTo(note); assertThat(saved.getAccessLevel()).isEqualTo(accessLevel); + assertThat(saved.isBirdIdSuggestionEnabled()).isFalse(); - then(birdReqHistory).should().onCollectionCreatedIfPending(same(saved), any()); + then(birdReqHistory).should().syncOpenState(same(saved), any()); + } + + @Test + @DisplayName("정상 생성 - birdId가 없고 동정요청 옵션 생략 시 활성화") + void createCollection_unknownBird_defaultSuggestionEnabled() { + Long userId = 1L; + LocalDate date = LocalDate.of(2025, 8, 7); + User user = User.createUser("email@example.com"); + ReflectionTestUtils.setField(user, "id", userId); + + CreateCollectionCommand command = new CreateCollectionCommand( + userId, null, date, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, null + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + willAnswer(invocation -> { + UserBirdCollection c = invocation.getArgument(0); + ReflectionTestUtils.setField(c, "id", 3L); + return 3L; + }).given(collectionRepository).save(any(UserBirdCollection.class)); + + Long result = service.createCollection(command); + + assertThat(result).isEqualTo(3L); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBirdCollection.class); + then(collectionRepository).should().save(captor.capture()); + UserBirdCollection saved = captor.getValue(); + assertThat(saved.isBirdIdSuggestionEnabled()).isTrue(); + assertThat(saved.canReceiveBirdIdSuggestions()).isTrue(); + then(birdReqHistory).should().syncOpenState(same(saved), any()); + } + + @Test + @DisplayName("정상 생성 - birdId가 없고 동정요청 옵션 false면 비활성화") + void createCollection_unknownBird_suggestionDisabled() { + Long userId = 1L; + User user = User.createUser("email@example.com"); + ReflectionTestUtils.setField(user, "id", userId); + + CreateCollectionCommand command = new CreateCollectionCommand( + userId, null, LocalDate.now(), 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, false + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + willAnswer(invocation -> { + UserBirdCollection c = invocation.getArgument(0); + ReflectionTestUtils.setField(c, "id", 3L); + return 3L; + }).given(collectionRepository).save(any(UserBirdCollection.class)); + + service.createCollection(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBirdCollection.class); + then(collectionRepository).should().save(captor.capture()); + UserBirdCollection saved = captor.getValue(); + assertThat(saved.isBirdIdSuggestionEnabled()).isFalse(); + assertThat(saved.canReceiveBirdIdSuggestions()).isFalse(); + then(birdReqHistory).should().syncOpenState(same(saved), any()); } @Test @DisplayName("발견 날짜 누락 시 BadRequestException") void createCollection_missingDate_throws() { CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, null, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC + 1L, null, null, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -142,7 +202,7 @@ void createCollection_missingDate_throws() { @DisplayName("위치 정보 누락 시 BadRequestException") void createCollection_missingLocation_throws() { CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, LocalDate.now(), null, 20.0, null, null, null, AccessLevelType.PUBLIC + 1L, null, LocalDate.now(), null, 20.0, null, null, null, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -156,7 +216,7 @@ void createCollection_missingLocation_throws() { void createCollection_noteTooLong_throws() { String longNote = "a".repeat(UserBirdCollection.NOTE_MAX_LENGTH + 1); CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, LocalDate.now(), 10.0, 20.0, null, null, longNote, AccessLevelType.PUBLIC + 1L, null, LocalDate.now(), 10.0, 20.0, null, null, longNote, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -261,5 +321,69 @@ void deleteCollection_forbidden_throws() { } } - // updateCollection 관련 기존 테스트들은 이 변경과 무관하므로 그대로 유지합니다. + @Nested + @DisplayName("updateCollection 메서드 테스트") + class UpdateCollectionTests { + + @Test + @DisplayName("birdId가 없는 컬렉션에서 동정요청 옵션을 false로 변경") + void updateCollection_disableSuggestion() { + Long userId = 1L; + Long collId = 2L; + User user = User.createUser("e@e"); + ReflectionTestUtils.setField(user, "id", userId); + UserBirdCollection coll = UserBirdCollection.builder() + .user(user) + .bird(null) + .discoveredDate(LocalDate.now()) + .location(org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory.create(0, 0)) + .accessLevel(AccessLevelType.PUBLIC) + .build(); + ReflectionTestUtils.setField(coll, "id", collId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(collectionRepository.findById(collId)).willReturn(Optional.of(coll)); + given(collectionImageRepository.findObjectKeysByCollectionId(collId)).willReturn(List.of()); + + service.updateCollection(new UpdateCollectionCommand( + userId, collId, null, null, null, null, null, null, null, null, null, false + )); + + assertThat(coll.isBirdIdSuggestionEnabled()).isFalse(); + assertThat(coll.canReceiveBirdIdSuggestions()).isFalse(); + then(birdReqHistory).should().syncOpenState(same(coll), any()); + } + + @Test + @DisplayName("birdId를 확정 상태에서 null로 변경하고 옵션을 생략하면 동정요청 활성화") + void updateCollection_identifiedToUnknown_defaultsSuggestionEnabled() { + Long userId = 1L; + Long collId = 2L; + User user = User.createUser("e@e"); + ReflectionTestUtils.setField(user, "id", userId); + Bird bird = new Bird(); + ReflectionTestUtils.setField(bird, "id", 10L); + UserBirdCollection coll = UserBirdCollection.builder() + .user(user) + .bird(bird) + .discoveredDate(LocalDate.now()) + .location(org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory.create(0, 0)) + .accessLevel(AccessLevelType.PUBLIC) + .build(); + ReflectionTestUtils.setField(coll, "id", collId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(collectionRepository.findById(collId)).willReturn(Optional.of(coll)); + given(collectionImageRepository.findObjectKeysByCollectionId(collId)).willReturn(List.of()); + + service.updateCollection(new UpdateCollectionCommand( + userId, collId, true, null, null, null, null, null, null, null, null, null + )); + + assertThat(coll.getBird()).isNull(); + assertThat(coll.isBirdIdSuggestionEnabled()).isTrue(); + assertThat(coll.canReceiveBirdIdSuggestions()).isTrue(); + then(birdReqHistory).should().syncOpenState(same(coll), any()); + } + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index 9f1a713b..8f7564be 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -77,6 +77,7 @@ private static CommunityCollectionInfo collectionInfo( Long commentCount, Boolean isPopular, Long suggestionUserCount, + Boolean canSuggestBirdId, CommunityCollectionInfo.BirdInfo birdInfo, CommunityCollectionInfo.UserInfo userInfo ) { @@ -96,6 +97,7 @@ private static CommunityCollectionInfo collectionInfo( false, isPopular, suggestionUserCount, + canSuggestBirdId, birdInfo, userInfo ); @@ -189,13 +191,13 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() { CommunityCollectionInfo pendingInfo = collectionInfo( 1L, "https://example.com/image1.jpg", "https://example.com/thumbnails/1", "이게 무슨 새일까요?", - 10L, 5L, false, 3L, null, userInfo + 10L, 5L, false, 3L, true, null, userInfo ); CommunityCollectionInfo.BirdInfo birdInfo = new CommunityCollectionInfo.BirdInfo(100L, "까치"); CommunityCollectionInfo normalInfo = collectionInfo( 2L, "https://example.com/image2.jpg", "https://example.com/thumbnails/2", "까치를 발견했어요!", - 15L, 7L, false, null, birdInfo, userInfo + 15L, 7L, false, null, false, birdInfo, userInfo ); given(dataAssembler.toCollectionInfos(collections, userId)) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java index 73ff8a43..cace7917 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java @@ -218,10 +218,14 @@ void findPending_returnsWithoutBird() { newCollection(user, bird, AccessLevelType.PUBLIC, null); // resolved (bird!=null) UserBirdCollection withoutBirdPublic = newCollection(user, null, AccessLevelType.PUBLIC, null); // pending 대상 + UserBirdCollection suggestionDisabled = newCollection(user, null, AccessLevelType.PUBLIC, null); + suggestionDisabled.changeBirdIdSuggestionEnabled(false); + em.merge(suggestionDisabled); newCollection(user, null, AccessLevelType.PRIVATE, null); // PRIVATE → 제외 // ★ 핵심: pending 조회는 열린 BirdIdRequestHistory가 있어야 잡힌다 openPending(withoutBirdPublic, OffsetDateTime.now().minusMinutes(1)); + openPending(suggestionDisabled, OffsetDateTime.now().minusMinutes(2)); CommunityQueryCommand command = new CommunityQueryCommand(1, 10, null); From 97624af8d144e7ad4d570a8e0b7d4cff2b25d1df Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 22 Jul 2026 23:53:15 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9D=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EB=90=98=EB=8A=94=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20canSuggestBirdId=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 - 당장 필요없어 보여도 기획이 어떻게 달라질지 몰라 일단 추가한 것도 있음 --- .../api/BirdIdSuggestionController.java | 2 +- .../collection/api/CollectionController.java | 1 + .../api/CollectionLikeController.java | 2 +- .../GetCollectionEditDataResponse.java | 3 + .../response/GetLikedCollectionsResponse.java | 5 +- .../GetNearbyCollectionsResponse.java | 3 + .../GetPendingCollectionsResponse.java | 5 +- .../dto/response/MyCollectionsResponse.java | 5 +- .../response/UpdateCollectionResponse.java | 5 +- .../BirdIdSuggestionQueryService.java | 3 +- .../CollectionLikeQueryService.java | 2 +- .../application/CollectionQueryService.java | 3 +- .../mapper/CollectionLikeWebMapper.java | 3 +- .../mapper/CollectionWebMapper.java | 3 + .../api/dto/response/UserProfileResponse.java | 5 +- .../profile/mapper/UserProfileMapper.java | 3 +- .../BirdIdSuggestionQueryServiceTest.java | 4 + .../CollectionQueryServiceTest.java | 45 ++++++++++ .../CollectionSuggestionFlagMapperTest.java | 86 +++++++++++++++++++ 19 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java index 2d26f8c6..f2e7637f 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java @@ -31,7 +31,7 @@ public class BirdIdSuggestionController { @PermitAll @Operation( summary = "동정 의견을 기다리는 컬렉션 목록 조회", - description = "bird_id 미확정 PUBLIC 컬렉션 목록을 조회", + description = "bird_id가 미확정이고 동정 의견을 받는 PUBLIC 컬렉션 목록을 조회", responses = @ApiResponse( responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = GetPendingCollectionsResponse.class)) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index 667b2091..e84e1dc7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -245,6 +245,7 @@ public GetCollectionDetailResponse getCollectionDetail( - commentCount - createdAt - discoveredDate + - canSuggestBirdId """, responses = { @ApiResponse( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionLikeController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionLikeController.java index 9b3d2eb3..954866e7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionLikeController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionLikeController.java @@ -77,7 +77,7 @@ public LikeStatusResponse getLikeStatus( security = @SecurityRequirement(name = "bearerAuth"), description = "내가 좋아요한 컬렉션 목록을 조회합니다.", responses = { - @ApiResponse(responseCode = "200", description = "좋아요한 컬렉션 ID 목록 조회 성공", + @ApiResponse(responseCode = "200", description = "좋아요한 컬렉션 목록 조회 성공", content = @Content(schema = @Schema(implementation = GetLikedCollectionsResponse.class))), @ApiResponse(responseCode = "401", description = "사용자 인증 실패", content = @Content), @ApiResponse(responseCode = "404", description = "사용자가 존재하지 않음", content = @Content) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java index 8b567046..0053fab1 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java @@ -37,6 +37,9 @@ public class GetCollectionEditDataResponse { @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") private Boolean birdIdSuggestionEnabled; + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + private Boolean canSuggestBirdId; + @Schema(description = "이미지 ID", example = "300") private Long imageId; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetLikedCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetLikedCollectionsResponse.java index 6dfbb653..9bdf2eff 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetLikedCollectionsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetLikedCollectionsResponse.java @@ -12,6 +12,9 @@ public record GetLikedCollectionsResponse( @Schema(name = "GetLikedCollectionsResponse.Item") public record Item( @Schema(description = "컬렉션 ID", example = "1") - Long collectionId + Long collectionId, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + Boolean canSuggestBirdId ) {} } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetNearbyCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetNearbyCollectionsResponse.java index 347f17b8..37d01e7c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetNearbyCollectionsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetNearbyCollectionsResponse.java @@ -53,6 +53,9 @@ public static class Item { @Schema(description = "내가 좋아요 눌렀는지 여부", example = "true") private Boolean isLiked; + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + private Boolean canSuggestBirdId; + @Schema(description = "컬렉션 소유자 정보") private UserInfo user; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetPendingCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetPendingCollectionsResponse.java index 08bee4e3..4932080c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetPendingCollectionsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetPendingCollectionsResponse.java @@ -32,6 +32,9 @@ public record Item( String thumbnailProfileImageUrl, @Schema(description = "동정 의견 요청 시각", example = "2025-07-20T00:23:58.164815", requiredMode = Schema.RequiredMode.REQUIRED) - LocalDateTime birdIdSuggestionRequestedAt + LocalDateTime birdIdSuggestionRequestedAt, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + Boolean canSuggestBirdId ) {} } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/MyCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/MyCollectionsResponse.java index 3d4d98ad..4101bdec 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/MyCollectionsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/MyCollectionsResponse.java @@ -35,6 +35,9 @@ public record Item( LocalDateTime createdAt, @Schema(description = "새 발견 일시", example = "2025-01-15") - LocalDate discoveredDate + LocalDate discoveredDate, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + Boolean canSuggestBirdId ) { } } \ No newline at end of file diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java index 16daacb3..1dadd00e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java @@ -37,5 +37,8 @@ public record UpdateCollectionResponse( AccessLevelType accessLevel, @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") - Boolean birdIdSuggestionEnabled + Boolean birdIdSuggestionEnabled, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + Boolean canSuggestBirdId ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryService.java index fbb95765..ef4d1ebc 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryService.java @@ -70,7 +70,8 @@ public GetPendingCollectionsResponse getPendingCollections() { thumbnailProfileImageUrls.get(c.getUser().getId()), startedAt != null ? OffsetDateTimeLocalizer.toSeoulLocalDateTime(startedAt) - : null + : null, + c.canReceiveBirdIdSuggestions() ); }) .toList(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeQueryService.java index 0529d686..019f6635 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeQueryService.java @@ -44,7 +44,7 @@ public LikeStatusResponse getLikeStatusResponse(Long userId, Long collectionId) } /** - * 사용자가 좋아요한 컬렉션 ID 목록 조회 + * 사용자가 좋아요한 컬렉션 목록 조회 */ public GetLikedCollectionsResponse getLikedCollectionIdsResponse(Long userId) { userRepository.findById(userId) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index caee9867..57f320d6 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -121,7 +121,8 @@ public MyCollectionsResponse getMyCollections(Long userId) { likeCount, commentCount, OffsetDateTimeLocalizer.toSeoulLocalDateTime(c.getCreatedAt()), - c.getDiscoveredDate() + c.getDiscoveredDate(), + c.canReceiveBirdIdSuggestions() ); }) .toList(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionLikeWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionLikeWebMapper.java index 393b597f..e25e4199 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionLikeWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionLikeWebMapper.java @@ -16,7 +16,7 @@ ) public interface CollectionLikeWebMapper { - // 좋아요한 컬렉션 ID 목록 조회 + // 좋아요한 컬렉션 목록 조회 default GetLikedCollectionsResponse toGetLikedCollectionsResponse(List collections) { if (collections == null || collections.isEmpty()) { return new GetLikedCollectionsResponse(List.of()); @@ -29,6 +29,7 @@ default GetLikedCollectionsResponse toGetLikedCollectionsResponse(List toLikedCollectionItems(List collections); @Mapping(target = "collectionId", source = "id") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") GetLikedCollectionsResponse.Item toLikedCollectionItem(UserBirdCollection collection); // 컬렉션을 좋아요한 사용자 목록 조회 diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java index 490ce138..7ddcca66 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java @@ -38,6 +38,7 @@ public interface CollectionWebMapper { GetCollectionEditDataCommand toGetCollectionDataCommand(Long userId, Long collectionId); @Mapping(target = "birdId", source = "bird.id") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") GetCollectionEditDataResponse toGetCollectionEditDataResponse(UserBirdCollection collection); @Mapping(target = "userId", source = "userId") @@ -45,6 +46,7 @@ public interface CollectionWebMapper { @Mapping(target = "birdId", source = "collection.bird.id") @Mapping(target = "collectionId", source = "collection.id") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") UpdateCollectionResponse toUpdateCollectionResponse(UserBirdCollection collection, String imageUrl); @Mapping(target = "bird.birdId", source = "collection", qualifiedByName = "getBirdId") @@ -74,6 +76,7 @@ public interface CollectionWebMapper { @Mapping(target = "likeCount", source = "likeCount") @Mapping(target = "commentCount", source = "commentCount") @Mapping(target = "isLiked", source = "isLiked") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") @Mapping(target = "user.userId", source = "collection.user.id") @Mapping(target = "user.nickname", source = "collection.user.nickname") @Mapping(target = "user.profileImageUrl", source = "userProfileImageUrl") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/dto/response/UserProfileResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/dto/response/UserProfileResponse.java index 146931d9..61cb4036 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/dto/response/UserProfileResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/dto/response/UserProfileResponse.java @@ -54,6 +54,9 @@ public record CollectionItem( LocalDate discoveredDate, @Schema(description = "컬렉션을 업로드한 날짜", example = "2025-05-17", requiredMode = Schema.RequiredMode.REQUIRED) - LocalDate uploadedDate + LocalDate uploadedDate, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + Boolean canSuggestBirdId ) {} } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/profile/mapper/UserProfileMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/profile/mapper/UserProfileMapper.java index 2f88907b..c4124822 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/profile/mapper/UserProfileMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/profile/mapper/UserProfileMapper.java @@ -55,7 +55,8 @@ default List toCollectionItems( thumbnailUrlMap.get(c.getId()), c.getNote(), c.getDiscoveredDate(), - OffsetDateTimeLocalizer.toSeoulLocalDate(c.getCreatedAt()) + OffsetDateTimeLocalizer.toSeoulLocalDate(c.getCreatedAt()), + c.canReceiveBirdIdSuggestions() )) .collect(Collectors.toList()); } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryServiceTest.java index 406f1222..bbb81606 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryServiceTest.java @@ -3,6 +3,7 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetBirdIdSuggestionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetPendingCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; +import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.BirdIdSuggestionRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; @@ -69,6 +70,7 @@ private static UserBirdCollection coll(long id, User owner, String note) { setField(c, "id", id); setField(c, "user", owner); setField(c, "note", note); + c.setAccessLevel(AccessLevelType.PUBLIC); // birdIdSuggestionRequestedAt 필드는 제거되었으므로 더 이상 세팅하지 않음 return c; } @@ -123,12 +125,14 @@ void success() { assertThat(first.imageUrl()).isEqualTo("http://cdn/img/thumb/key1.jpg"); assertThat(first.profileImageUrl()).isEqualTo("http://cdn/profile/1/profile.jpg"); assertThat(first.thumbnailProfileImageUrl()).isEqualTo("http://cdn/profile/1/thumbnail.webp"); + assertThat(first.canSuggestBirdId()).isTrue(); GetPendingCollectionsResponse.Item second = res.items().get(1); assertThat(second.collectionId()).isEqualTo(2L); assertThat(second.imageUrl()).isNull(); assertThat(second.profileImageUrl()).isEqualTo("http://cdn/profile/default/default-1.png"); assertThat(second.thumbnailProfileImageUrl()).isEqualTo("http://cdn/profile/default/thumbnail-1.webp"); + assertThat(second.canSuggestBirdId()).isTrue(); // 히스토리 조회가 호출됐는지까지 확인 verify(birdIdRequestHistoryRepository).findOpenStartedAtMapByCollectionIds(List.of(1L, 2L)); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 7e4aaec2..03f6cc30 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -1,10 +1,12 @@ package org.devkor.apu.saerok_server.domain.collection.application; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.*; +import org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory; import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionWebMapper; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; @@ -22,9 +24,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.*; import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verifyNoInteractions; @@ -212,4 +217,44 @@ void getCollectionDetail_userNotFound() { assertThrows(NotFoundException.class, () -> collectionQueryService.getCollectionDetailResponse(badUserId, collectionId)); } + + @Test + @DisplayName("내 컬렉션 목록에 동정 의견 가능 여부를 포함한다") + void getMyCollections_includesCanSuggestBirdId() throws IllegalAccessException { + Long userId = 1L; + User owner = new User(); + userIdField.set(owner, userId); + + UserBirdCollection enabled = UserBirdCollection.builder() + .user(owner) + .discoveredDate(LocalDate.of(2026, 7, 22)) + .location(PointFactory.create(37.5, 127.0)) + .accessLevel(AccessLevelType.PUBLIC) + .birdIdSuggestionEnabled(true) + .build(); + collectionIdField.set(enabled, 1L); + org.springframework.test.util.ReflectionTestUtils.setField(enabled, "createdAt", OffsetDateTime.now()); + + UserBirdCollection disabled = UserBirdCollection.builder() + .user(owner) + .discoveredDate(LocalDate.of(2026, 7, 21)) + .location(PointFactory.create(37.5, 127.0)) + .accessLevel(AccessLevelType.PUBLIC) + .birdIdSuggestionEnabled(false) + .build(); + collectionIdField.set(disabled, 2L); + org.springframework.test.util.ReflectionTestUtils.setField(disabled, "createdAt", OffsetDateTime.now()); + + List collections = List.of(enabled, disabled); + given(userRepository.findById(userId)).willReturn(Optional.of(owner)); + given(collectionRepository.findByUserId(userId)).willReturn(collections); + given(collectionImageUrlService.getPrimaryImageUrlsFor(collections)).willReturn(Map.of()); + given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(collections)).willReturn(Map.of()); + + MyCollectionsResponse response = collectionQueryService.getMyCollections(userId); + + assertThat(response.items()) + .extracting(MyCollectionsResponse.Item::canSuggestBirdId) + .containsExactly(true, false); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java new file mode 100644 index 00000000..401d9b3b --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java @@ -0,0 +1,86 @@ +package org.devkor.apu.saerok_server.domain.collection.mapper; + +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetLikedCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; +import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; +import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; +import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory; +import org.devkor.apu.saerok_server.domain.profile.api.dto.response.UserProfileResponse; +import org.devkor.apu.saerok_server.domain.profile.mapper.UserProfileMapper; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class CollectionSuggestionFlagMapperTest { + + private final CollectionWebMapper collectionWebMapper = Mappers.getMapper(CollectionWebMapper.class); + private final CollectionLikeWebMapper collectionLikeWebMapper = Mappers.getMapper(CollectionLikeWebMapper.class); + private final UserProfileMapper userProfileMapper = Mappers.getMapper(UserProfileMapper.class); + + @Test + void collectionResponsesMapCanSuggestBirdIdFromCollectionState() { + UserBirdCollection collection = suggestionEnabledCollection(); + + GetCollectionDetailResponse detail = collectionWebMapper.toGetCollectionDetailResponse( + collection, null, null, null, 0, 0, false, true + ); + GetCollectionEditDataResponse editData = collectionWebMapper.toGetCollectionEditDataResponse(collection); + UpdateCollectionResponse update = collectionWebMapper.toUpdateCollectionResponse(collection, null); + GetNearbyCollectionsResponse.Item nearby = collectionWebMapper.toGetNearbyCollectionsResponseItem( + collection, null, null, null, null, 0, 0, false, true + ); + GetLikedCollectionsResponse.Item liked = collectionLikeWebMapper.toLikedCollectionItem(collection); + + assertThat(detail.getCanSuggestBirdId()).isTrue(); + assertThat(editData.getCanSuggestBirdId()).isTrue(); + assertThat(update.canSuggestBirdId()).isTrue(); + assertThat(nearby.getCanSuggestBirdId()).isTrue(); + assertThat(liked.canSuggestBirdId()).isTrue(); + } + + @Test + void profileCollectionItemsMapCanSuggestBirdIdFromCollectionState() { + UserBirdCollection collection = suggestionEnabledCollection(); + CollectionImageUrlService imageUrlService = mock(CollectionImageUrlService.class); + given(imageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); + given(imageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); + + List items = + userProfileMapper.toCollectionItems(List.of(collection), imageUrlService); + + assertThat(items).singleElement() + .extracting(UserProfileResponse.CollectionItem::canSuggestBirdId) + .isEqualTo(true); + } + + private UserBirdCollection suggestionEnabledCollection() { + User owner = new User(); + ReflectionTestUtils.setField(owner, "id", 10L); + + UserBirdCollection collection = UserBirdCollection.builder() + .user(owner) + .bird(null) + .discoveredDate(LocalDate.of(2026, 7, 22)) + .location(PointFactory.create(37.5, 127.0)) + .accessLevel(AccessLevelType.PUBLIC) + .birdIdSuggestionEnabled(true) + .build(); + ReflectionTestUtils.setField(collection, "id", 1L); + ReflectionTestUtils.setField(collection, "createdAt", OffsetDateTime.now()); + return collection; + } +} From 6025f2c0b45dd3621ebab55a51a7e8da6ad312a6 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 22 Jul 2026 23:59:38 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=20birdIdSu?= =?UTF-8?q?ggestionEnabled=EB=A5=BC=20=EB=B0=9B=EB=8A=94=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=EC=84=A0=20canSuggestBirdId=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/response/GetCollectionEditDataResponse.java | 5 +---- .../api/dto/response/UpdateCollectionResponse.java | 5 +---- .../domain/collection/mapper/CollectionWebMapper.java | 2 -- .../mapper/CollectionSuggestionFlagMapperTest.java | 6 ------ 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java index 0053fab1..644755d2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java @@ -37,12 +37,9 @@ public class GetCollectionEditDataResponse { @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") private Boolean birdIdSuggestionEnabled; - @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") - private Boolean canSuggestBirdId; - @Schema(description = "이미지 ID", example = "300") private Long imageId; @Schema(description = "이미지 URL", example = "https://cdn.example.com/images/300.jpg") private String imageUrl; -} \ No newline at end of file +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java index 1dadd00e..16daacb3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java @@ -37,8 +37,5 @@ public record UpdateCollectionResponse( AccessLevelType accessLevel, @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") - Boolean birdIdSuggestionEnabled, - - @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") - Boolean canSuggestBirdId + Boolean birdIdSuggestionEnabled ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java index 7ddcca66..be7b246b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java @@ -38,7 +38,6 @@ public interface CollectionWebMapper { GetCollectionEditDataCommand toGetCollectionDataCommand(Long userId, Long collectionId); @Mapping(target = "birdId", source = "bird.id") - @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") GetCollectionEditDataResponse toGetCollectionEditDataResponse(UserBirdCollection collection); @Mapping(target = "userId", source = "userId") @@ -46,7 +45,6 @@ public interface CollectionWebMapper { @Mapping(target = "birdId", source = "collection.bird.id") @Mapping(target = "collectionId", source = "collection.id") - @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") UpdateCollectionResponse toUpdateCollectionResponse(UserBirdCollection collection, String imageUrl); @Mapping(target = "bird.birdId", source = "collection", qualifiedByName = "getBirdId") diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java index 401d9b3b..0d9e04d6 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionSuggestionFlagMapperTest.java @@ -1,10 +1,8 @@ package org.devkor.apu.saerok_server.domain.collection.mapper; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; -import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetLikedCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; -import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -38,16 +36,12 @@ void collectionResponsesMapCanSuggestBirdIdFromCollectionState() { GetCollectionDetailResponse detail = collectionWebMapper.toGetCollectionDetailResponse( collection, null, null, null, 0, 0, false, true ); - GetCollectionEditDataResponse editData = collectionWebMapper.toGetCollectionEditDataResponse(collection); - UpdateCollectionResponse update = collectionWebMapper.toUpdateCollectionResponse(collection, null); GetNearbyCollectionsResponse.Item nearby = collectionWebMapper.toGetNearbyCollectionsResponseItem( collection, null, null, null, null, 0, 0, false, true ); GetLikedCollectionsResponse.Item liked = collectionLikeWebMapper.toLikedCollectionItem(collection); assertThat(detail.getCanSuggestBirdId()).isTrue(); - assertThat(editData.getCanSuggestBirdId()).isTrue(); - assertThat(update.canSuggestBirdId()).isTrue(); assertThat(nearby.getCanSuggestBirdId()).isTrue(); assertThat(liked.canSuggestBirdId()).isTrue(); } From 3e75be80485dc7c08ff39952a4b0cf3fc5d3e876 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:23:20 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=88=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=A0=89=EC=85=98=20=EA=B2=80=EC=83=89=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 --- build.gradle | 1 + .../collection/api/CollectionController.java | 50 +++++++++++++ .../SearchNearbyCollectionsRequest.java | 37 ++++++++++ .../SearchNearbyCollectionsResponse.java | 22 ++++++ .../application/CollectionQueryService.java | 74 +++++++++++++++++-- .../dto/SearchNearbyCollectionsCommand.java | 11 +++ .../core/repository/CollectionRepository.java | 47 ++++++++++++ 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java diff --git a/build.gradle b/build.gradle index 83107669..effb9872 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index e84e1dc7..ea298752 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.request.*; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.CreateCollectionImageResponse; @@ -17,11 +18,13 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.PresignResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; import org.devkor.apu.saerok_server.domain.collection.application.CollectionCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionImageCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionQueryService; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.NearbyCollectionsMode; import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionWebMapper; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; @@ -370,6 +373,53 @@ public void deleteCollectionImage( collectionImageCommandService.deleteCollectionImage(userId, collectionId, imageId); } + @PostMapping("/nearby/search") + @PermitAll + @Operation( + summary = "새 이름으로 주위 컬렉션 검색 (인증: optional)", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다. + + - 최초 검색 반경은 기본 5km이며 `radiusMeters`로 변경할 수 있습니다. + - 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. + - 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다. + - 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다. + 클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다. + - 현재는 동정 완료된 새의 한국어 이름만 검색합니다. `tempBirdName`은 아직 검색에 포함하지 않습니다. + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = SearchNearbyCollectionsRequest.class)) + ), + responses = { + @ApiResponse( + responseCode = "200", description = "검색 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SearchNearbyCollectionsResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) + } + ) + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody SearchNearbyCollectionsRequest request + ) { + Long userId = userPrincipal == null ? null : userPrincipal.getId(); + return collectionQueryService.searchNearbyCollectionsByBirdName( + new SearchNearbyCollectionsCommand( + userId, + request.getLatitude(), + request.getLongitude(), + request.getRadiusMeters(), + request.getQuery(), + request.getLimit() + ) + ); + } + @GetMapping("/nearby") @PermitAll @Operation( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java new file mode 100644 index 00000000..c70e6d57 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java @@ -0,0 +1,37 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 주위 컬렉션을 검색하는 요청 DTO") +public class SearchNearbyCollectionsRequest { + + @NotNull(message = "위도를 입력해주세요.") + @Schema(description = "검색 중심 위도", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) + private Double latitude; + + @NotNull(message = "경도를 입력해주세요.") + @Schema(description = "검색 중심 경도", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) + private Double longitude; + + @NotBlank(message = "query를 입력해주세요.") + @Schema(description = "새 이름 검색어", example = "까치", requiredMode = Schema.RequiredMode.REQUIRED) + private String query; + + @NotNull(message = "radiusMeters를 입력해주세요.") + @Positive(message = "radiusMeters는 0보다 커야 해요.") + @Schema(description = "최초 검색 반경 (m). 생략 시 5km", example = "5000") + private Double radiusMeters = 5_000D; + + @Positive(message = "limit은 1 이상의 양수여야 해요.") + @Max(value = 60, message = "limit은 60 이하여야 해요.") + @Schema(description = "최초 검색 결과 최대 개수 (생략 시 60, 최대 60). 검색 반경이 늘어날수록 반환 개수도 줄어듭니다.", example = "60") + private Integer limit; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java new file mode 100644 index 00000000..50d010be --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 검색한 주위 컬렉션 조회 응답 DTO") +public class SearchNearbyCollectionsResponse { + + @Schema(description = "검색 결과 컬렉션 목록") + private List items; + + @Schema( + description = "최대 검색 반경까지 확장했지만 검색 결과가 없는지 여부", + example = "false" + ) + private boolean noResults; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index 57f320d6..e21defdb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -4,8 +4,10 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetCollectionEditDataCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.application.strategy.NearbyCollectionsStrategy; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; @@ -39,6 +41,11 @@ @Transactional(readOnly = true) public class CollectionQueryService { + private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000; + private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MIN_BIRD_NAME_SEARCH_LIMIT = 10; + private final CollectionRepository collectionRepository; private final CollectionImageRepository collectionImageRepository; private final CollectionLikeRepository collectionLikeRepository; @@ -185,6 +192,62 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom return response; } + response.setItems(toNearbyCollectionItems(collections, command.userId())); + return response; + } + + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchNearbyCollectionsCommand command) { + if (command.userId() != null) { + userRepository.findById(command.userId()).orElseThrow(() -> new NotFoundException("유효하지 않은 사용자 id예요")); + } + + // 1) 근거리 컬렉션 조회 (전략 기반) + Point refPoint = PointFactory.create(command.latitude(), command.longitude()); + double radiusMeters = Math.min(command.initialRadiusMeters(), MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + int attempt = 0; + + while (true) { + List collections = collectionRepository.findNearbyByBirdName( + refPoint, + radiusMeters, + command.query().trim(), + command.userId(), + calculateBirdNameSearchLimit(command.limit(), attempt) + ); + + if (!collections.isEmpty()) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(toNearbyCollectionItems(collections, command.userId())); + response.setNoResults(false); + return response; + } + + if (radiusMeters >= MAX_BIRD_NAME_SEARCH_RADIUS_METERS) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(List.of()); + response.setNoResults(true); + return response; + } + + radiusMeters = Math.min(radiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + attempt++; + } + } + + private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) { + int initialLimit = Math.min( + requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit, + MAX_BIRD_NAME_SEARCH_LIMIT + ); + int minimumLimit = Math.min(initialLimit, MIN_BIRD_NAME_SEARCH_LIMIT); + return Math.max(minimumLimit, initialLimit / (1 << attempt)); + } + + private List toNearbyCollectionItems( + List collections, + Long userId + ) { + // 2) 연관(작성자/새) 엔티티를 미리 불러와(prefetch) LAZY N+1 차단 List collectionIds = collections.stream().map(UserBirdCollection::getId).toList(); collectionRepository.prefetchUserAndBirdByIds(collectionIds); @@ -198,8 +261,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom Map commentCounts = collectionCommentRepository.countByCollectionIds(collectionIds); // 5) 내가 좋아요 눌렀는지 배치 조회 - Map myLikeMap = (command.userId() != null) - ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(command.userId(), collectionIds) + Map myLikeMap = (userId != null) + ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(userId, collectionIds) : new LinkedHashMap<>(); // 6) 작성자 프로필 이미지 배치 조회 @@ -212,10 +275,10 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom .map(c -> { long likeCount = likeCounts.getOrDefault(c.getId(), 0L); long commentCount = commentCounts.getOrDefault(c.getId(), 0L); - boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false); + boolean isLikedByMe = userId != null && myLikeMap.getOrDefault(c.getId(), false); String userProfileImageUrl = profileImageMap.get(c.getUser().getId()); String thumbnailProfileImageUrl = thumbnailProfileImageMap.get(c.getUser().getId()); - boolean isMine = command.userId() != null && command.userId().equals(c.getUser().getId()); + boolean isMine = userId != null && userId.equals(c.getUser().getId()); return collectionWebMapper.toGetNearbyCollectionsResponseItem( c, @@ -231,7 +294,6 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom }) .toList(); - response.setItems(items); - return response; + return items; } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java new file mode 100644 index 00000000..fcf0a496 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.collection.application.dto; + +public record SearchNearbyCollectionsCommand( + Long userId, + Double latitude, + Double longitude, + Double initialRadiusMeters, + String query, + Integer limit +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index e5f1c80a..130db7f0 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -111,6 +111,53 @@ ORDER BY ST_Distance( return query.getResultList(); } + /** + * 중심점 반경 안에서 새 한국어 이름으로 컬렉션을 부분 검색한다. + * tempBirdName은 아직 사용하지 않으므로 현재 검색 대상에 포함하지 않는다. + */ + @SuppressWarnings("unchecked") + public List findNearbyByBirdName( + Point ref, + double radiusMeters, + String query, + Long userId, + Integer limit + ) { + String sql = """ + SELECT c.* + FROM user_bird_collection c + JOIN bird b ON b.id = c.bird_id + WHERE ST_DWithin( + c.location::geography, + CAST(:refPoint AS geography), + :radius + ) + AND b.korean_name LIKE :query + AND ( + c.access_level = 'PUBLIC' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + AND ( + b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + ORDER BY ST_Distance( + c.location::geography, + CAST(:refPoint AS geography) + ) + """; + + var nativeQuery = em.createNativeQuery(sql, UserBirdCollection.class) + .setParameter("refPoint", ref) + .setParameter("radius", radiusMeters) + .setParameter("query", "%" + query + "%") + .setParameter("userId", userId); + if (limit != null) { + nativeQuery.setMaxResults(limit); + } + return nativeQuery.getResultList(); + } + public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, boolean isMineOnly) { if (isMineOnly && userId != null) { From 234e48163a1b69e79c899983cb4913c95bd2af06 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:23:47 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20geogaphy=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V96__add_geography_location_index_to_collection.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql diff --git a/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql new file mode 100644 index 00000000..ba16b1a6 --- /dev/null +++ b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_user_collection_location_geography + ON user_bird_collection USING GIST ((location::geography)); From e8ab27671a45929e04cc58830fe54593591c6c46 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:26:17 +0900 Subject: [PATCH 11/15] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionQueryServiceTest.java | 81 +++++++++++++++++++ .../repository/CollectionRepositoryTest.java | 56 +++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 03f6cc30..2dec1428 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -2,6 +2,8 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -31,6 +33,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -257,4 +262,80 @@ void getMyCollections_includesCanSuggestBirdId() throws IllegalAccessException { .extracting(MyCollectionsResponse.Item::canSuggestBirdId) .containsExactly(true, false); } + + @Test + @DisplayName("새 이름 주변 검색은 반경 확장마다 반환 제한을 줄이고 최대 150km에서 결과 없음 플래그를 반환한다") + void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResults() { + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 5_000.0, + "까치", + null + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertTrue(response.isNoResults()); + assertTrue(response.getItems().isEmpty()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10)); + } + + @Test + @DisplayName("새 이름 주변 검색은 확장된 반경에서 결과를 찾으면 해당 결과를 반환한다") + void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansion() throws IllegalAccessException { + User owner = new User(); + userIdField.set(owner, 10L); + UserBirdCollection collection = new UserBirdCollection(); + collectionIdField.set(collection, 1L); + collectionUserField.set(collection, owner); + + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 5_000.0, + "까치", + 5 + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of(collection)); + given(collectionImageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionLikeRepository.countLikesByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(collectionCommentRepository.countByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileImageUrlsFor(List.of(owner))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileThumbnailImageUrlsFor(List.of(owner))).willReturn(Map.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertFalse(response.isNoResults()); + assertEquals(1, response.getItems().size()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5)); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index c0d23ce5..a1e0e1d4 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -176,6 +176,62 @@ void findNearby_withLimit_appliesMaxResults() throws Exception { assertEquals(2, result.size()); } + @Test + @DisplayName("새 이름 주변 검색은 반경 안의 부분 일치 컬렉션만 반환한다") + void findNearbyByBirdName_returnsOnlyMatchingCollectionsWithinRadius() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Point far = gf.createPoint(new Coordinate(127.1000, 37.5665)); + + Bird matchingBird = new BirdBuilder(em).korName("까치").build(); + Bird otherBird = new BirdBuilder(em).korName("직박구리").build(); + + UserBirdCollection matchingCollection = newCollection(owner, matchingBird, near, AccessLevelType.PUBLIC); + newCollection(owner, otherBird, near, AccessLevelType.PUBLIC); + newCollection(owner, matchingBird, far, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(matchingCollection.getId(), result.getFirst().getId()); + } + + @Test + @DisplayName("익명 새 이름 주변 검색은 공개 컬렉션만 반환한다") + void findNearbyByBirdName_anonymous_returnsOnlyPublicCollections() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Bird magpie = new BirdBuilder(em).korName("까치").build(); + + UserBirdCollection publicCollection = newCollection(owner, magpie, near, AccessLevelType.PUBLIC); + newCollection(owner, magpie, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까치", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(publicCollection.getId(), result.getFirst().getId()); + } + @Test @DisplayName("주변 조회는 보호등급 새 컬렉션을 결과에서 제외한다") void findNearby_excludesProtectedBirdCollections() throws Exception { From db9755cce914017f4953b76268f6c219f538cebf Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:56:58 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20=EC=9C=84=EB=8F=84/=EA=B2=BD?= =?UTF-8?q?=EB=8F=84=EC=97=90=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EB=B0=98=EA=B2=BD=20=ED=99=95=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collection/api/CollectionController.java | 5 ++-- .../SearchNearbyCollectionsRequest.java | 14 ++++++--- .../application/CollectionQueryService.java | 10 ++++++- .../CollectionQueryServiceTest.java | 30 +++++++++++-------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index ea298752..4b8c9e1c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -381,8 +381,9 @@ public void deleteCollectionImage( description = """ 현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다. - - 최초 검색 반경은 기본 5km이며 `radiusMeters`로 변경할 수 있습니다. - - 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. + - `radiusMeters`는 필수이며, 프론트 지도 반경을 미터 단위로 전달합니다. + - `radiusMeters`가 1.25km 미만이면 해당 반경을 한 번 검색한 뒤 1.25km로 확장합니다. + - 이후 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. - 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다. - 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다. 클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다. diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java index c70e6d57..3ebde062 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.collection.api.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -14,11 +16,15 @@ public class SearchNearbyCollectionsRequest { @NotNull(message = "위도를 입력해주세요.") - @Schema(description = "검색 중심 위도", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 해요.") + @DecimalMax(value = "90.0", message = "위도는 90 이하여야 해요.") + @Schema(description = "검색 중심 위도 (-90~90)", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) private Double latitude; @NotNull(message = "경도를 입력해주세요.") - @Schema(description = "검색 중심 경도", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 해요.") + @DecimalMax(value = "180.0", message = "경도는 180 이하여야 해요.") + @Schema(description = "검색 중심 경도 (-180~180)", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) private Double longitude; @NotBlank(message = "query를 입력해주세요.") @@ -27,8 +33,8 @@ public class SearchNearbyCollectionsRequest { @NotNull(message = "radiusMeters를 입력해주세요.") @Positive(message = "radiusMeters는 0보다 커야 해요.") - @Schema(description = "최초 검색 반경 (m). 생략 시 5km", example = "5000") - private Double radiusMeters = 5_000D; + @Schema(description = "최초 검색 반경 (m).", example = "10", requiredMode = Schema.RequiredMode.REQUIRED) + private Double radiusMeters; @Positive(message = "limit은 1 이상의 양수여야 해요.") @Max(value = 60, message = "limit은 60 이하여야 해요.") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index e21defdb..81280608 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -41,6 +41,7 @@ @Transactional(readOnly = true) public class CollectionQueryService { + private static final double BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS = 1_250; private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000; private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60; private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60; @@ -229,11 +230,18 @@ public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchN return response; } - radiusMeters = Math.min(radiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + radiusMeters = nextBirdNameSearchRadius(radiusMeters); attempt++; } } + private double nextBirdNameSearchRadius(double currentRadiusMeters) { + if (currentRadiusMeters < BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS) { + return BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS; + } + return Math.min(currentRadiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + } + private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) { int initialLimit = Math.min( requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit, diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 2dec1428..8f0f792a 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -270,15 +270,19 @@ void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResu null, 37.5665, 126.9780, - 5_000.0, + 1_250.0, "까치", null ); - given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60))) + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30))) + given(collectionRepository.findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15))) + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10))) .willReturn(List.of()); given(collectionRepository.findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10))) .willReturn(List.of()); @@ -293,9 +297,11 @@ void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResu assertTrue(response.getItems().isEmpty()); var inOrder = inOrder(collectionRepository); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10)); @@ -314,13 +320,13 @@ void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansi null, 37.5665, 126.9780, - 5_000.0, + 10.0, "까치", 5 ); - given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5))) + given(collectionRepository.findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5))) + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5))) .willReturn(List.of(collection)); given(collectionImageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); @@ -335,7 +341,7 @@ void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansi assertEquals(1, response.getItems().size()); var inOrder = inOrder(collectionRepository); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5)); } } From c22a9478a68d606f79c3601f00d565e6e3ab7c53 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 18:54:44 +0900 Subject: [PATCH 13/15] =?UTF-8?q?chore:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=ED=8E=B8=EC=9D=98=EC=84=B1=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apu/saerok_server/domain/ad/api/AdController.java | 2 +- .../domain/announcement/api/AnnouncementController.java | 2 +- .../saerok_server/domain/auth/api/AuthController.java | 2 +- .../domain/auth/api/LocalAuthController.java | 2 +- .../collection/api/BirdIdSuggestionController.java | 2 +- .../collection/api/CollectionCommentController.java | 4 ++-- .../collection/api/CollectionCommentLikeController.java | 2 +- .../api/CollectionCommentReportController.java | 2 +- .../api/FreeBoardPostCommentReportController.java | 2 +- .../freeboard/api/FreeBoardPostReportController.java | 2 +- .../domain/mock/api/UserBlockController.java | 2 +- .../domain/notification/api/NotificationController.java | 2 +- .../notification/api/NotificationSettingController.java | 2 +- .../domain/notification/api/UserDeviceController.java | 2 +- .../domain/profile/api/ProfileController.java | 2 +- .../saerok_server/domain/user/api/UserController.java | 2 +- src/main/resources/application.yml | 9 ++++++++- 17 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/ad/api/AdController.java b/src/main/java/org/devkor/apu/saerok_server/domain/ad/api/AdController.java index f71bd933..67bc1278 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/ad/api/AdController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/ad/api/AdController.java @@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Ad API", description = "클라이언트 광고 노출 및 이벤트 API") +@Tag(name = "Ads API", description = "클라이언트 광고 노출 및 이벤트 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/ad") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java index 72d83a80..34ef70b0 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Announcement API", description = "서비스 공지사항 조회 API") +@Tag(name = "Announcements API", description = "서비스 공지사항 조회 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/announcements") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java index 1f5176c2..deb5c7f5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java @@ -33,7 +33,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Auth API", description = "소셜 인증 관련 API") +@Tag(name = "Authentication API", description = "소셜 인증 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/auth/") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/LocalAuthController.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/LocalAuthController.java index a9be6262..dec27c1f 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/LocalAuthController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/LocalAuthController.java @@ -14,7 +14,7 @@ @RestController @RequestMapping("${api_prefix}/local/") @RequiredArgsConstructor -@Tag(name = "로컬 테스트", description = "로컬 개발환경 전용 API") +@Tag(name = "Local Test API", description = "로컬 개발환경 전용 API") public class LocalAuthController { private final LocalLoginService localLoginService; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java index f2e7637f..492ddb9a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/BirdIdSuggestionController.java @@ -18,7 +18,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Bird‑ID Suggestion API", description = "조류 ID 동정 의견 관련 API") +@Tag(name = "Bird ID Suggestions API", description = "조류 ID 동정 의견 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/collections") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentController.java index a8ac057e..a8e9be3c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentController.java @@ -22,7 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Collection Comment API", description = "컬렉션 댓글 관련 API") +@Tag(name = "Collection Comments API", description = "컬렉션 댓글 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/collections") @@ -151,4 +151,4 @@ public GetCollectionCommentCountResponse getCommentCount( ) { return commentQueryService.getCommentCount(collectionId); } -} \ No newline at end of file +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentLikeController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentLikeController.java index f0e9a38f..74cc0dd2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentLikeController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentLikeController.java @@ -16,7 +16,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Collection Comment API", description = "컬렉션 댓글 관련 API") +@Tag(name = "Collection Comments API", description = "컬렉션 댓글 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/collections/comments") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentReportController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentReportController.java index b2f9d73c..5d286394 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentReportController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionCommentReportController.java @@ -15,7 +15,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Collection Comment API", description = "컬렉션 댓글 관련 API") +@Tag(name = "Collection Comments API", description = "컬렉션 댓글 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/collections") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostCommentReportController.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostCommentReportController.java index bea94133..23102e9e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostCommentReportController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostCommentReportController.java @@ -15,7 +15,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "FreeBoard Comment API", description = "자유게시판 댓글 관련 API") +@Tag(name = "Free Board API", description = "자유게시판 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/community/freeboard/posts") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostReportController.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostReportController.java index c55642a7..bf4e949d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostReportController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/FreeBoardPostReportController.java @@ -15,7 +15,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "FreeBoard API", description = "자유게시판 관련 API") +@Tag(name = "Free Board API", description = "자유게시판 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/community/freeboard/posts") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/mock/api/UserBlockController.java b/src/main/java/org/devkor/apu/saerok_server/domain/mock/api/UserBlockController.java index d0e66d87..3ab475c6 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/mock/api/UserBlockController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/mock/api/UserBlockController.java @@ -17,7 +17,7 @@ import java.time.LocalDateTime; import java.util.List; -@Tag(name = "User Block API", description = "사용자 차단 API (Mock)") +@Tag(name = "User Blocks API", description = "사용자 차단 API (Mock)") @RestController @PreAuthorize("permitAll()") @RequestMapping("${api_prefix}/users/blocks") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationController.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationController.java index c7516cbd..33908dda 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationController.java @@ -18,7 +18,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Notification API", description = "알림 관련 API") +@Tag(name = "Notifications API", description = "알림 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/notifications") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationSettingController.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationSettingController.java index 101090ab..19ef1b2d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationSettingController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/NotificationSettingController.java @@ -21,7 +21,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Notification API", description = "알림 관련 API") +@Tag(name = "Notifications API", description = "알림 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/notifications/settings") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java index 156fec7e..513f2fc7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java @@ -16,7 +16,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Notification API", description = "알림 관련 API") +@Tag(name = "Notifications API", description = "알림 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/notifications/tokens") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/ProfileController.java b/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/ProfileController.java index e6b7ea17..04be8a06 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/ProfileController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/profile/api/ProfileController.java @@ -14,7 +14,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Profile API", description = "사용자 프로필 조회") +@Tag(name = "Profiles API", description = "사용자 프로필 조회") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/profile") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java index df931a60..ed54a942 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/api/UserController.java @@ -25,7 +25,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.http.HttpStatus; -@Tag(name = "User API", description = "회원 정보 관련 API") +@Tag(name = "Users API", description = "회원 정보 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("${api_prefix}/user/") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 038ef17a..a7f34caf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -53,4 +53,11 @@ notification-batch: max-batches-per-tick: 300 springdoc: - use-fqn: true \ No newline at end of file + use-fqn: true + paths-to-exclude: + - ${api_prefix}/admin/** + swagger-ui: + tags-sorter: alpha + operations-sorter: method + doc-expansion: list + filter: true From 211654b705dbd6639a765ef226be071d63a9e1f0 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 26 Jul 2026 00:29:14 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=EB=8F=84=EA=B0=90=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BB=AC=EB=A0=89=EC=85=98=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CommunityQueryService.java | 11 +++ .../core/repository/CommunityRepository.java | 16 ++++ .../domain/dex/bird/api/BirdController.java | 9 ++- .../api/dto/response/BirdDetailResponse.java | 4 + .../bird/application/BirdQueryService.java | 5 +- .../query/mapper/BirdProfileViewMapper.java | 1 + ...dd_public_bird_collection_recent_index.sql | 3 + .../CommunityQueryServiceTest.java | 27 +++++++ .../repository/CommunityRepositoryTest.java | 33 ++++++++ .../application/BirdQueryServiceTest.java | 81 +++++++++++++++++++ 10 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java index b1bdb233..a390de0a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.community.application; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityCollectionsResponse; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse; @@ -22,6 +23,8 @@ @RequiredArgsConstructor public class CommunityQueryService { + private static final int BIRD_DETAIL_RELATED_COLLECTION_LIMIT = 5; + private final CommunityRepository communityRepository; private final CommunityDataAssembler dataAssembler; private final CommunityWebMapper communityWebMapper; @@ -52,6 +55,14 @@ public GetCommunityCollectionsResponse getRecentCollections(Long userId, Communi return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId)); } + public List getRecentPublicCollectionsByBirdId(Long birdId, Long userId) { + List collections = communityRepository.findRecentPublicCollectionsByBirdId( + birdId, + BIRD_DETAIL_RELATED_COLLECTION_LIMIT + ); + return dataAssembler.toCollectionInfos(collections, userId); + } + public GetCommunityCollectionsResponse getPopularCollections(Long userId, CommunityQueryCommand command) { List collections = communityRepository.findPopularCollections(command); return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId)); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java index 41b05195..94d8e975 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java @@ -32,6 +32,22 @@ public List findRecentPublicCollections(CommunityQueryComman return query.getResultList(); } + /** 특정 조류가 지정된 공개 컬렉션을 최신순으로 조회한다. */ + public List findRecentPublicCollectionsByBirdId(Long birdId, int limit) { + return em.createQuery(""" + SELECT c FROM UserBirdCollection c + JOIN FETCH c.user u + JOIN FETCH c.bird b + WHERE c.accessLevel = :public + AND b.id = :birdId + ORDER BY c.createdAt DESC, c.id DESC + """, UserBirdCollection.class) + .setParameter("public", AccessLevelType.PUBLIC) + .setParameter("birdId", birdId) + .setMaxResults(limit) + .getResultList(); + } + // 인기 있는 컬렉션들을 조회 (좋아요 수가 minLikes 이상인 것들 최신순) public List findPopularCollections(CommunityQueryCommand command) { Query query = em.createQuery(""" diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java index 01463246..c12e6108 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java @@ -11,9 +11,11 @@ import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.*; import org.devkor.apu.saerok_server.domain.dex.bird.application.BirdQueryService; import org.devkor.apu.saerok_server.domain.dex.bird.application.dto.BirdSearchCommand; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; import org.devkor.apu.saerok_server.global.shared.exception.ErrorResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.time.OffsetDateTime; @@ -147,8 +149,11 @@ public ResponseEntity getBirds( } ) public ResponseEntity getBirdDetail( - @Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId) { - BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId); + @Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal != null ? userPrincipal.getId() : null; + BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId); return ResponseEntity.ok(response); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java index 1a0503c5..6709755e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -37,6 +38,9 @@ public class BirdDetailResponse { @Schema(description = "국내 관찰 가능 계절 목록") public List seasonsWithRarity; + @Schema(description = "이 조류가 지정된 공개 컬렉션 목록 (최신순, 최대 5개)") + public List relatedCollections; + @Schema(description = "분류학적 정보") public static class BirdTaxonomy { public String phylumEng; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java index d4a85f34..fae7e882 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.application; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdAutocompleteResponse; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdChangesResponse; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse; @@ -44,6 +45,7 @@ public class BirdQueryService { private final SizeCategoryRulesMapper sizeCategoryRulesMapper; private final SizeCategoryService sizeCategoryService; private final SizeCategoryRulesConfig sizeCategoryRulesConfig; + private final CommunityQueryService communityQueryService; public BirdFullSyncResponse getBirdFullSyncResponse() { List birdProfileViews = birdProfileViewRepository.findAll(); @@ -60,11 +62,12 @@ public BirdFullSyncResponse getBirdFullSyncResponse() { return response; } - public BirdDetailResponse getBirdDetailResponse(Long birdId) { + public BirdDetailResponse getBirdDetailResponse(Long birdId, Long userId) { BirdProfileView birdProfileView = birdProfileViewRepository.findById(birdId) .orElseThrow(() -> new NotFoundException("해당 id의 birdProfileView를 찾지 못했어요")); BirdDetailResponse response = birdProfileViewMapper.toBirdDetailResponse(birdProfileView); response.sizeCategory = sizeCategoryService.getSizeCategory(birdProfileView).getLabel(); + response.relatedCollections = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId); return response; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java index f748e7cc..afaaa97d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java @@ -45,6 +45,7 @@ protected void fillS3Urls( @Mapping(source = "description.description", target = "description") @Mapping(target = "imageUrls", ignore = true) @Mapping(target = "sizeCategory", ignore = true) + @Mapping(target = "relatedCollections", ignore = true) public abstract BirdDetailResponse toBirdDetailResponse(BirdProfileView view); @AfterMapping diff --git a/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql b/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql new file mode 100644 index 00000000..a543810b --- /dev/null +++ b/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_user_bird_collection_public_bird_created_at + ON user_bird_collection (bird_id, created_at DESC, id DESC) + WHERE access_level = 'PUBLIC'; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index 8f7564be..7f55521a 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -223,6 +223,33 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() { assertThat(secondItem.note()).isEqualTo("까치를 발견했어요!"); } + @Test + @DisplayName("도감 상세용 관련 컬렉션은 최대 5개 조회 결과를 기존 카드 형식으로 조립한다") + void getRecentPublicCollectionsByBirdId_assemblesRelatedCollections() { + // Given + Long birdId = 100L; + Long userId = 1L; + User owner = user(2L, "테스트유저"); + Bird bird = bird(birdId, "까치"); + List collections = List.of(collection(10L, owner, bird, "까치를 발견했어요!")); + CommunityCollectionInfo collectionInfo = collectionInfo( + 10L, "https://example.com/image.jpg", "https://example.com/thumbnail.webp", "까치를 발견했어요!", + 1L, 2L, false, null, false, + new CommunityCollectionInfo.BirdInfo(birdId, "까치"), + new CommunityCollectionInfo.UserInfo(2L, "테스트유저", null, null) + ); + given(communityRepository.findRecentPublicCollectionsByBirdId(birdId, 5)).willReturn(collections); + given(dataAssembler.toCollectionInfos(collections, userId)).willReturn(List.of(collectionInfo)); + + // When + List result = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId); + + // Then + assertThat(result).containsExactly(collectionInfo); + then(communityRepository).should().findRecentPublicCollectionsByBirdId(birdId, 5); + then(dataAssembler).should().toCollectionInfos(collections, userId); + } + @Test @DisplayName("커뮤니티 메인 조회 시 자유게시판 최신 글 5건이 포함된다") void getCommunityMain_returnsRecentFreeBoardPosts() { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java index cace7917..9e6a7540 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java @@ -167,6 +167,39 @@ void findRecent_returnsPublicOnly() { assertEquals(publicOld.getId(), result.get(1).getId(), "이전 컬렉션이 다음"); } + @Test + @DisplayName("특정 조류의 공개 컬렉션만 최신순으로 최대 개수만큼 조회") + void findRecentPublicCollectionsByBirdId_returnsLatestPublicCollectionsWithinLimit() { + // given + User user = newUser("user"); + Bird targetBird = newBird("참새"); + Bird otherBird = newBird("까마귀"); + OffsetDateTime now = OffsetDateTime.now(); + + UserBirdCollection oldest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(6)); + UserBirdCollection fifth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(5)); + UserBirdCollection fourth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(4)); + UserBirdCollection third = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(3)); + UserBirdCollection second = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(2)); + UserBirdCollection latest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(1)); + newCollection(user, targetBird, AccessLevelType.PRIVATE, now); + newCollection(user, otherBird, AccessLevelType.PUBLIC, now); + + em.flush(); + em.clear(); + + // when + List result = communityRepository.findRecentPublicCollectionsByBirdId(targetBird.getId(), 5); + + // then + assertEquals(5, result.size()); + assertEquals( + List.of(latest.getId(), second.getId(), third.getId(), fourth.getId(), fifth.getId()), + result.stream().map(UserBirdCollection::getId).toList() + ); + assertFalse(result.stream().anyMatch(collection -> collection.getId().equals(oldest.getId()))); + } + @Test @DisplayName("PopularCollection 스냅샷에 등록된 PUBLIC 컬렉션만 조회하고 좋아요 수와 무관") void findPopular_returnsOnlySnapshotEntries() { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java new file mode 100644 index 00000000..1eccd40a --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java @@ -0,0 +1,81 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.application; + +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; +import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService; +import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse; +import org.devkor.apu.saerok_server.domain.dex.bird.core.dto.SizeCategoryDto; +import org.devkor.apu.saerok_server.domain.dex.bird.core.mapper.BirdMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.service.SizeCategoryService; +import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.BirdProfileViewMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.SizeCategoryRulesMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.query.repository.BirdProfileViewRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; +import org.devkor.apu.saerok_server.global.core.config.feature.SizeCategoryRulesConfig; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class BirdQueryServiceTest { + + private BirdQueryService birdQueryService; + + @Mock private BirdRepository birdRepository; + @Mock private BirdMapper birdMapper; + @Mock private BirdProfileViewRepository birdProfileViewRepository; + @Mock private BirdProfileViewMapper birdProfileViewMapper; + @Mock private SizeCategoryRulesMapper sizeCategoryRulesMapper; + @Mock private SizeCategoryService sizeCategoryService; + @Mock private SizeCategoryRulesConfig sizeCategoryRulesConfig; + @Mock private CommunityQueryService communityQueryService; + + @BeforeEach + void setUp() { + birdQueryService = new BirdQueryService( + birdRepository, + birdMapper, + birdProfileViewRepository, + birdProfileViewMapper, + sizeCategoryRulesMapper, + sizeCategoryService, + sizeCategoryRulesConfig, + communityQueryService + ); + } + + @Test + @DisplayName("도감 상세 응답에 관련 공개 컬렉션을 포함한다") + void getBirdDetailResponse_includesRelatedCollections() { + // Given + Long birdId = 100L; + Long userId = 1L; + BirdProfileView birdProfileView = org.mockito.Mockito.mock(BirdProfileView.class); + BirdDetailResponse birdDetailResponse = new BirdDetailResponse(); + List relatedCollections = List.of(); + + given(birdProfileViewRepository.findById(birdId)).willReturn(Optional.of(birdProfileView)); + given(birdProfileViewMapper.toBirdDetailResponse(birdProfileView)).willReturn(birdDetailResponse); + given(sizeCategoryService.getSizeCategory(birdProfileView)).willReturn(new SizeCategoryDto("small", "참새 크기")); + given(communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId)) + .willReturn(relatedCollections); + + // When + BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId); + + // Then + assertThat(response.sizeCategory).isEqualTo("참새 크기"); + assertThat(response.relatedCollections).isSameAs(relatedCollections); + then(communityQueryService).should().getRecentPublicCollectionsByBirdId(birdId, userId); + } +} From b48485554b0434f89eead066bda3a2f4735af5b6 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 2 Aug 2026 03:42:02 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=EB=88=84=EC=A0=81=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=88=98=20=EC=A0=95=EC=A0=81=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80,=20=ED=94=8C=EB=9E=AB=ED=8F=BC?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=EA=B0=80=EC=9E=85=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/stat/api/AdminStatController.java | 24 ++- .../dto/response/CurrentUserStatResponse.java | 16 ++ .../CurrentUserStatQueryService.java | 88 ++++++++++ .../application/StatAggregationService.java | 93 ++++++---- .../stat/application/StatBatchScheduler.java | 4 +- .../stat/application/StatQueryService.java | 47 ++++- .../admin/stat/core/entity/StatMetric.java | 10 +- .../CurrentUserStatQueryServiceTest.java | 80 +++++++++ ...formSignupTrendAggregationServiceTest.java | 160 ++++++++++++++++++ .../application/StatQueryServiceTest.java | 61 +++++++ 10 files changed, 542 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java index f83134f6..4a992172 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java @@ -11,6 +11,8 @@ import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.StatSeriesResponse; import org.devkor.apu.saerok_server.domain.admin.stat.application.StatAggregationService; import org.devkor.apu.saerok_server.domain.admin.stat.application.StatQueryService; +import org.devkor.apu.saerok_server.domain.admin.stat.application.CurrentUserStatQueryService; +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; import org.devkor.apu.saerok_server.global.shared.util.EnumParser; import org.springframework.http.HttpStatus; @@ -28,6 +30,25 @@ public class AdminStatController { private final StatQueryService queryService; private final StatAggregationService aggregationService; + private final CurrentUserStatQueryService currentUserStatQueryService; + + @GetMapping("/current-users") + @PreAuthorize("@perm.has('ADMIN_STAT_READ')") + @Operation( + summary = "현재 사용자 현황 조회", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 조회 시점의 가입 완료 사용자 현황을 반환합니다. 일별 통계 테이블을 사용하지 않습니다. + 플랫폼별 수는 활성 푸시 토큰을 보유한 사용자 수이며, 한 사용자가 여러 플랫폼에 중복 포함될 수 있습니다. + """, + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = CurrentUserStatResponse.class))) + } + ) + public CurrentUserStatResponse getCurrentUserStats() { + return currentUserStatQueryService.getCurrentUserStats(); + } @GetMapping("/series") @PreAuthorize("@perm.has('ADMIN_STAT_READ')") @@ -37,7 +58,8 @@ public class AdminStatController { description = """ metric 목록을 지정하면, 각 metric에 대한 시계열을 반환합니다. - 단일값: COLLECTION_TOTAL_COUNT, COLLECTION_PRIVATE_RATIO, BIRD_ID_PENDING_COUNT, BIRD_ID_RESOLVED_COUNT → payload.value - - 멀티값: BIRD_ID_RESOLUTION_STATS (min_hours, max_hours, avg_hours, stddev_hours) + - 멀티값: BIRD_ID_RESOLUTION_STATS_28D (min_hours, max_hours, avg_hours, stddev_hours), + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE (IOS, ANDROID 누적 가입 사용자 수) """, responses = { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java new file mode 100644 index 00000000..fce0c528 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java @@ -0,0 +1,16 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Map; + +@Schema(description = "관리자용 현재 사용자 현황 응답 DTO") +public record CurrentUserStatResponse( + @Schema(description = "현재 가입 완료 사용자 수", example = "1250") + long completedUserCount, + @Schema(description = "가입 경로별 현재 가입 완료 사용자 수. 가입 경로가 없는 사용자는 UNKNOWN 키로 반환") + Map signupSourceCounts, + @Schema(description = "플랫폼별 활성 푸시 토큰 보유 사용자 수. 한 사용자가 여러 플랫폼에 중복 포함될 수 있음") + Map activePushUserCountsByPlatform +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java new file mode 100644 index 00000000..c1191ccd --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java @@ -0,0 +1,88 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CurrentUserStatQueryService { + + private static final String UNKNOWN_SIGNUP_SOURCE = "UNKNOWN"; + + private final EntityManager em; + + public CurrentUserStatResponse getCurrentUserStats() { + return new CurrentUserStatResponse( + countCompletedUsers(), + countCompletedUsersBySignupSource(), + countActivePushUsersByPlatform() + ); + } + + private long countCompletedUsers() { + return em.createQuery(""" + SELECT COUNT(u) FROM User u + WHERE u.signupStatus = :completed + AND u.deletedAt IS NULL + """, Long.class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getSingleResult(); + } + + private Map countCompletedUsersBySignupSource() { + Map counts = new LinkedHashMap<>(); + for (SignupSourceType source : SignupSourceType.values()) { + counts.put(source.name(), 0L); + } + counts.put(UNKNOWN_SIGNUP_SOURCE, 0L); + + List rows = em.createQuery(""" + SELECT u.signupSource, COUNT(u) FROM User u + WHERE u.signupStatus = :completed + AND u.deletedAt IS NULL + GROUP BY u.signupSource + """, Object[].class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getResultList(); + + for (Object[] row : rows) { + String key = row[0] == null ? UNKNOWN_SIGNUP_SOURCE : row[0].toString(); + counts.put(key, ((Number) row[1]).longValue()); + } + return counts; + } + + private Map countActivePushUsersByPlatform() { + Map counts = new LinkedHashMap<>(); + for (DevicePlatform platform : DevicePlatform.values()) { + counts.put(platform.name(), 0L); + } + + List rows = em.createQuery(""" + SELECT ud.platform, COUNT(DISTINCT u.id) FROM UserDevice ud + JOIN ud.user u + WHERE ud.token IS NOT NULL + AND u.signupStatus = :completed + AND u.deletedAt IS NULL + GROUP BY ud.platform + """, Object[].class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getResultList(); + + for (Object[] row : rows) { + counts.put(row[0].toString(), ((Number) row[1]).longValue()); + } + return counts; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java index adee1378..4be111c1 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java @@ -46,9 +46,8 @@ public void aggregateFor(LocalDate date, Set metrics) { case USER_DAU -> aggregateUserDau(date); case USER_WAU -> aggregateUserWau(date); case USER_MAU -> aggregateUserMau(date); + case USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE -> aggregateUserDevicePlatformSignupDaily(date); - case USER_SIGNUP_SOURCE_TOTAL -> aggregateUserSignupSourceTotal(date); - case USER_DEVICE_PLATFORM_TOTAL -> aggregateUserDevicePlatformTotal(date); } } } @@ -224,45 +223,79 @@ SELECT COUNT(DISTINCT user_id) FROM user_activity_ping dailyRepo.upsertValue(StatMetric.USER_MAU, date, n.longValue()); } - /** 누적 가입 경로별 가입자 수 (스냅샷): signupCompletedAt < end, signupSource IS NOT NULL */ - private void aggregateUserSignupSourceTotal(LocalDate date) { - var end = endExclusive(date); - - @SuppressWarnings("unchecked") - List rows = em.createQuery(""" - SELECT u.signupSource, COUNT(u) FROM User u - WHERE u.signupCompletedAt < :end - AND u.signupSource IS NOT NULL - GROUP BY u.signupSource - """) - .setParameter("end", end) - .getResultList(); - - Map payload = new HashMap<>(); - for (Object[] row : rows) { - payload.put(row[0].toString(), ((Number) row[1]).longValue()); - } - dailyRepo.upsertPayload(StatMetric.USER_SIGNUP_SOURCE_TOTAL, date, payload); - } - - /** 누적 플랫폼별 유니크 유저 수 (스냅샷): UserDevice.createdAt < end */ - private void aggregateUserDevicePlatformTotal(LocalDate date) { + /** + * 플랫폼별 일일 신규 가입 사용자 수를 저장한다. + * + *

사용자-플랫폼마다 첫 기기 등록만 사용하며, 가입 완료와 첫 기기 등록 중 더 늦은 날을 + * 플랫폼 가입일로 본다. 조회 단계에서 이 일별 증분을 누적해 증가 추이를 만든다.

+ */ + private void aggregateUserDevicePlatformSignupDaily(LocalDate date) { + var start = date.atStartOfDay(KST).toOffsetDateTime(); var end = endExclusive(date); + boolean initialAggregation = dailyRepo + .findLastDateOf(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE) + .isEmpty(); - @SuppressWarnings("unchecked") List rows = em.createQuery(""" - SELECT ud.platform, COUNT(DISTINCT ud.user.id) FROM UserDevice ud - WHERE ud.createdAt < :end + SELECT ud.platform, COUNT(DISTINCT u.id) FROM UserDevice ud + JOIN ud.user u + WHERE ud.createdAt = ( + SELECT MIN(ud2.createdAt) FROM UserDevice ud2 + WHERE ud2.user.id = u.id + AND ud2.platform = ud.platform + ) + AND ( + ( + :initialAggregation = TRUE + AND ( + (u.signupCompletedAt IS NOT NULL + AND u.signupCompletedAt < :end + AND ud.createdAt < :end) + OR (u.signupCompletedAt IS NULL + AND u.signupStatus IN (:completed, :withdrawn) + AND u.joinedAt < :end + AND ud.createdAt < :end) + ) + ) + OR ( + :initialAggregation = FALSE + AND ( + ( + u.signupCompletedAt IS NOT NULL + AND ( + (u.signupCompletedAt >= :start AND u.signupCompletedAt < :end + AND ud.createdAt <= u.signupCompletedAt) + OR (ud.createdAt >= :start AND ud.createdAt < :end + AND u.signupCompletedAt < ud.createdAt) + ) + ) + OR ( + u.signupCompletedAt IS NULL + AND u.signupStatus IN (:completed, :withdrawn) + AND ( + (u.joinedAt >= :start AND u.joinedAt < :end + AND ud.createdAt <= u.joinedAt) + OR (ud.createdAt >= :start AND ud.createdAt < :end + AND u.joinedAt < ud.createdAt) + ) + ) + ) + ) + ) GROUP BY ud.platform - """) + """, Object[].class) + .setParameter("start", start) .setParameter("end", end) + .setParameter("initialAggregation", initialAggregation) + .setParameter("completed", SignupStatusType.COMPLETED) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN) .getResultList(); Map payload = new HashMap<>(); for (Object[] row : rows) { payload.put(row[0].toString(), ((Number) row[1]).longValue()); } - dailyRepo.upsertPayload(StatMetric.USER_DEVICE_PLATFORM_TOTAL, date, payload); + dailyRepo.upsertPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, date, payload); } /* Helpers */ diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java index a9c4ac30..6a09754c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java @@ -42,9 +42,7 @@ public void runDailyAggregation() { StatMetric.USER_DAU, StatMetric.USER_WAU, StatMetric.USER_MAU, - - StatMetric.USER_SIGNUP_SOURCE_TOTAL, - StatMetric.USER_DEVICE_PLATFORM_TOTAL + StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE )) { var last = dailyRepo.findLastDateOf(metric).orElse(null); LocalDate from = (last == null) ? yesterday : last.plusDays(1); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java index 0b3789d6..943954b9 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java @@ -21,15 +21,20 @@ @RequiredArgsConstructor public class StatQueryService { + private static final String UNKNOWN_SIGNUP_SOURCE = "UNKNOWN"; + private final DailyStatRepository dailyRepo; + @SuppressWarnings("deprecation") public StatSeriesResponse getSeries(List metrics, String period) { if (metrics == null || metrics.isEmpty()) return StatSeriesResponse.empty(); LocalDateRange range = parsePeriod(period); List out = new ArrayList<>(); for (StatMetric m : metrics) { - List rows = dailyRepo.findSeriesByMetric(m, range.startDate(), range.endDate()); + List rows = m == StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE + ? dailyRepo.findSeriesByMetric(m, null, range.endDate()) + : dailyRepo.findSeriesByMetric(m, range.startDate(), range.endDate()); if (m == StatMetric.BIRD_ID_RESOLUTION_STATS_28D) { var minSeries = new StatSeriesResponse.ComponentSeries( @@ -55,12 +60,23 @@ public StatSeriesResponse getSeries(List metrics, String period) { out.add(StatSeriesResponse.multi(m.name(), List.of(minSeries, maxSeries, avgSeries, stdSeries))); + } else if (m == StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE) { + List components = Arrays.stream(DevicePlatform.values()) + .map(platform -> cumulativePlatformSeries(platform, rows, range.startDate())) + .toList(); + out.add(StatSeriesResponse.multi(m.name(), components)); + } else if (m == StatMetric.USER_SIGNUP_SOURCE_TOTAL) { - List components = Arrays.stream(SignupSourceType.values()) + List keys = new ArrayList<>(Arrays.stream(SignupSourceType.values()) + .map(SignupSourceType::name) + .toList()); + keys.add(UNKNOWN_SIGNUP_SOURCE); + + List components = keys.stream() .map(src -> new StatSeriesResponse.ComponentSeries( - src.name(), + src, rows.stream().map(s -> - new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(src.name())))).toList() + new StatSeriesResponse.Point(s.getDate(), numberOrZero(s.getPayload().get(src)))).toList() )).toList(); out.add(StatSeriesResponse.multi(m.name(), components)); @@ -69,7 +85,7 @@ public StatSeriesResponse getSeries(List metrics, String period) { .map(p -> new StatSeriesResponse.ComponentSeries( p.name(), rows.stream().map(s -> - new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(p.name())))).toList() + new StatSeriesResponse.Point(s.getDate(), numberOrZero(s.getPayload().get(p.name())))).toList() )).toList(); out.add(StatSeriesResponse.multi(m.name(), components)); @@ -87,6 +103,27 @@ private static Number numberOrNull(Object o) { return (o instanceof Number n) ? n : null; } + private static Number numberOrZero(Object o) { + return (o instanceof Number n) ? n : 0L; + } + + private StatSeriesResponse.ComponentSeries cumulativePlatformSeries( + DevicePlatform platform, + List rows, + LocalDate startDate + ) { + long cumulative = 0L; + List points = new ArrayList<>(); + + for (DailyStat row : rows) { + cumulative += numberOrZero(row.getPayload().get(platform.name())).longValue(); + if (startDate == null || !row.getDate().isBefore(startDate)) { + points.add(new StatSeriesResponse.Point(row.getDate(), cumulative)); + } + } + return new StatSeriesResponse.ComponentSeries(platform.name(), points); + } + private LocalDateRange parsePeriod(String period) { if (period == null || period.isBlank()) { return LocalDateRange.empty(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java index f322c23a..c4dbea73 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java @@ -14,6 +14,12 @@ public enum StatMetric { USER_WAU, // 주간 활성 사용자 수(마지막 7일 rolling) USER_MAU, // 월간 활성 사용자 수(마지막 30일 rolling) - USER_SIGNUP_SOURCE_TOTAL, // 누적 가입 경로별 가입자 수 (스냅샷, 멀티값) — signupCompletedAt 기준 - USER_DEVICE_PLATFORM_TOTAL // 누적 플랫폼별 유니크 유저 수 (스냅샷, 멀티값) — UserDevice.createdAt 기준 + /** @deprecated 현재 현황 API(/admin/stats/current-users)를 사용한다. 기존 일별 데이터 조회 호환용이다. */ + @Deprecated + USER_SIGNUP_SOURCE_TOTAL, + /** @deprecated 현재 현황 API(/admin/stats/current-users)를 사용한다. 기존 일별 데이터 조회 호환용이다. */ + @Deprecated + USER_DEVICE_PLATFORM_TOTAL, + + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE // 플랫폼별 누적 가입 사용자 수 (일별 증분 저장, 멀티값) } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java new file mode 100644 index 00000000..76dd7261 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java @@ -0,0 +1,80 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(CurrentUserStatQueryService.class) +@ActiveProfiles("test") +class CurrentUserStatQueryServiceTest extends AbstractPostgresContainerTest { + + @Autowired TestEntityManager em; + @Autowired CurrentUserStatQueryService sut; + + @Test + @DisplayName("현재 가입 완료 사용자, 가입 경로, 활성 푸시 플랫폼을 함께 집계한다") + void getCurrentUserStats_countsOnlyCurrentCompletedUsersAndActivePushDevices() { + User iosOnly = completedUser(SignupSourceType.INSTAGRAM); + activeDevice(iosOnly, "ios-device-1", DevicePlatform.IOS); + + User bothPlatforms = completedUser(SignupSourceType.FRIEND); + activeDevice(bothPlatforms, "ios-device-2", DevicePlatform.IOS); + activeDevice(bothPlatforms, "android-device-1", DevicePlatform.ANDROID); + + completedUser(null); + + User inactiveDeviceUser = completedUser(SignupSourceType.INSTAGRAM); + UserDevice inactiveDevice = activeDevice(inactiveDeviceUser, "android-device-2", DevicePlatform.ANDROID); + inactiveDevice.deactivateToken(); + + User incompleteUser = new UserBuilder(em).build(); + activeDevice(incompleteUser, "ios-device-3", DevicePlatform.IOS); + + User deletedUser = completedUser(SignupSourceType.COMMUNITY); + activeDevice(deletedUser, "android-device-3", DevicePlatform.ANDROID); + deletedUser.softDelete(); + + em.flush(); + em.clear(); + + CurrentUserStatResponse response = sut.getCurrentUserStats(); + + assertThat(response.completedUserCount()).isEqualTo(4L); + assertThat(response.signupSourceCounts()) + .containsEntry("INSTAGRAM", 2L) + .containsEntry("FRIEND", 1L) + .containsEntry("UNKNOWN", 1L) + .containsEntry("COMMUNITY", 0L); + assertThat(response.activePushUserCountsByPlatform()) + .containsEntry("IOS", 2L) + .containsEntry("ANDROID", 1L); + } + + private User completedUser(SignupSourceType signupSource) { + User user = new UserBuilder(em).build(); + user.setSignupSource(signupSource); + user.setSignupStatus(SignupStatusType.COMPLETED); + return user; + } + + private UserDevice activeDevice(User user, String deviceId, DevicePlatform platform) { + UserDevice device = UserDevice.create(user, deviceId, "token-" + deviceId, platform); + em.persist(device); + return device; + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java new file mode 100644 index 00000000..850623a0 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java @@ -0,0 +1,160 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.BirdIdRequestHistoryRepository; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.DailyStatRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.EnumSet; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ + StatAggregationService.class, + DailyStatRepository.class, + BirdIdRequestHistoryRepository.class +}) +@ActiveProfiles("test") +class PlatformSignupTrendAggregationServiceTest extends AbstractPostgresContainerTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired TestEntityManager em; + @Autowired StatAggregationService sut; + @Autowired DailyStatRepository dailyRepo; + + @Test + @DisplayName("플랫폼 가입 증분은 첫 기기 등록과 가입 완료가 모두 충족된 날에 사용자당 한 번만 저장한다") + void aggregatePlatformSignupDaily_countsFirstPlatformAdoptionOnly() { + User iosOnly = completedUser(); + device(iosOnly, "ios-device-1", DevicePlatform.IOS); + device(iosOnly, "ios-device-2", DevicePlatform.IOS); + + User bothPlatforms = completedUser(); + device(bothPlatforms, "ios-device-3", DevicePlatform.IOS); + device(bothPlatforms, "android-device-1", DevicePlatform.ANDROID); + + User incomplete = new UserBuilder(em).build(); + device(incomplete, "android-device-2", DevicePlatform.ANDROID); + + User withdrawn = completedUser(); + device(withdrawn, "android-device-3", DevicePlatform.ANDROID); + withdrawn.anonymizeForWithdrawal(); + + em.flush(); + em.clear(); + + LocalDate date = LocalDate.now(KST); + sut.aggregateFor(date, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map payload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, date) + .orElseThrow() + .getPayload(); + + assertThat(((Number) payload.get("IOS")).longValue()).isEqualTo(2L); + assertThat(((Number) payload.get("ANDROID")).longValue()).isEqualTo(2L); + } + + @Test + @DisplayName("기기 등록이 가입 완료보다 빠르면 가입 완료일에 플랫폼 가입 증분을 저장한다") + void aggregatePlatformSignupDaily_usesTheLaterOfDeviceRegistrationAndSignupCompletion() { + LocalDate signupDate = LocalDate.now(KST); + User user = new UserBuilder(em).build(); + UserDevice device = UserDevice.create(user, "ios-device-before-signup", "token-before-signup", DevicePlatform.IOS); + em.persist(device); + em.flush(); + + updateDeviceCreatedAt(device.getId(), signupDate.minusDays(1).atTime(10, 0).atZone(KST).toOffsetDateTime()); + completeOn(user.getId(), signupDate.atTime(10, 0).atZone(KST).toOffsetDateTime()); + em.clear(); + + sut.aggregateFor(signupDate, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map payload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, signupDate) + .orElseThrow() + .getPayload(); + + assertThat(((Number) payload.get("IOS")).longValue()).isEqualTo(1L); + } + + @Test + @DisplayName("첫 집계는 기존 플랫폼 가입자를 기준값으로 저장하고 이후 집계에서 다시 더하지 않는다") + void aggregatePlatformSignupDaily_seedsExistingUsersOnlyOnTheFirstAggregation() { + LocalDate firstAggregationDate = LocalDate.now(KST); + User user = new UserBuilder(em).build(); + UserDevice device = UserDevice.create(user, "existing-ios-device", "existing-token", DevicePlatform.IOS); + em.persist(device); + em.flush(); + + completeOn(user.getId(), firstAggregationDate.minusDays(5).atTime(10, 0).atZone(KST).toOffsetDateTime()); + updateDeviceCreatedAt(device.getId(), firstAggregationDate.minusDays(4).atTime(10, 0).atZone(KST).toOffsetDateTime()); + em.clear(); + + sut.aggregateFor(firstAggregationDate, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + sut.aggregateFor(firstAggregationDate.plusDays(1), EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map firstPayload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstAggregationDate) + .orElseThrow() + .getPayload(); + Map nextPayload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstAggregationDate.plusDays(1)) + .orElseThrow() + .getPayload(); + + assertThat(((Number) firstPayload.get("IOS")).longValue()).isEqualTo(1L); + assertThat(nextPayload).doesNotContainKey("IOS"); + } + + private User completedUser() { + User user = new UserBuilder(em).build(); + user.setSignupSource(SignupSourceType.INSTAGRAM); + user.setSignupStatus(SignupStatusType.COMPLETED); + return user; + } + + private void device(User user, String deviceId, DevicePlatform platform) { + em.persist(UserDevice.create(user, deviceId, "token-" + deviceId, platform)); + } + + private void updateDeviceCreatedAt(Long deviceId, OffsetDateTime createdAt) { + em.getEntityManager() + .createNativeQuery("UPDATE user_device SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, deviceId) + .executeUpdate(); + } + + private void completeOn(Long userId, OffsetDateTime completedAt) { + em.getEntityManager() + .createNativeQuery(""" + UPDATE users + SET signup_status = 'COMPLETED', + signup_completed_at = ?1 + WHERE id = ?2 + """) + .setParameter(1, completedAt) + .setParameter(2, userId) + .executeUpdate(); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java new file mode 100644 index 00000000..fbd4fa54 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java @@ -0,0 +1,61 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.StatSeriesResponse; +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.DailyStat; +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.DailyStatRepository; +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 java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StatQueryServiceTest { + + @Mock DailyStatRepository dailyRepo; + @InjectMocks StatQueryService sut; + + @Test + void getSeries_cumulatesDailyPlatformSignupCountsFromBeforeTheRequestedRange() { + LocalDate firstDate = LocalDate.of(2026, 7, 1); + LocalDate startDate = firstDate.plusDays(1); + LocalDate endDate = firstDate.plusDays(2); + when(dailyRepo.findSeriesByMetric(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, null, endDate)) + .thenReturn(List.of( + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstDate, + Map.of("IOS", 2L)), + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, startDate, + Map.of("IOS", 1L, "ANDROID", 3L)), + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, endDate, + Map.of("ANDROID", 2L)) + )); + + StatSeriesResponse response = sut.getSeries( + List.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE), + startDate + "," + endDate + ); + + List components = response.series().getFirst().components(); + assertThat(valuesOf(components, "IOS")).containsExactly(3L, 3L); + assertThat(valuesOf(components, "ANDROID")).containsExactly(3L, 5L); + } + + private List valuesOf(List components, String key) { + return components.stream() + .filter(component -> component.key().equals(key)) + .findFirst() + .orElseThrow() + .points() + .stream() + .map(point -> point.value().longValue()) + .toList(); + } +}