Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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("활성화 상태"),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ object BannerHelper {
id,
name,
mediaType,
posterUrl,
bannerType,
sortOrder,
isActive,
Expand All @@ -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,
Expand All @@ -67,6 +70,7 @@ object BannerHelper {
descriptionB,
descriptionFontColor,
imageUrl,
posterUrl,
textPosition,
isExternalUrl,
targetUrl,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배포 호환성 확인이 필요합니다. V5가 last_modify_by를 즉시 rename하지만 표준 backend release는 Product/Admin 이미지만 갱신하고 batch 이미지는 함께 배포하지 않습니다. 따라서 migration 적용 후 기존 batch가 구 컬럼으로 실행되어 실패하며, 기본 RollingUpdate 중인 기존 Product/Admin pod도 같은 영향을 받을 수 있습니다. V5에서는 신규 principal 컬럼을 추가하되 구 컬럼을 유지하고, Product/Admin/Batch 전환 후 후속 migration에서 구 컬럼을 제거하는 expand-contract 방식을 권장합니다.

+ "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()";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ private ArgumentMatcher<String> 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()");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -110,6 +114,7 @@ public void update(
String descriptionB,
String descriptionFontColor,
String imageUrl,
String posterUrl,
TextPosition textPosition,
Boolean isExternalUrl,
String targetUrl,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public record AdminBannerDetailResponse(
String descriptionB,
String descriptionFontColor,
String imageUrl,
String posterUrl,
TextPosition textPosition,
Boolean isExternalUrl,
String targetUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public record AdminBannerListResponse(
Long id,
String name,
MediaType mediaType,
String posterUrl,
BannerType bannerType,
Integer sortOrder,
Boolean isActive,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public Page<AdminBannerListResponse> searchForAdmin(
banner.id,
banner.name,
banner.mediaType,
banner.posterUrl,
banner.bannerType,
banner.sortOrder,
banner.isActive,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public AdminBannerDetailResponse getDetail(Long bannerId) {
banner.getDescriptionB(),
banner.getDescriptionFontColor(),
banner.getImageUrl(),
banner.getPosterUrl(),
banner.getTextPosition(),
banner.getIsExternalUrl(),
banner.getTargetUrl(),
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public List<BannerResponse> getActiveBanners(Integer limit) {
.descriptionB(banner.getDescriptionB())
.descriptionFontColor(banner.getDescriptionFontColor())
.imageUrl(banner.getImageUrl())
.posterUrl(banner.getPosterUrl())
.textPosition(banner.getTextPosition())
.targetUrl(banner.getTargetUrl())
.isExternalUrl(banner.getIsExternalUrl())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.bottlenote.common.constant;

public enum AuditPrincipalType {
USER,
ADMIN,
AGENT,
SYSTEM,
ANONYMOUS
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -26,4 +31,30 @@ public class BaseTimeEntity extends AbstractAggregateRoot<BaseEntity> {
@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;
}
Original file line number Diff line number Diff line change
@@ -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<String> {
public class AuditorAwareImpl implements AuditorAware<AuditPrincipal> {

@Override
public Optional<String> getCurrentAuditor() {

public Optional<AuditPrincipal> getCurrentAuditor() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return Optional.empty();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SYSTEM은 인증 정보가 없다는 이유로 기본 반환하지 않고, scheduler·batch·내부 자동화처럼 시스템 작업임이 명시된 실행 경로에서만 주입하는 정책으로 맞춰주세요. 일반 미인증 context는 현재처럼 자동으로 SYSTEM 처리하지 않아야 합니다. 명시적 automation → SYSTEM, 일반 미인증 → 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();
}
Expand Down
Loading
Loading