diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt index 500e2a5b4..e98df4ff5 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt @@ -91,6 +91,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("배너 ID"), fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배너명"), fieldWithPath("data[].mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), + fieldWithPath("data[].posterUrl").type(JsonFieldType.STRING).description("동영상 배너 대표 이미지 URL").optional(), fieldWithPath("data[].bannerType").type(JsonFieldType.STRING).description("배너 유형"), fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), @@ -148,6 +149,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("data.descriptionB").type(JsonFieldType.STRING).description("배너 설명B").optional(), fieldWithPath("data.descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), fieldWithPath("data.imageUrl").type(JsonFieldType.STRING).description("이미지 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("data.posterUrl").type(JsonFieldType.STRING).description("동영상 배너 대표 이미지 URL").optional(), fieldWithPath("data.textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), fieldWithPath("data.isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), fieldWithPath("data.targetUrl").type(JsonFieldType.VARIES).description("이동 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), @@ -204,6 +206,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("posterUrl").type(JsonFieldType.STRING).description("동영상 배너 대표 이미지 URL (최대 500자)").optional(), fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등, 기본값: RT)").optional(), fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부 (기본값: false)").optional(), fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), @@ -269,6 +272,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("posterUrl").type(JsonFieldType.STRING).description("동영상 배너 대표 이미지 URL (최대 500자)").optional(), fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt index b256d7bdc..17eb9a6c4 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt @@ -13,6 +13,7 @@ object BannerHelper { id: Long = 1L, name: String = "테스트 배너", mediaType: MediaType = MediaType.IMAGE, + posterUrl: String? = "https://example.com/poster.jpg", bannerType: BannerType = BannerType.CURATION, sortOrder: Int = 0, isActive: Boolean = true, @@ -23,6 +24,7 @@ object BannerHelper { id, name, mediaType, + posterUrl, bannerType, sortOrder, isActive, @@ -48,6 +50,7 @@ object BannerHelper { descriptionB: String? = "배너 설명B", descriptionFontColor: String = "#ffffff", imageUrl: String = "https://example.com/banner.jpg", + posterUrl: String? = "https://example.com/poster.jpg", textPosition: TextPosition = TextPosition.RT, isExternalUrl: Boolean = false, targetUrl: String? = null, @@ -67,6 +70,7 @@ object BannerHelper { descriptionB, descriptionFontColor, imageUrl, + posterUrl, textPosition, isExternalUrl, targetUrl, @@ -87,6 +91,7 @@ object BannerHelper { descriptionB: String? = "배너 설명B", descriptionFontColor: String = "#ffffff", imageUrl: String = "https://example.com/banner.jpg", + posterUrl: String? = "https://example.com/poster.jpg", textPosition: String = "RT", isExternalUrl: Boolean = false, targetUrl: String? = null, @@ -102,6 +107,7 @@ object BannerHelper { "descriptionB" to descriptionB, "descriptionFontColor" to descriptionFontColor, "imageUrl" to imageUrl, + "posterUrl" to posterUrl, "textPosition" to textPosition, "isExternalUrl" to isExternalUrl, "targetUrl" to targetUrl, @@ -119,6 +125,7 @@ object BannerHelper { descriptionB: String? = "수정된 설명B", descriptionFontColor: String = "#000000", imageUrl: String = "https://example.com/updated.jpg", + posterUrl: String? = "https://example.com/poster.jpg", textPosition: String = "CENTER", isExternalUrl: Boolean = false, targetUrl: String? = null, @@ -135,6 +142,7 @@ object BannerHelper { "descriptionB" to descriptionB, "descriptionFontColor" to descriptionFontColor, "imageUrl" to imageUrl, + "posterUrl" to posterUrl, "textPosition" to textPosition, "isExternalUrl" to isExternalUrl, "targetUrl" to targetUrl, diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/curation/CurationExpirationStatusJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/curation/CurationExpirationStatusJob.java index dbcfe4c34..fde0a179b 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/curation/CurationExpirationStatusJob.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/curation/CurationExpirationStatusJob.java @@ -14,7 +14,10 @@ public class CurationExpirationStatusJob extends QuartzJobBean { private static final String DEACTIVATE_EXPIRED_CURATIONS_SQL = "UPDATE curation " - + "SET is_active = false, last_modify_at = NOW(), last_modify_by = 'batch-curation-expiration' " + + "SET is_active = false, last_modify_at = NOW(), " + + "last_modify_principal_email = 'batch-curation-expiration', " + + "last_modify_principal_type = 'SYSTEM', " + + "last_modify_principal_id = NULL " + "WHERE is_active = true " + "AND exposure_end_date IS NOT NULL " + "AND exposure_end_date < CURDATE()"; diff --git a/bottlenote-batch/src/test/java/app/batch/bottlenote/curation/CurationExpirationStatusJobTest.java b/bottlenote-batch/src/test/java/app/batch/bottlenote/curation/CurationExpirationStatusJobTest.java index 13c750542..15d034892 100644 --- a/bottlenote-batch/src/test/java/app/batch/bottlenote/curation/CurationExpirationStatusJobTest.java +++ b/bottlenote-batch/src/test/java/app/batch/bottlenote/curation/CurationExpirationStatusJobTest.java @@ -44,6 +44,10 @@ private ArgumentMatcher nativeExpirationUpdateSql() { return sql -> sql.contains("UPDATE curation") && sql.contains("is_active = false") + && sql.contains("last_modify_principal_email = 'batch-curation-expiration'") + && sql.contains("last_modify_principal_type = 'SYSTEM'") + && sql.contains("last_modify_principal_id = NULL") + && !sql.contains("last_modify_by") && sql.contains("is_active = true") && sql.contains("exposure_end_date < CURDATE()"); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java index fa0c5a925..cddc1e126 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java @@ -59,6 +59,10 @@ public class Banner extends BaseEntity { @Column(name = "image_url", nullable = false) private String imageUrl; + @Comment("동영상 배너 대표 이미지 URL") + @Column(name = "poster_url", length = 500) + private String posterUrl; + @Comment("텍스트 위치") @Column(name = "text_position", nullable = false) @Enumerated(EnumType.STRING) @@ -110,6 +114,7 @@ public void update( String descriptionB, String descriptionFontColor, String imageUrl, + String posterUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, @@ -125,6 +130,7 @@ public void update( this.descriptionB = descriptionB; this.descriptionFontColor = descriptionFontColor; this.imageUrl = imageUrl; + this.posterUrl = posterUrl; this.textPosition = textPosition; this.isExternalUrl = isExternalUrl; this.targetUrl = targetUrl; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java index 9c26ef4b9..e79751bf1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java @@ -20,6 +20,7 @@ public record AdminBannerCreateRequest( @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String descriptionFontColor, @NotBlank(message = "BANNER_IMAGE_URL_REQUIRED") String imageUrl, + @Size(max = 500, message = "BANNER_POSTER_URL_MAX_SIZE") String posterUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java index 98d42471e..2149d36be 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java @@ -19,6 +19,7 @@ public record AdminBannerUpdateRequest( @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String descriptionFontColor, @NotBlank(message = "BANNER_IMAGE_URL_REQUIRED") String imageUrl, + @Size(max = 500, message = "BANNER_POSTER_URL_MAX_SIZE") String posterUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java index 68d3a7482..f6c5435c5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java @@ -13,6 +13,7 @@ public record AdminBannerDetailResponse( String descriptionB, String descriptionFontColor, String imageUrl, + String posterUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java index 2b2a2f54c..fde3c6fe1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java @@ -8,6 +8,7 @@ public record AdminBannerListResponse( Long id, String name, MediaType mediaType, + String posterUrl, BannerType bannerType, Integer sortOrder, Boolean isActive, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java index 93c076af8..505befd88 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java @@ -22,6 +22,7 @@ public class BannerResponse { private String descriptionB; private String descriptionFontColor; private String imageUrl; + private String posterUrl; private TextPosition textPosition; private String targetUrl; private Boolean isExternalUrl; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java index b43cb3bc8..912e927ed 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java @@ -33,6 +33,7 @@ public Page searchForAdmin( banner.id, banner.name, banner.mediaType, + banner.posterUrl, banner.bannerType, banner.sortOrder, banner.isActive, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java index 6aab781fa..e18070a5d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java @@ -65,6 +65,7 @@ public AdminBannerDetailResponse getDetail(Long bannerId) { banner.getDescriptionB(), banner.getDescriptionFontColor(), banner.getImageUrl(), + banner.getPosterUrl(), banner.getTextPosition(), banner.getIsExternalUrl(), banner.getTargetUrl(), @@ -99,6 +100,7 @@ public AdminResultResponse create(AdminBannerCreateRequest request) { .descriptionB(request.descriptionB()) .descriptionFontColor(request.descriptionFontColor()) .imageUrl(request.imageUrl()) + .posterUrl(request.posterUrl()) .textPosition(request.textPosition()) .isExternalUrl(request.isExternalUrl()) .targetUrl(request.targetUrl()) @@ -137,6 +139,7 @@ public AdminResultResponse update(Long bannerId, AdminBannerUpdateRequest reques request.descriptionB(), request.descriptionFontColor(), request.imageUrl(), + request.posterUrl(), request.textPosition(), request.isExternalUrl(), request.targetUrl(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java index df59e8219..800426890 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java @@ -31,6 +31,7 @@ public List getActiveBanners(Integer limit) { .descriptionB(banner.getDescriptionB()) .descriptionFontColor(banner.getDescriptionFontColor()) .imageUrl(banner.getImageUrl()) + .posterUrl(banner.getPosterUrl()) .textPosition(banner.getTextPosition()) .targetUrl(banner.getTargetUrl()) .isExternalUrl(banner.getIsExternalUrl()) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/constant/AuditPrincipalType.java b/bottlenote-mono/src/main/java/app/bottlenote/common/constant/AuditPrincipalType.java new file mode 100644 index 000000000..3e1c6b2d9 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/constant/AuditPrincipalType.java @@ -0,0 +1,9 @@ +package app.bottlenote.common.constant; + +public enum AuditPrincipalType { + USER, + ADMIN, + AGENT, + SYSTEM, + ANONYMOUS +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/domain/AuditPrincipal.java b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/AuditPrincipal.java new file mode 100644 index 000000000..512806e04 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/AuditPrincipal.java @@ -0,0 +1,28 @@ +package app.bottlenote.common.domain; + +import app.bottlenote.common.constant.AuditPrincipalType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuditPrincipal { + + @Column(name = "principal_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "principal_type", length = 30) + private AuditPrincipalType type; + + @Column(name = "principal_email") + private String email; +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseEntity.java b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseEntity.java index 9cfc3be92..e72a6e528 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseEntity.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseEntity.java @@ -1,27 +1,7 @@ package app.bottlenote.common.domain; -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import lombok.Getter; -import org.hibernate.annotations.Comment; -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.LastModifiedBy; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; -/** 생성자 , 수정자 정보가 존재하는 entity의 경우 사용 */ -@Getter +/** 생성자와 수정자 정보가 존재하는 기존 entity의 호환용 공통 클래스 */ @MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -public class BaseEntity extends BaseTimeEntity { - - @Comment("최초 생성자") - @CreatedBy - @Column(updatable = false, name = "create_by") - private String createBy; - - @Comment("최종 수정자") - @LastModifiedBy - @Column(name = "last_modify_by") - private String lastModifyBy; -} +public class BaseEntity extends BaseTimeEntity {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseTimeEntity.java b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseTimeEntity.java index dcb1dca74..791d98e1c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseTimeEntity.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/domain/BaseTimeEntity.java @@ -1,17 +1,22 @@ package app.bottlenote.common.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; import lombok.Getter; import org.hibernate.annotations.Comment; +import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.domain.AbstractAggregateRoot; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -/** 생성일, 수정일이 필요한 entity의 경우 사용 */ +/** 생성일, 수정일과 감사 주체가 필요한 entity의 공통 클래스 */ @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) @@ -26,4 +31,30 @@ public class BaseTimeEntity extends AbstractAggregateRoot { @LastModifiedDate @Column(name = "last_modify_at") private LocalDateTime lastModifyAt; + + @CreatedBy + @Embedded + @AttributeOverrides({ + @AttributeOverride( + name = "id", + column = @Column(name = "create_principal_id", updatable = false)), + @AttributeOverride( + name = "type", + column = @Column(name = "create_principal_type", length = 30, updatable = false)), + @AttributeOverride( + name = "email", + column = @Column(name = "create_principal_email", updatable = false)) + }) + private AuditPrincipal createPrincipal; + + @LastModifiedBy + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "id", column = @Column(name = "last_modify_principal_id")), + @AttributeOverride( + name = "type", + column = @Column(name = "last_modify_principal_type", length = 30)), + @AttributeOverride(name = "email", column = @Column(name = "last_modify_principal_email")) + }) + private AuditPrincipal lastModifyPrincipal; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/AuditorAwareImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/AuditorAwareImpl.java index 39e67e923..17edf39f1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/AuditorAwareImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/AuditorAwareImpl.java @@ -1,26 +1,33 @@ package app.bottlenote.global.config.jpa; +import app.bottlenote.common.constant.AuditPrincipalType; +import app.bottlenote.common.domain.AuditPrincipal; +import app.bottlenote.global.security.CustomAdminUserContext; +import app.bottlenote.global.security.CustomUserContext; import java.util.Optional; -import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.AuditorAware; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -@Slf4j -public class AuditorAwareImpl implements AuditorAware { +public class AuditorAwareImpl implements AuditorAware { @Override - public Optional getCurrentAuditor() { - + public Optional getCurrentAuditor() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return Optional.empty(); + } - if (authentication != null && authentication.isAuthenticated()) { - if ("[ROLE_ANONYMOUS]".equals(authentication.getAuthorities().toString())) { - return Optional.of("anonymousUser"); - } - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - return Optional.ofNullable(userDetails.getUsername()); + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomAdminUserContext admin) { + return Optional.of( + new AuditPrincipal(admin.getId(), AuditPrincipalType.ADMIN, admin.getUsername())); + } + if (principal instanceof CustomUserContext user) { + AuditPrincipalType type = + user.getId().equals(-4L) ? AuditPrincipalType.ANONYMOUS : AuditPrincipalType.USER; + Long id = type == AuditPrincipalType.ANONYMOUS ? null : user.getId(); + return Optional.of(new AuditPrincipal(id, type, user.getUsername())); } return Optional.empty(); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/JpaConfig.java b/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/JpaConfig.java index 59651b4c4..01a0a11ca 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/JpaConfig.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/JpaConfig.java @@ -1,5 +1,6 @@ package app.bottlenote.global.config.jpa; +import app.bottlenote.common.domain.AuditPrincipal; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; @@ -13,7 +14,7 @@ public class JpaConfig { @Bean - public AuditorAware auditorAware() { + public AuditorAware auditorAware() { return new AuditorAwareImpl(); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/banner/service/AdminBannerServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/banner/service/AdminBannerServiceTest.java index 7d30b9e97..4037d3397 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/banner/service/AdminBannerServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/banner/service/AdminBannerServiceTest.java @@ -5,6 +5,7 @@ import app.bottlenote.banner.constant.BannerType; import app.bottlenote.banner.domain.Banner; +import app.bottlenote.banner.dto.request.AdminBannerCreateRequest; import app.bottlenote.banner.exception.BannerException; import app.bottlenote.banner.exception.BannerExceptionCode; import app.bottlenote.banner.fixture.InMemoryBannerRepository; @@ -77,6 +78,23 @@ void reorder_whenUnknownIdRequested_throwsException() { .isEqualTo(BannerExceptionCode.BANNER_NOT_FOUND); } + @Test + @DisplayName("배너를 생성할 때 동영상 대표 이미지 URL을 저장한다") + void 배너를_생성할_때_posterUrl을_저장한다() { + AdminBannerCreateRequest request = + AdminBannerCreateRequest.builder() + .name("동영상 배너") + .imageUrl("https://example.com/banner.mp4") + .posterUrl("https://example.com/poster.jpg") + .bannerType(BannerType.CURATION) + .build(); + + adminBannerService.create(request); + + Banner saved = bannerRepository.findAllOrderBySortOrderAsc().getFirst(); + assertThat(saved.getPosterUrl()).isEqualTo("https://example.com/poster.jpg"); + } + private Banner saveBanner(String name, int sortOrder) { return bannerRepository.save( Banner.builder() diff --git a/bottlenote-mono/src/test/java/app/bottlenote/banner/service/BannerQueryServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/banner/service/BannerQueryServiceTest.java new file mode 100644 index 000000000..1f39af5c8 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/banner/service/BannerQueryServiceTest.java @@ -0,0 +1,40 @@ +package app.bottlenote.banner.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.domain.Banner; +import app.bottlenote.banner.dto.response.BannerResponse; +import app.bottlenote.banner.fixture.InMemoryBannerRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("BannerQueryService 단위 테스트") +class BannerQueryServiceTest { + + @Test + @DisplayName("활성 배너를 조회할 때 동영상 대표 이미지 URL을 반환한다") + void 활성_배너를_조회할_때_posterUrl을_반환한다() { + InMemoryBannerRepository repository = new InMemoryBannerRepository(); + repository.save( + Banner.builder() + .name("동영상 배너") + .imageUrl("https://example.com/banner.mp4") + .posterUrl("https://example.com/poster.jpg") + .bannerType(BannerType.CURATION) + .sortOrder(1) + .isActive(true) + .build()); + BannerQueryService service = new BannerQueryService(repository); + + List result = service.getActiveBanners(1); + + assertThat(result) + .singleElement() + .extracting(BannerResponse::getPosterUrl) + .isEqualTo("https://example.com/poster.jpg"); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java index e7776782a..d4bb234fc 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java @@ -5,6 +5,9 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; +import app.bottlenote.banner.domain.Banner; import app.bottlenote.banner.dto.response.BannerResponse; import app.bottlenote.banner.fixture.BannerTestFactory; import app.bottlenote.global.data.response.GlobalResponse; @@ -117,6 +120,26 @@ void test_4() throws Exception { assertEquals(3, banners.size()); } + @DisplayName("동영상 배너는 대표 이미지 URL을 반환한다.") + @Test + void 동영상_배너는_posterUrl을_반환한다() throws Exception { + bannerTestFactory.persistBanner( + Banner.builder() + .name("동영상 배너") + .imageUrl("https://example.com/banner.mp4") + .posterUrl("https://example.com/poster.jpg") + .mediaType(MediaType.VIDEO) + .bannerType(BannerType.CURATION) + .sortOrder(1) + .isActive(true)); + + MvcTestResult result = + mockMvcTester.get().uri("/api/v1/banners").contentType(APPLICATION_JSON).exchange(); + + List banners = extractDataAsList(result, new TypeReference<>() {}); + assertEquals("https://example.com/poster.jpg", banners.getFirst().getPosterUrl()); + } + @DisplayName("활성 배너가 없으면 빈 배열을 반환한다.") @Test void test_5() throws Exception { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java index 5a665583e..85ebccc9f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java @@ -69,7 +69,8 @@ void test_1() throws Exception { Review savedReview = reviewRepository.findById(review.getId()).orElseGet(null); - assertEquals(user.getEmail(), savedReview.getCreateBy()); + assertEquals(user.getId(), savedReview.getCreatePrincipal().getId()); + assertEquals(user.getEmail(), savedReview.getCreatePrincipal().getEmail()); } private ReviewCreateRequest withoutImages(ReviewCreateRequest request) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingTest.java index 66fd10d3c..67d3abd72 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingTest.java @@ -1,69 +1,93 @@ package app.bottlenote.global.config; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; +import app.bottlenote.common.constant.AuditPrincipalType; +import app.bottlenote.common.domain.AuditPrincipal; import app.bottlenote.global.config.jpa.AuditorAwareImpl; +import app.bottlenote.global.security.CustomAdminUserContext; +import app.bottlenote.global.security.CustomUserContext; +import app.bottlenote.user.constant.AdminRole; +import app.bottlenote.user.constant.UserType; +import app.bottlenote.user.domain.AdminUser; +import app.bottlenote.user.domain.User; +import java.util.List; import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; @Tag("unit") @DisplayName("[unit] [infra] JpaAuditing") -@ExtendWith(MockitoExtension.class) class JpaAuditingTest { - @Mock private SecurityContext securityContext; + private final AuditorAwareImpl auditorAware = new AuditorAwareImpl(); - @Mock private Authentication authentication; + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } - @Mock private UserDetails userDetails; + @Test + @DisplayName("사용자로 인증할 때 사용자 principal을 감사 주체로 반환한다") + void 사용자로_인증할_때_사용자_principal을_반환한다() { + User user = + User.builder() + .id(1L) + .email("user@example.com") + .nickName("user") + .role(UserType.ROLE_USER) + .build(); + CustomUserContext principal = + new CustomUserContext(user, List.of(new SimpleGrantedAuthority(UserType.ROLE_USER.name()))); + authenticate(principal); - @InjectMocks private AuditorAwareImpl auditorAware; + Optional result = auditorAware.getCurrentAuditor(); - @BeforeEach - void setUp() { - SecurityContextHolder.setContext(securityContext); + assertThat(result).isPresent(); + assertThat(result.orElseThrow().getId()).isEqualTo(1L); + assertThat(result.orElseThrow().getType()).isEqualTo(AuditPrincipalType.USER); + assertThat(result.orElseThrow().getEmail()).isEqualTo("user@example.com"); } - @DisplayName("현재 로그인 한 유저가 Auditing 된다.") @Test - void auditing_success_when_is_authenticated() { - // given - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.isAuthenticated()).thenReturn(true); - when(authentication.getPrincipal()).thenReturn(userDetails); - when(userDetails.getUsername()).thenReturn("testUser"); + @DisplayName("관리자로 인증할 때 관리자 principal을 감사 주체로 반환한다") + void 관리자로_인증할_때_관리자_principal을_반환한다() { + AdminUser admin = + AdminUser.builder() + .id(2L) + .email("admin@example.com") + .password("encoded") + .name("admin") + .roles(List.of(AdminRole.ROOT_ADMIN)) + .build(); + CustomAdminUserContext principal = + new CustomAdminUserContext( + admin, List.of(new SimpleGrantedAuthority(AdminRole.ROOT_ADMIN.name()))); + authenticate(principal); - // then - Optional currentAuditor = auditorAware.getCurrentAuditor(); + Optional result = auditorAware.getCurrentAuditor(); - // ghen - assertEquals("testUser", currentAuditor.get()); + assertThat(result).isPresent(); + assertThat(result.orElseThrow().getId()).isEqualTo(2L); + assertThat(result.orElseThrow().getType()).isEqualTo(AuditPrincipalType.ADMIN); + assertThat(result.orElseThrow().getEmail()).isEqualTo("admin@example.com"); } - @DisplayName("로그인하지 않은 유저는 Auditing되지 않는다.") @Test - void auditing_fail_when_is_not_authenticated() { - // Given - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.isAuthenticated()).thenReturn(false); - - // When - Optional currentAuditor = auditorAware.getCurrentAuditor(); + @DisplayName("인증되지 않았을 때 감사 주체를 반환하지 않는다") + void 인증되지_않았을_때_감사_주체를_반환하지_않는다() { + assertThat(auditorAware.getCurrentAuditor()).isEmpty(); + } - // Then - assertTrue(currentAuditor.isEmpty()); + private void authenticate(UserDetails principal) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities())); } } diff --git a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java index 2461348b5..a78012f97 100644 --- a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java @@ -83,6 +83,7 @@ void getActiveBanners() throws Exception { .descriptionB("소개합니다.") .descriptionFontColor("ffffff") .imageUrl("https://cdn.bottle-note.com/banners/partner.jpg") + .posterUrl("https://cdn.bottle-note.com/banners/partner-poster.jpg") .textPosition(TextPosition.RT) .targetUrl("/partners/brand-abc") .isExternalUrl(false) @@ -117,6 +118,7 @@ void getActiveBanners() throws Exception { fieldWithPath("data[].descriptionB").description("배너 설명 파트 B").optional(), fieldWithPath("data[].descriptionFontColor").description("배너 설명 텍스트 색상 (HEX)"), fieldWithPath("data[].imageUrl").description("배너 이미지 URL"), + fieldWithPath("data[].posterUrl").description("동영상 배너 대표 이미지 URL").optional(), fieldWithPath("data[].textPosition").description("텍스트 위치 (하단 TextPosition 참조)"), fieldWithPath("data[].targetUrl").description("클릭 시 이동할 URL").optional(), fieldWithPath("data[].isExternalUrl") diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java index 4443cccbe..1c9a87dbe 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java @@ -95,6 +95,7 @@ public Page searchForAdmin( b.getId(), b.getName(), b.getMediaType(), + b.getPosterUrl(), b.getBannerType(), b.getSortOrder(), b.getIsActive(), diff --git a/git.environment-variables b/git.environment-variables index 6611431cb..9c896cb3b 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 6611431cbdcd2f0a5a0d730cb1ec45b39c656dcb +Subproject commit 9c896cb3bc6a4f452e3c19c11181dba220f22b34