From c337234a8fa87299f0a6b3d69085b352944200ee Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 3 Jul 2026 18:50:01 +0900 Subject: [PATCH 1/2] =?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 2/2] =?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; } + }