From 055b43d047a7337ba071ac771376ba31139db90d Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:18:22 +0900 Subject: [PATCH 1/7] feat: add ad management ui (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ad management ui * style: refresh ad management styling * fix: repair placement slot filter expression * chore: AdController 및 광고 관리 UI 코드 리포매팅 및 정리 --- .../infra/CurrentAdminClient.java | 24 +- .../saerok_admin/infra/ad/AdminAdClient.java | 138 +++ .../infra/ad/dto/AdImagePresignResponse.java | 7 + .../ad/dto/AdminAdImagePresignRequest.java | 4 + .../infra/ad/dto/AdminAdListResponse.java | 19 + .../ad/dto/AdminAdPlacementListResponse.java | 24 + .../ad/dto/AdminCreateAdPlacementRequest.java | 13 + .../infra/ad/dto/AdminCreateAdRequest.java | 10 + .../infra/ad/dto/AdminCreateSlotRequest.java | 9 + .../infra/ad/dto/AdminSlotListResponse.java | 18 + .../ad/dto/AdminUpdateAdPlacementRequest.java | 12 + .../infra/ad/dto/AdminUpdateAdRequest.java | 10 + .../infra/ad/dto/AdminUpdateSlotRequest.java | 8 + .../apu/saerok_admin/web/AdController.java | 1026 +++++++++++++++++ .../web/view/CurrentAdminProfile.java | 42 +- .../apu/saerok_admin/web/view/ad/AdForm.java | 22 + .../saerok_admin/web/view/ad/AdListItem.java | 20 + .../web/view/ad/AdPlacementForm.java | 16 + .../web/view/ad/AdPlacementGroup.java | 15 + .../web/view/ad/AdPlacementItem.java | 64 + .../saerok_admin/web/view/ad/AdSlotForm.java | 14 + .../web/view/ad/AdSlotListItem.java | 19 + .../web/view/ad/AdSummaryMetrics.java | 8 + .../web/view/ad/PlacementTimeStatus.java | 7 + src/main/resources/static/css/ads.css | 343 ++++++ .../resources/static/js/ads-image-upload.js | 76 ++ src/main/resources/templates/ads/ad-form.html | 67 ++ src/main/resources/templates/ads/list.html | 423 +++++++ .../templates/ads/placement-form.html | 101 ++ .../resources/templates/ads/slot-form.html | 86 ++ .../templates/fragments/_sidebar.html | 18 +- 31 files changed, 2655 insertions(+), 8 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/infra/ad/AdminAdClient.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdImagePresignResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdImagePresignRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdListResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdPlacementListResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdPlacementRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateSlotRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminSlotListResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdPlacementRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateSlotRequest.java create mode 100644 src/main/java/apu/saerok_admin/web/AdController.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdForm.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdListItem.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdPlacementForm.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdSlotForm.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/AdSummaryMetrics.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ad/PlacementTimeStatus.java create mode 100644 src/main/resources/static/css/ads.css create mode 100644 src/main/resources/static/js/ads-image-upload.js create mode 100644 src/main/resources/templates/ads/ad-form.html create mode 100644 src/main/resources/templates/ads/list.html create mode 100644 src/main/resources/templates/ads/placement-form.html create mode 100644 src/main/resources/templates/ads/slot-form.html diff --git a/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java b/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java index b4cd734..58d2a88 100644 --- a/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java +++ b/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java @@ -12,11 +12,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.util.UriBuilder; -import org.springframework.util.StringUtils; @Component public class CurrentAdminClient { @@ -57,11 +57,14 @@ public Optional fetchCurrentAdminProfile() { return Optional.empty(); } + List roles = response.roles() != null ? List.copyOf(response.roles()) : List.of(); + return Optional.of(new CurrentAdminProfile( response.nickname(), response.email(), response.profileImageUrl(), - toRoleDescriptions(response.roles()) + toRoleDescriptions(roles), + normalizeRoleCodes(roles) )); } catch (RestClientResponseException exception) { log.warn( @@ -118,4 +121,21 @@ private List toRoleDescriptions(List roles) { return descriptions.isEmpty() ? List.of() : List.copyOf(descriptions); } + + private List normalizeRoleCodes(List roles) { + if (roles == null || roles.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String role : roles) { + if (!StringUtils.hasText(role)) { + continue; + } + normalized.add(role.toUpperCase(Locale.ROOT)); + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); + } } diff --git a/src/main/java/apu/saerok_admin/infra/ad/AdminAdClient.java b/src/main/java/apu/saerok_admin/infra/ad/AdminAdClient.java new file mode 100644 index 0000000..0d74045 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/AdminAdClient.java @@ -0,0 +1,138 @@ +package apu.saerok_admin.infra.ad; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.ad.dto.AdImagePresignResponse; +import apu.saerok_admin.infra.ad.dto.AdminAdImagePresignRequest; +import apu.saerok_admin.infra.ad.dto.AdminAdListResponse; +import apu.saerok_admin.infra.ad.dto.AdminAdPlacementListResponse; +import apu.saerok_admin.infra.ad.dto.AdminCreateAdPlacementRequest; +import apu.saerok_admin.infra.ad.dto.AdminCreateAdRequest; +import apu.saerok_admin.infra.ad.dto.AdminCreateSlotRequest; +import apu.saerok_admin.infra.ad.dto.AdminSlotListResponse; +import apu.saerok_admin.infra.ad.dto.AdminUpdateAdPlacementRequest; +import apu.saerok_admin.infra.ad.dto.AdminUpdateAdRequest; +import apu.saerok_admin.infra.ad.dto.AdminUpdateSlotRequest; +import java.net.URI; +import java.util.List; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminAdClient { + + private static final String[] ADMIN_AD_SEGMENTS = {"admin", "ad"}; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminAdClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + List missing = saerokApiProps.missingPrefixSegments(); + this.missingPrefixSegments = missing.toArray(new String[0]); + } + + public AdminAdListResponse listAds() { + return get(AdminAdListResponse.class, "list"); + } + + public AdminAdListResponse.Item createAd(AdminCreateAdRequest request) { + return post(AdminAdListResponse.Item.class, request, "create"); + } + + public AdminAdListResponse.Item updateAd(Long adId, AdminUpdateAdRequest request) { + return put(AdminAdListResponse.Item.class, request, adId.toString()); + } + + public void deleteAd(Long adId) { + delete(adId.toString()); + } + + public AdImagePresignResponse generateAdImagePresignUrl(AdminAdImagePresignRequest request) { + return post(AdImagePresignResponse.class, request, "image", "presign"); + } + + public AdminSlotListResponse listSlots() { + return get(AdminSlotListResponse.class, "slot"); + } + + public AdminSlotListResponse.Item createSlot(AdminCreateSlotRequest request) { + return post(AdminSlotListResponse.Item.class, request, "slot"); + } + + public AdminSlotListResponse.Item updateSlot(Long slotId, AdminUpdateSlotRequest request) { + return put(AdminSlotListResponse.Item.class, request, "slot", slotId.toString()); + } + + public void deleteSlot(Long slotId) { + delete("slot", slotId.toString()); + } + + public AdminAdPlacementListResponse listPlacements() { + return get(AdminAdPlacementListResponse.class, "placement"); + } + + public AdminAdPlacementListResponse.Item createPlacement(AdminCreateAdPlacementRequest request) { + return post(AdminAdPlacementListResponse.Item.class, request, "placement"); + } + + public AdminAdPlacementListResponse.Item updatePlacement(Long placementId, AdminUpdateAdPlacementRequest request) { + return put(AdminAdPlacementListResponse.Item.class, request, "placement", placementId.toString()); + } + + public void deletePlacement(Long placementId) { + delete("placement", placementId.toString()); + } + + private T get(Class responseType, String... segments) { + T response = saerokRestClient.get() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin ad API"); + } + return response; + } + + private T post(Class responseType, Object body, String... segments) { + T response = saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin ad API"); + } + return response; + } + + private T put(Class responseType, Object body, String... segments) { + T response = saerokRestClient.method(HttpMethod.PUT) + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin ad API"); + } + return response; + } + + private void delete(String... segments) { + saerokRestClient.delete() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .toBodilessEntity(); + } + + private URI buildUri(UriBuilder builder, String... segments) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_AD_SEGMENTS); + builder.pathSegment(segments); + return builder.build(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdImagePresignResponse.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdImagePresignResponse.java new file mode 100644 index 0000000..21b1a18 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdImagePresignResponse.java @@ -0,0 +1,7 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdImagePresignResponse( + String presignedUrl, + String objectKey +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdImagePresignRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdImagePresignRequest.java new file mode 100644 index 0000000..6fcd979 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdImagePresignRequest.java @@ -0,0 +1,4 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdminAdImagePresignRequest(String contentType) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdListResponse.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdListResponse.java new file mode 100644 index 0000000..9db10ab --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdListResponse.java @@ -0,0 +1,19 @@ +package apu.saerok_admin.infra.ad.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminAdListResponse(List items) { + + public record Item( + Long id, + String name, + String memo, + String imageUrl, + String contentType, + String targetUrl, + OffsetDateTime createdAt, + OffsetDateTime updatedAt + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdPlacementListResponse.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdPlacementListResponse.java new file mode 100644 index 0000000..caa61ce --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminAdPlacementListResponse.java @@ -0,0 +1,24 @@ +package apu.saerok_admin.infra.ad.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminAdPlacementListResponse(List items) { + + public record Item( + Long id, + Long adId, + String adName, + String adImageUrl, + Long slotId, + String slotName, + LocalDate startDate, + LocalDate endDate, + Short weight, + Boolean enabled, + OffsetDateTime createdAt, + OffsetDateTime updatedAt + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdPlacementRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdPlacementRequest.java new file mode 100644 index 0000000..9622e11 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdPlacementRequest.java @@ -0,0 +1,13 @@ +package apu.saerok_admin.infra.ad.dto; + +import java.time.LocalDate; + +public record AdminCreateAdPlacementRequest( + Long adId, + Long slotId, + LocalDate startDate, + LocalDate endDate, + Short weight, + Boolean enabled +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdRequest.java new file mode 100644 index 0000000..780aee9 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateAdRequest.java @@ -0,0 +1,10 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdminCreateAdRequest( + String name, + String memo, + String objectKey, + String contentType, + String targetUrl +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateSlotRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateSlotRequest.java new file mode 100644 index 0000000..dbaebda --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminCreateSlotRequest.java @@ -0,0 +1,9 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdminCreateSlotRequest( + String name, + String memo, + Double fallbackRatio, + Integer ttlSeconds +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminSlotListResponse.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminSlotListResponse.java new file mode 100644 index 0000000..19f9f55 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminSlotListResponse.java @@ -0,0 +1,18 @@ +package apu.saerok_admin.infra.ad.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminSlotListResponse(List items) { + + public record Item( + Long id, + String name, + String memo, + Double fallbackRatio, + Integer ttlSeconds, + OffsetDateTime createdAt, + OffsetDateTime updatedAt + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdPlacementRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdPlacementRequest.java new file mode 100644 index 0000000..76ecd74 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdPlacementRequest.java @@ -0,0 +1,12 @@ +package apu.saerok_admin.infra.ad.dto; + +import java.time.LocalDate; + +public record AdminUpdateAdPlacementRequest( + Long slotId, + LocalDate startDate, + LocalDate endDate, + Short weight, + Boolean enabled +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdRequest.java new file mode 100644 index 0000000..1418d1d --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateAdRequest.java @@ -0,0 +1,10 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdminUpdateAdRequest( + String name, + String memo, + String objectKey, + String contentType, + String targetUrl +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateSlotRequest.java b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateSlotRequest.java new file mode 100644 index 0000000..cd8a4e8 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/ad/dto/AdminUpdateSlotRequest.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.ad.dto; + +public record AdminUpdateSlotRequest( + String memo, + Double fallbackRatio, + Integer ttlSeconds +) { +} diff --git a/src/main/java/apu/saerok_admin/web/AdController.java b/src/main/java/apu/saerok_admin/web/AdController.java new file mode 100644 index 0000000..cf878eb --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/AdController.java @@ -0,0 +1,1026 @@ + +package apu.saerok_admin.web; + +import apu.saerok_admin.infra.ad.AdminAdClient; +import apu.saerok_admin.infra.ad.dto.AdImagePresignResponse; +import apu.saerok_admin.infra.ad.dto.AdminAdImagePresignRequest; +import apu.saerok_admin.infra.ad.dto.AdminAdListResponse; +import apu.saerok_admin.infra.ad.dto.AdminAdPlacementListResponse; +import apu.saerok_admin.infra.ad.dto.AdminCreateAdPlacementRequest; +import apu.saerok_admin.infra.ad.dto.AdminCreateAdRequest; +import apu.saerok_admin.infra.ad.dto.AdminCreateSlotRequest; +import apu.saerok_admin.infra.ad.dto.AdminSlotListResponse; +import apu.saerok_admin.infra.ad.dto.AdminUpdateAdPlacementRequest; +import apu.saerok_admin.infra.ad.dto.AdminUpdateAdRequest; +import apu.saerok_admin.infra.ad.dto.AdminUpdateSlotRequest; +import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.CurrentAdminProfile; +import apu.saerok_admin.web.view.ad.AdForm; +import apu.saerok_admin.web.view.ad.AdListItem; +import apu.saerok_admin.web.view.ad.AdPlacementForm; +import apu.saerok_admin.web.view.ad.AdPlacementGroup; +import apu.saerok_admin.web.view.ad.AdPlacementItem; +import apu.saerok_admin.web.view.ad.AdSlotForm; +import apu.saerok_admin.web.view.ad.AdSlotListItem; +import apu.saerok_admin.web.view.ad.AdSummaryMetrics; +import apu.saerok_admin.web.view.ad.PlacementTimeStatus; +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/ads") +public class AdController { + + private static final Logger log = LoggerFactory.getLogger(AdController.class); + + private final AdminAdClient adminAdClient; + private final Clock clock; + + public AdController(AdminAdClient adminAdClient, Clock clock) { + this.adminAdClient = adminAdClient; + this.clock = clock; + } + + @GetMapping + public String index(@RequestParam(name = "tab", defaultValue = "ads") String tab, + @RequestParam(name = "adQuery", required = false) String adQuery, + @RequestParam(name = "slotFilter", required = false) Long slotFilter, + @RequestParam(name = "period", defaultValue = "all") String periodFilter, + @RequestParam(name = "status", defaultValue = "all") String statusFilter, + Model model) { + model.addAttribute("pageTitle", "광고 관리"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.active("광고 관리") + )); + model.addAttribute("toastMessages", List.of()); + + LocalDate today = LocalDate.now(clock); + + List adItems = List.of(); + String adsLoadError = null; + try { + AdminAdListResponse response = adminAdClient.listAds(); + List rawItems = Optional.ofNullable(response) + .map(AdminAdListResponse::items) + .orElseGet(List::of); + adItems = rawItems.stream() + .map(this::toAdListItem) + .sorted(Comparator.comparing(AdListItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load ads. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + adsLoadError = "광고 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load ads.", exception); + adsLoadError = "광고 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } + + List placementItems = List.of(); + String placementsLoadError = null; + try { + AdminAdPlacementListResponse placementResponse = adminAdClient.listPlacements(); + List rawPlacements = Optional.ofNullable(placementResponse) + .map(AdminAdPlacementListResponse::items) + .orElseGet(List::of); + placementItems = rawPlacements.stream() + .map(item -> toPlacementItem(item, today)) + .sorted(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load ad placements. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + placementsLoadError = "광고 노출 스케줄을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load ad placements.", exception); + placementsLoadError = "광고 노출 스케줄을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } + + Map placementCountsBySlot = placementItems.stream() + .collect(Collectors.groupingBy(AdPlacementItem::slotId, Collectors.counting())); + + List slotItems = List.of(); + String slotsLoadError = null; + try { + AdminSlotListResponse slotResponse = adminAdClient.listSlots(); + List rawSlots = Optional.ofNullable(slotResponse) + .map(AdminSlotListResponse::items) + .orElseGet(List::of); + slotItems = rawSlots.stream() + .map(item -> toSlotListItem(item, placementCountsBySlot)) + .sorted(Comparator.comparing(AdSlotListItem::code, Comparator.nullsLast(String::compareToIgnoreCase))) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load ad slots. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + slotsLoadError = "광고 위치 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load ad slots.", exception); + slotsLoadError = "광고 위치 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } + + List filteredAds = filterAds(adItems, adQuery); + List filteredPlacements = filterPlacements(placementItems, slotFilter, periodFilter, statusFilter, today); + Map slotMap = slotItems.stream() + .collect(Collectors.toMap(AdSlotListItem::id, item -> item, (left, right) -> left, LinkedHashMap::new)); + + List placementGroups = buildPlacementGroups(slotItems, slotMap, filteredPlacements, slotFilter); + + int activePlacementCount = (int) placementItems.stream() + .filter(item -> item.enabled() && item.timeStatus() == PlacementTimeStatus.ACTIVE) + .count(); + AdSummaryMetrics summaryMetrics = new AdSummaryMetrics(adItems.size(), slotItems.size(), activePlacementCount); + + model.addAttribute("tab", normalizeTab(tab)); + model.addAttribute("summary", summaryMetrics); + model.addAttribute("ads", filteredAds); + model.addAttribute("adsLoadError", adsLoadError); + model.addAttribute("adQuery", adQuery != null ? adQuery : ""); + model.addAttribute("slots", slotItems); + model.addAttribute("slotsLoadError", slotsLoadError); + model.addAttribute("placementsLoadError", placementsLoadError); + model.addAttribute("placementGroups", placementGroups); + model.addAttribute("placementFilterSlot", slotFilter); + model.addAttribute("placementFilterPeriod", normalizePeriod(periodFilter)); + model.addAttribute("placementFilterStatus", normalizeStatus(statusFilter)); + model.addAttribute("hasPlacements", !filteredPlacements.isEmpty()); + model.addAttribute("toastMessages", List.of()); + + return "ads/list"; + } + + @GetMapping("/new") + public String newAdForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고를 등록할 권한이 없습니다."); + return "redirect:/ads"; + } + + model.addAttribute("pageTitle", "새 광고 등록"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads"), + Breadcrumb.active("새 광고 등록") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", new AdForm(null, "", "", "", null, null, null)); + return "ads/ad-form"; + } + + @PostMapping + public String createAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam String name, + @RequestParam(name = "targetUrl") String targetUrl, + @RequestParam(name = "memo", required = false) String memo, + @RequestParam(name = "objectKey") String objectKey, + @RequestParam(name = "contentType") String contentType, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고를 등록할 권한이 없습니다."); + return "redirect:/ads?tab=ads"; + } + + if (!StringUtils.hasText(name) || !StringUtils.hasText(targetUrl) || !StringUtils.hasText(objectKey) || !StringUtils.hasText(contentType)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/ads/new"; + } + + try { + adminAdClient.createAd(new AdminCreateAdRequest(name.trim(), trimToNull(memo), objectKey, contentType, targetUrl.trim())); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=ads"; + } catch (RestClientResponseException exception) { + log.warn("Failed to create ad. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/ads/new"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to create ad.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/ads/new"; + } + } + + @GetMapping("/edit") + public String editAdForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long adId, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고를 수정할 권한이 없습니다."); + return "redirect:/ads"; + } + + try { + AdminAdListResponse response = adminAdClient.listAds(); + Optional match = Optional.ofNullable(response) + .map(AdminAdListResponse::items) + .orElseGet(List::of) + .stream() + .filter(item -> Objects.equals(item.id(), adId)) + .findFirst(); + if (match.isEmpty()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "해당 광고를 찾을 수 없습니다."); + return "redirect:/ads"; + } + + AdminAdListResponse.Item item = match.get(); + AdForm form = new AdForm(item.id(), item.name(), item.targetUrl(), item.memo(), null, null, item.imageUrl()); + + model.addAttribute("pageTitle", "광고 수정"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads"), + Breadcrumb.active("광고 수정") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", form); + return "ads/ad-form"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load ad for edit. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load ad for edit.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 정보를 불러오지 못했습니다."); + return "redirect:/ads"; + } + + @PostMapping("/edit") + public String updateAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long adId, + @RequestParam String name, + @RequestParam("targetUrl") String targetUrl, + @RequestParam(name = "memo", required = false) String memo, + @RequestParam(name = "objectKey", required = false) String objectKey, + @RequestParam(name = "contentType", required = false) String contentType, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고를 수정할 권한이 없습니다."); + return "redirect:/ads?tab=ads"; + } + + if (!StringUtils.hasText(name) || !StringUtils.hasText(targetUrl)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/ads/edit?id=" + adId; + } + + String normalizedObjectKey = StringUtils.hasText(objectKey) ? objectKey : null; + String normalizedContentType = StringUtils.hasText(contentType) ? contentType : null; + + try { + adminAdClient.updateAd(adId, new AdminUpdateAdRequest(name.trim(), trimToNull(memo), normalizedObjectKey, normalizedContentType, targetUrl.trim())); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=ads"; + } catch (RestClientResponseException exception) { + log.warn("Failed to update ad. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update ad.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 수정에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/ads/edit?id=" + adId; + } + + @PostMapping("/delete") + public String deleteAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long adId, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고를 삭제할 권한이 없습니다."); + return "redirect:/ads?tab=ads"; + } + + try { + adminAdClient.deleteAd(adId); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "삭제되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to delete ad. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to delete ad.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + return "redirect:/ads?tab=ads"; + } + + @GetMapping("/slots/new") + public String newSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); + return "redirect:/ads?tab=slots"; + } + + model.addAttribute("pageTitle", "새 광고 위치 추가"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=slots"), + Breadcrumb.active("새 광고 위치 추가") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", new AdSlotForm(null, "", "", 50.0, 60)); + return "ads/slot-form"; + } + + @PostMapping("/slots") + public String createSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("code") String code, + @RequestParam(name = "description", required = false) String description, + @RequestParam("fallbackRatio") Double fallbackRatioPercent, + @RequestParam("ttlSeconds") Integer ttlSeconds, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); + return "redirect:/ads?tab=slots"; + } + + if (!StringUtils.hasText(code) || fallbackRatioPercent == null || ttlSeconds == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/ads/slots/new"; + } + + double normalizedFallback = Math.max(0, Math.min(100, fallbackRatioPercent)); + double fallbackRatio = normalizedFallback / 100.0; + + try { + adminAdClient.createSlot(new AdminCreateSlotRequest(code.trim(), trimToNull(description), fallbackRatio, ttlSeconds)); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=slots"; + } catch (RestClientResponseException exception) { + log.warn("Failed to create slot. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to create slot.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/ads/slots/new"; + } + + @GetMapping("/slots/edit") + public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long slotId, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); + return "redirect:/ads?tab=slots"; + } + + try { + AdminSlotListResponse response = adminAdClient.listSlots(); + Optional match = Optional.ofNullable(response) + .map(AdminSlotListResponse::items) + .orElseGet(List::of) + .stream() + .filter(item -> Objects.equals(item.id(), slotId)) + .findFirst(); + if (match.isEmpty()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "해당 광고 위치를 찾을 수 없습니다."); + return "redirect:/ads?tab=slots"; + } + + AdminSlotListResponse.Item item = match.get(); + double fallbackRatioPercent = item.fallbackRatio() != null ? item.fallbackRatio() * 100.0 : 0.0; + AdSlotForm form = new AdSlotForm(item.id(), item.name(), item.memo(), fallbackRatioPercent, item.ttlSeconds()); + + model.addAttribute("pageTitle", "광고 위치 수정"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=slots"), + Breadcrumb.active("광고 위치 수정") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", form); + return "ads/slot-form"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load slot for edit. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load slot for edit.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 정보를 불러오지 못했습니다."); + return "redirect:/ads?tab=slots"; + } + + @PostMapping("/slots/edit") + public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long slotId, + @RequestParam(name = "description", required = false) String description, + @RequestParam("fallbackRatio") Double fallbackRatioPercent, + @RequestParam("ttlSeconds") Integer ttlSeconds, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); + return "redirect:/ads?tab=slots"; + } + + if (fallbackRatioPercent == null || ttlSeconds == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/ads/slots/edit?id=" + slotId; + } + + double normalizedFallback = Math.max(0, Math.min(100, fallbackRatioPercent)); + double fallbackRatio = normalizedFallback / 100.0; + + try { + adminAdClient.updateSlot(slotId, new AdminUpdateSlotRequest(trimToNull(description), fallbackRatio, ttlSeconds)); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=slots"; + } catch (RestClientResponseException exception) { + log.warn("Failed to update slot. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update slot.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 수정에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/ads/slots/edit?id=" + slotId; + } + + @PostMapping("/slots/delete") + public String deleteSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long slotId, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 삭제할 권한이 없습니다."); + return "redirect:/ads?tab=slots"; + } + + try { + adminAdClient.deleteSlot(slotId); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "삭제되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to delete slot. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to delete slot.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + return "redirect:/ads?tab=slots"; + } + + @GetMapping("/placements/new") + public String newPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam(name = "slotId", required = false) Long preselectedSlotId, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + List ads = fetchAdOptions(); + List slots = fetchSlotOptions(); + + model.addAttribute("pageTitle", "새 스케줄 등록"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=placements"), + Breadcrumb.active("새 스케줄 등록") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", new AdPlacementForm(null, null, preselectedSlotId, null, null, (short) 1, Boolean.TRUE)); + model.addAttribute("adOptions", ads); + model.addAttribute("slotOptions", slots); + return "ads/placement-form"; + } + + @PostMapping("/placements") + public String createPlacement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("adId") Long adId, + @RequestParam("slotId") Long slotId, + @RequestParam("startDate") String startDate, + @RequestParam("endDate") String endDate, + @RequestParam("weight") Short weight, + @RequestParam(name = "enabled", defaultValue = "false") boolean enabled, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + try { + AdminCreateAdPlacementRequest request = new AdminCreateAdPlacementRequest( + adId, + slotId, + LocalDate.parse(startDate), + LocalDate.parse(endDate), + weight, + enabled + ); + adminAdClient.createPlacement(request); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=placements"; + } catch (Exception exception) { + log.warn("Failed to create placement.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 등록에 실패했습니다. 입력값을 확인해주세요."); + return "redirect:/ads/placements/new"; + } + + @GetMapping("/placements/edit") + public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long placementId, + Model model, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + try { + AdminAdPlacementListResponse response = adminAdClient.listPlacements(); + Optional match = Optional.ofNullable(response) + .map(AdminAdPlacementListResponse::items) + .orElseGet(List::of) + .stream() + .filter(item -> Objects.equals(item.id(), placementId)) + .findFirst(); + if (match.isEmpty()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "해당 스케줄을 찾을 수 없습니다."); + return "redirect:/ads?tab=placements"; + } + + AdminAdPlacementListResponse.Item item = match.get(); + AdPlacementForm form = new AdPlacementForm( + item.id(), + item.adId(), + item.slotId(), + item.startDate() != null ? item.startDate().toString() : null, + item.endDate() != null ? item.endDate().toString() : null, + item.weight() != null ? item.weight() : (short) 1, + item.enabled() + ); + + model.addAttribute("pageTitle", "스케줄 수정"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=placements"), + Breadcrumb.active("스케줄 수정") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", form); + model.addAttribute("adOptions", fetchAdOptions()); + model.addAttribute("slotOptions", fetchSlotOptions()); + return "ads/placement-form"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load placement for edit. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load placement for edit.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 정보를 불러오지 못했습니다."); + return "redirect:/ads?tab=placements"; + } + + @PostMapping("/placements/edit") + public String updatePlacement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long placementId, + @RequestParam("slotId") Long slotId, + @RequestParam("startDate") String startDate, + @RequestParam("endDate") String endDate, + @RequestParam("weight") Short weight, + @RequestParam(name = "enabled", defaultValue = "false") boolean enabled, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + try { + AdminUpdateAdPlacementRequest request = new AdminUpdateAdPlacementRequest( + slotId, + LocalDate.parse(startDate), + LocalDate.parse(endDate), + weight, + enabled + ); + adminAdClient.updatePlacement(placementId, request); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); + return "redirect:/ads?tab=placements"; + } catch (Exception exception) { + log.warn("Failed to update placement.", exception); + } + + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 수정에 실패했습니다. 입력값을 확인해주세요."); + return "redirect:/ads/placements/edit?id=" + placementId; + } + + @PostMapping("/placements/delete") + public String deletePlacement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long placementId, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 삭제할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + try { + adminAdClient.deletePlacement(placementId); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "삭제되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to delete placement. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to delete placement.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + return "redirect:/ads?tab=placements"; + } + + @PostMapping("/placements/toggle") + public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam("id") Long placementId, + @RequestParam("enabled") boolean enabled, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.isAdminEditor()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태를 변경할 권한이 없습니다."); + return "redirect:/ads?tab=placements"; + } + + try { + AdminAdPlacementListResponse response = adminAdClient.listPlacements(); + Optional match = Optional.ofNullable(response) + .map(AdminAdPlacementListResponse::items) + .orElseGet(List::of) + .stream() + .filter(item -> Objects.equals(item.id(), placementId)) + .findFirst(); + if (match.isEmpty()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "해당 스케줄을 찾을 수 없습니다."); + return "redirect:/ads?tab=placements"; + } + + AdminAdPlacementListResponse.Item item = match.get(); + AdminUpdateAdPlacementRequest request = new AdminUpdateAdPlacementRequest( + item.slotId(), + item.startDate(), + item.endDate(), + item.weight(), + enabled + ); + adminAdClient.updatePlacement(placementId, request); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", enabled ? "이 스케줄을 다시 활성화합니다." : "저장되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to toggle placement. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to toggle placement.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + return "redirect:/ads?tab=placements"; + } + + @PostMapping("/image/presign") + @ResponseBody + public ResponseEntity presignImage(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestBody Map payload) { + if (!currentAdminProfile.isAdminEditor()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", "이미지 업로드 권한이 없습니다.")); + } + + String contentType = payload != null ? payload.get("contentType") : null; + if (!StringUtils.hasText(contentType)) { + return ResponseEntity.badRequest().body(Map.of("message", "contentType이 필요합니다.")); + } + + try { + AdImagePresignResponse presign = adminAdClient.generateAdImagePresignUrl(new AdminAdImagePresignRequest(contentType)); + return ResponseEntity.ok(Map.of( + "presignedUrl", presign.presignedUrl(), + "objectKey", presign.objectKey() + )); + } catch (RestClientResponseException exception) { + log.warn("Failed to request ad image presign. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return ResponseEntity.status(exception.getStatusCode()).body(Map.of("message", "Presigned URL 발급에 실패했습니다.")); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to request ad image presign.", exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Presigned URL 발급에 실패했습니다.")); + } + } + + private String normalizeTab(String tab) { + if (!StringUtils.hasText(tab)) { + return "ads"; + } + return switch (tab.toLowerCase(Locale.ROOT)) { + case "ads", "slots", "placements" -> tab.toLowerCase(Locale.ROOT); + default -> "ads"; + }; + } + + private String normalizePeriod(String period) { + if (!StringUtils.hasText(period)) { + return "all"; + } + return switch (period.toLowerCase(Locale.ROOT)) { + case "today" -> "today"; + case "next7", "seven", "upcoming" -> "next7"; + default -> "all"; + }; + } + + private String normalizeStatus(String status) { + if (!StringUtils.hasText(status)) { + return "all"; + } + return switch (status.toLowerCase(Locale.ROOT)) { + case "active" -> "active"; + case "upcoming" -> "upcoming"; + case "ended", "past" -> "ended"; + default -> "all"; + }; + } + + private List filterAds(List ads, String query) { + if (!StringUtils.hasText(query)) { + return ads; + } + String normalized = query.trim().toLowerCase(Locale.ROOT); + return ads.stream() + .filter(item -> containsIgnoreCase(item.name(), normalized) + || containsIgnoreCase(item.targetUrl(), normalized) + || containsIgnoreCase(item.memo(), normalized)) + .toList(); + } + + private List filterPlacements(List placements, + Long slotFilter, + String periodFilter, + String statusFilter, + LocalDate today) { + String normalizedPeriod = normalizePeriod(periodFilter); + String normalizedStatus = normalizeStatus(statusFilter); + return placements.stream() + .filter(item -> slotFilter == null || Objects.equals(item.slotId(), slotFilter)) + .filter(item -> matchesPeriod(item, normalizedPeriod, today)) + .filter(item -> matchesStatus(item, normalizedStatus)) + .sorted(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private boolean matchesPeriod(AdPlacementItem item, String period, LocalDate today) { + if ("today".equals(period)) { + if (item.startDate() == null || item.endDate() == null) { + return false; + } + return !today.isBefore(item.startDate()) && !today.isAfter(item.endDate()); + } + if ("next7".equals(period)) { + if (item.startDate() == null || item.endDate() == null) { + return false; + } + LocalDate rangeEnd = today.plusDays(7); + return !item.endDate().isBefore(today) && !item.startDate().isAfter(rangeEnd); + } + return true; + } + + private boolean matchesStatus(AdPlacementItem item, String status) { + return switch (status) { + case "active" -> item.timeStatus() == PlacementTimeStatus.ACTIVE; + case "upcoming" -> item.timeStatus() == PlacementTimeStatus.UPCOMING; + case "ended" -> item.timeStatus() == PlacementTimeStatus.ENDED; + default -> true; + }; + } + + private boolean containsIgnoreCase(String text, String query) { + if (!StringUtils.hasText(text) || !StringUtils.hasText(query)) { + return false; + } + return text.toLowerCase(Locale.ROOT).contains(query); + } + + private AdListItem toAdListItem(AdminAdListResponse.Item item) { + return new AdListItem( + item.id(), + item.name(), + item.memo(), + item.imageUrl(), + item.contentType(), + item.targetUrl(), + item.createdAt(), + item.updatedAt() + ); + } + + private AdSlotListItem toSlotListItem(AdminSlotListResponse.Item item, Map placementCountsBySlot) { + double fallbackRatioPercent = item.fallbackRatio() != null ? item.fallbackRatio() * 100.0 : 0.0; + int ttlSeconds = item.ttlSeconds() != null ? item.ttlSeconds() : 0; + int connectedCount = placementCountsBySlot.getOrDefault(item.id(), 0L).intValue(); + return new AdSlotListItem( + item.id(), + item.name(), + item.memo(), + fallbackRatioPercent, + ttlSeconds, + item.createdAt(), + item.updatedAt(), + connectedCount + ); + } + + private AdPlacementItem toPlacementItem(AdminAdPlacementListResponse.Item item, LocalDate today) { + PlacementTimeStatus timeStatus = resolveTimeStatus(item, today); + boolean enabled = Boolean.TRUE.equals(item.enabled()); + short weight = item.weight() != null ? item.weight() : (short) 1; + return new AdPlacementItem( + item.id(), + item.adId(), + item.adName(), + item.adImageUrl(), + item.slotId(), + item.slotName(), + item.startDate(), + item.endDate(), + weight, + enabled, + item.createdAt(), + item.updatedAt(), + timeStatus + ); + } + + private PlacementTimeStatus resolveTimeStatus(AdminAdPlacementListResponse.Item item, LocalDate today) { + LocalDate start = item.startDate(); + LocalDate end = item.endDate(); + if (start == null || end == null) { + return PlacementTimeStatus.ACTIVE; + } + if (today.isBefore(start)) { + return PlacementTimeStatus.UPCOMING; + } + if (today.isAfter(end)) { + return PlacementTimeStatus.ENDED; + } + return PlacementTimeStatus.ACTIVE; + } + + private List buildPlacementGroups(List slotItems, + Map slotMap, + List filteredPlacements, + Long slotFilter) { + Map> placementsBySlot = filteredPlacements.stream() + .collect(Collectors.groupingBy(AdPlacementItem::slotId, LinkedHashMap::new, Collectors.toCollection(ArrayList::new))); + + List groups = new ArrayList<>(); + for (AdSlotListItem slot : slotItems) { + if (slotFilter != null && !Objects.equals(slot.id(), slotFilter)) { + continue; + } + List placements = new ArrayList<>(placementsBySlot.getOrDefault(slot.id(), List.of())); + placements.sort(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))); + groups.add(new AdPlacementGroup(slot.id(), slot.code(), slot.description(), List.copyOf(placements))); + } + + // Placements whose slot is missing from slot list + placementsBySlot.forEach((slotId, items) -> { + if (slotId == null || slotMap.containsKey(slotId)) { + return; + } + if (slotFilter != null && !Objects.equals(slotId, slotFilter)) { + return; + } + items.sort(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))); + AdPlacementItem sample = items.get(0); + groups.add(new AdPlacementGroup(slotId, sample.slotName(), null, List.copyOf(items))); + }); + + return groups; + } + + private List fetchAdOptions() { + try { + AdminAdListResponse response = adminAdClient.listAds(); + return Optional.ofNullable(response) + .map(AdminAdListResponse::items) + .orElseGet(List::of) + .stream() + .map(this::toAdListItem) + .sorted(Comparator.comparing(AdListItem::name, Comparator.nullsLast(String::compareToIgnoreCase))) + .toList(); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load ads for options.", exception); + return List.of(); + } + } + + private List fetchSlotOptions() { + try { + AdminSlotListResponse response = adminAdClient.listSlots(); + return Optional.ofNullable(response) + .map(AdminSlotListResponse::items) + .orElseGet(List::of) + .stream() + .map(item -> toSlotListItem(item, Map.of())) + .sorted(Comparator.comparing(AdSlotListItem::code, Comparator.nullsLast(String::compareToIgnoreCase))) + .toList(); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load slots for options.", exception); + return List.of(); + } + } + + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} + + diff --git a/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java b/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java index b2a6e33..d87da27 100644 --- a/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java +++ b/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java @@ -1,13 +1,17 @@ package apu.saerok_admin.web.view; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import org.springframework.util.StringUtils; public record CurrentAdminProfile( String nickname, String email, String profileImageUrl, - List roleDescriptions + List roleDescriptions, + List roleCodes ) { private static final String DEFAULT_PROFILE_IMAGE_URL = @@ -20,13 +24,47 @@ public record CurrentAdminProfile( email = StringUtils.hasText(email) ? email : DEFAULT_EMAIL; profileImageUrl = StringUtils.hasText(profileImageUrl) ? profileImageUrl : DEFAULT_PROFILE_IMAGE_URL; roleDescriptions = roleDescriptions != null ? List.copyOf(roleDescriptions) : List.of(); + roleCodes = normalizeRoleCodes(roleCodes); } public boolean hasRoleDescriptions() { return !roleDescriptions.isEmpty(); } + public boolean hasRole(String roleCode) { + if (!StringUtils.hasText(roleCode)) { + return false; + } + String normalized = roleCode.toUpperCase(Locale.ROOT); + return roleCodes.contains(normalized); + } + + public boolean isAdminEditor() { + return hasRole("ADMIN_EDITOR"); + } + + public boolean isAdminViewerOnly() { + return hasRole("ADMIN_VIEWER") && !isAdminEditor(); + } + public static CurrentAdminProfile placeholder() { - return new CurrentAdminProfile(null, null, null, List.of()); + return new CurrentAdminProfile(null, null, null, List.of(), List.of()); + } + + private static List normalizeRoleCodes(List rawRoles) { + if (rawRoles == null || rawRoles.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String role : rawRoles) { + if (!StringUtils.hasText(role)) { + continue; + } + normalized.add(role.toUpperCase(Locale.ROOT)); + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); } } diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdForm.java b/src/main/java/apu/saerok_admin/web/view/ad/AdForm.java new file mode 100644 index 0000000..edc1d7c --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdForm.java @@ -0,0 +1,22 @@ +package apu.saerok_admin.web.view.ad; + +import org.springframework.util.StringUtils; + +public record AdForm( + Long id, + String name, + String targetUrl, + String memo, + String objectKey, + String contentType, + String imageUrl +) { + + public boolean hasExistingImage() { + return StringUtils.hasText(imageUrl); + } + + public boolean hasUploadedImage() { + return StringUtils.hasText(objectKey) && StringUtils.hasText(contentType); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdListItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdListItem.java new file mode 100644 index 0000000..ae0412d --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdListItem.java @@ -0,0 +1,20 @@ +package apu.saerok_admin.web.view.ad; + +import java.time.OffsetDateTime; +import org.springframework.util.StringUtils; + +public record AdListItem( + Long id, + String name, + String memo, + String imageUrl, + String contentType, + String targetUrl, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + + public boolean hasImage() { + return StringUtils.hasText(imageUrl); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementForm.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementForm.java new file mode 100644 index 0000000..03eae4b --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementForm.java @@ -0,0 +1,16 @@ +package apu.saerok_admin.web.view.ad; + +public record AdPlacementForm( + Long id, + Long adId, + Long slotId, + String startDate, + String endDate, + Short weight, + Boolean enabled +) { + + public boolean isEdit() { + return id != null; + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java new file mode 100644 index 0000000..1d02187 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java @@ -0,0 +1,15 @@ +package apu.saerok_admin.web.view.ad; + +import java.util.List; + +public record AdPlacementGroup( + Long slotId, + String slotCode, + String slotDescription, + List placements +) { + + public boolean hasPlacements() { + return placements != null && !placements.isEmpty(); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java new file mode 100644 index 0000000..017c5bb --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java @@ -0,0 +1,64 @@ +package apu.saerok_admin.web.view.ad; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +public record AdPlacementItem( + Long id, + Long adId, + String adName, + String adImageUrl, + Long slotId, + String slotName, + LocalDate startDate, + LocalDate endDate, + short weight, + boolean enabled, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + PlacementTimeStatus timeStatus +) { + + private static final DateTimeFormatter PERIOD_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + + public String periodLabel() { + // 시작/종료일이 모두 없는 경우 + if (startDate == null && endDate == null) { + return "기간 미지정"; + } + + // 시작일만 없는 경우: 열린 시작 + if (startDate == null) { + return "~ " + PERIOD_FORMATTER.format(endDate); + } + + // 종료일만 없는 경우: 열린 종료 + if (endDate == null) { + return PERIOD_FORMATTER.format(startDate) + " ~"; + } + + // 둘 다 있는 경우 + return PERIOD_FORMATTER.format(startDate) + " ~ " + PERIOD_FORMATTER.format(endDate); + } + + public String displayStatusLabel() { + return enabled ? "노출 중" : "일시 중지"; + } + + public String timeStatusLabel() { + return switch (timeStatus) { + case ACTIVE -> "노출 중"; + case UPCOMING -> "예정"; + case ENDED -> "종료"; + }; + } + + public String weightLabel() { + return Short.toString(weight); + } + + public boolean hasImage() { + return adImageUrl != null && !adImageUrl.isBlank(); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdSlotForm.java b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotForm.java new file mode 100644 index 0000000..7a680a8 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotForm.java @@ -0,0 +1,14 @@ +package apu.saerok_admin.web.view.ad; + +public record AdSlotForm( + Long id, + String code, + String description, + Double fallbackRatioPercent, + Integer ttlSeconds +) { + + public boolean isEdit() { + return id != null; + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java new file mode 100644 index 0000000..9dc38c5 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java @@ -0,0 +1,19 @@ +package apu.saerok_admin.web.view.ad; + +import java.time.OffsetDateTime; + +public record AdSlotListItem( + Long id, + String code, + String description, + double fallbackRatioPercent, + int ttlSeconds, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + int connectedScheduleCount +) { + + public String fallbackRatioLabel() { + return String.format("%.1f%%", fallbackRatioPercent); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdSummaryMetrics.java b/src/main/java/apu/saerok_admin/web/view/ad/AdSummaryMetrics.java new file mode 100644 index 0000000..333ed2b --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdSummaryMetrics.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.web.view.ad; + +public record AdSummaryMetrics( + int adCount, + int slotCount, + int activePlacementCount +) { +} diff --git a/src/main/java/apu/saerok_admin/web/view/ad/PlacementTimeStatus.java b/src/main/java/apu/saerok_admin/web/view/ad/PlacementTimeStatus.java new file mode 100644 index 0000000..7662db1 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/PlacementTimeStatus.java @@ -0,0 +1,7 @@ +package apu.saerok_admin.web.view.ad; + +public enum PlacementTimeStatus { + ACTIVE, + UPCOMING, + ENDED +} diff --git a/src/main/resources/static/css/ads.css b/src/main/resources/static/css/ads.css new file mode 100644 index 0000000..ff93409 --- /dev/null +++ b/src/main/resources/static/css/ads.css @@ -0,0 +1,343 @@ +:root { + color-scheme: light; +} + +.ads-page { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.ads-page-header h1 { + font-weight: 700; + color: #0f172a; +} + +.ads-page-header p { + color: #64748b; +} + +.ads-summary-row { + --ads-card-radius: 1.25rem; +} + +.ads-summary-card { + position: relative; + border: 0; + border-radius: var(--ads-card-radius); + background: linear-gradient(135deg, var(--ads-card-start, #eef2ff), var(--ads-card-end, #dbeafe)); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.ads-summary-card:hover { + transform: translateY(-4px); + box-shadow: 0 22px 48px rgba(15, 23, 42, 0.16); +} + +.ads-summary-card::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.45), transparent 55%); + pointer-events: none; +} + +.ads-summary-card--ads { + --ads-card-start: #eef2ff; + --ads-card-end: #e0f2fe; + --ads-icon-bg: rgba(79, 70, 229, 0.16); + --ads-icon-color: #4338ca; +} + +.ads-summary-card--slots { + --ads-card-start: #f1f5f9; + --ads-card-end: #d9f99d; + --ads-icon-bg: rgba(34, 197, 94, 0.16); + --ads-icon-color: #15803d; +} + +.ads-summary-card--placements { + --ads-card-start: #fef3c7; + --ads-card-end: #e0e7ff; + --ads-icon-bg: rgba(245, 158, 11, 0.18); + --ads-icon-color: #b45309; +} + +.ads-summary-icon { + width: 3rem; + height: 3rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--ads-icon-bg, rgba(15, 23, 42, 0.1)); + color: var(--ads-icon-color, #1d4ed8); + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.ads-summary-label { + font-weight: 600; + color: #334155; + margin-bottom: 0.25rem; +} + +.ads-summary-value { + font-size: 2.25rem; + font-weight: 700; + color: #0f172a; +} + +.ads-tabs { + border: 0; + background: rgba(226, 232, 240, 0.6); + padding: 0.4rem; + border-radius: 999px; + display: inline-flex; + gap: 0.25rem; +} + +.ads-tabs .nav-link { + border: 0; + border-radius: 999px; + color: #64748b; + font-weight: 500; + padding: 0.55rem 1.4rem; + transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.ads-tabs .nav-link:hover { + color: #1e293b; +} + +.ads-tabs .nav-link.active { + background: linear-gradient(135deg, #6366f1, #22d3ee); + color: #ffffff; + box-shadow: 0 12px 30px rgba(14, 165, 233, 0.35); +} + +.ads-panel { + border: 0; + border-radius: 1.25rem; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12); + background-color: #ffffff; +} + +.ads-panel .card-header { + border: 0; + background: linear-gradient(180deg, rgba(148, 163, 184, 0.18), rgba(255, 255, 255, 0)); + padding: 1.5rem 1.5rem 1.1rem; +} + +.ads-panel .card-body { + padding: 1.6rem 1.6rem 1.9rem; +} + +.ads-panel .table-responsive { + border-radius: 1rem; +} + +.ads-table thead th { + background: #f8fafc; + border-bottom: none; + font-weight: 600; + color: #475569; +} + +.ads-table tbody td { + border-top-color: rgba(226, 232, 240, 0.7); + color: #1f2937; +} + +.ads-table tbody tr:hover { + background: rgba(226, 232, 240, 0.35); +} + +.ads-thumb-surface { + border-radius: 1rem; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.35); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12); + background: #ffffff; +} + +.ads-thumb-surface--lg { + width: 120px; +} + +.ads-thumb-surface--sm { + width: 64px; +} + +.ads-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + border-radius: 1rem; + background: linear-gradient(135deg, rgba(226, 232, 240, 0.6), rgba(148, 163, 184, 0.3)); + border: 1px dashed rgba(148, 163, 184, 0.6); + color: #94a3b8; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); +} + +.ads-thumb-placeholder--lg { + width: 120px; + height: 90px; +} + +.ads-thumb-placeholder--sm { + width: 64px; + height: 64px; +} + +.ads-thumb-placeholder i { + font-size: 1.6rem; +} + +.ads-form-page { + max-width: 780px; + margin: 0 auto; +} + +.ads-form-card { + border: 0; + border-radius: 1.5rem; + background: linear-gradient(180deg, #ffffff 0%, #f5f7ff 100%); + box-shadow: 0 28px 56px rgba(15, 23, 42, 0.15); +} + +.ads-form-card__header { + border-bottom: 0; + padding: 1.85rem 1.85rem 0; + background: transparent; +} + +.ads-form-card__body { + padding: 1.85rem; +} + +.ads-form-card__body .form-control, +.ads-form-card__body .form-select, +.ads-form-card__body .form-range { + border-radius: 0.75rem; + border-color: rgba(148, 163, 184, 0.45); + box-shadow: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.ads-slider-group .form-range { + accent-color: #6366f1; +} + +.ads-form-card__body .form-control:focus, +.ads-form-card__body .form-select:focus, +.ads-form-card__body .form-range:focus { + border-color: #6366f1; + box-shadow: 0 0 0 0.25rem rgba(99, 102, 241, 0.18); +} + +.ads-upload-area { + border-radius: 1rem; + border: 1px dashed rgba(99, 102, 241, 0.25); + background: rgba(226, 232, 240, 0.45); + padding: 1.1rem; +} + +.ads-image-preview { + width: 200px; + border-radius: 1rem; + overflow: hidden; + border: 1px solid rgba(99, 102, 241, 0.25); + box-shadow: 0 18px 34px rgba(15, 23, 42, 0.14); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(14, 165, 233, 0.1)); +} + +.ads-upload-status { + color: #475569; +} + +.ads-form-actions .btn { + min-width: 120px; + border-radius: 999px; +} + +.ads-weight-group { + gap: 0.4rem; +} + +.ads-weight-group .btn { + border-radius: 999px; + border-width: 2px; + font-weight: 600; + color: #475569; + background: rgba(148, 163, 184, 0.12); +} + +.ads-weight-group .btn:hover { + color: #111827; +} + +.ads-weight-group .btn-check:checked + .btn { + color: #ffffff; + background: linear-gradient(135deg, #6366f1, #22d3ee); + border-color: transparent; + box-shadow: 0 12px 28px rgba(99, 102, 241, 0.35); +} + +.ads-status-switch .form-check-input { + width: 3.25rem; + height: 1.6rem; + background-color: #e2e8f0; + border: none; +} + +.ads-status-switch .form-check-input:focus { + box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.2); +} + +.ads-status-switch .form-check-input:checked { + background-color: #6366f1; +} + +.ads-status-switch .form-check-label { + font-weight: 600; + color: #334155; +} + +.ads-panel .badge { + border-radius: 999px; + padding: 0.45rem 0.9rem; + font-weight: 600; +} + +@media (max-width: 991.98px) { + .ads-tabs { + width: 100%; + justify-content: space-between; + } + + .ads-tabs .nav-link { + flex: 1 1 0; + text-align: center; + } + + .ads-summary-row { + gap: 1rem; + } + + .ads-form-card__header, + .ads-form-card__body { + padding: 1.5rem; + } + + .ads-form-actions { + flex-direction: column; + align-items: stretch; + } + + .ads-form-actions .btn { + width: 100%; + } +} diff --git a/src/main/resources/static/js/ads-image-upload.js b/src/main/resources/static/js/ads-image-upload.js new file mode 100644 index 0000000..55921ec --- /dev/null +++ b/src/main/resources/static/js/ads-image-upload.js @@ -0,0 +1,76 @@ +(function () { + var form = document.getElementById('adForm'); + if (!form) { + return; + } + + var fileInput = document.getElementById('adImageFile'); + var objectKeyInput = document.getElementById('adObjectKey'); + var contentTypeInput = document.getElementById('adContentType'); + var statusElement = document.getElementById('adImageUploadStatus'); + var previewImage = document.getElementById('adImagePreview'); + + var setStatus = function (message, variantClass) { + if (!statusElement) { + return; + } + statusElement.textContent = message; + statusElement.classList.remove('text-success', 'text-danger', 'text-muted'); + statusElement.classList.add(variantClass || 'text-muted'); + }; + + var uploadFile = function (file) { + if (!file) { + return; + } + var contentType = file.type || 'application/octet-stream'; + setStatus('이미지 업로드 URL을 요청하고 있습니다...', 'text-muted'); + + fetch('/ads/image/presign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentType: contentType }) + }).then(function (response) { + if (!response.ok) { + throw new Error('Presign failed'); + } + return response.json(); + }).then(function (data) { + if (!data || !data.presignedUrl || !data.objectKey) { + throw new Error('Invalid presign response'); + } + setStatus('이미지를 업로드하는 중입니다...', 'text-muted'); + return fetch(data.presignedUrl, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: file + }).then(function (uploadResponse) { + if (!uploadResponse.ok) { + throw new Error('Upload failed'); + } + objectKeyInput.value = data.objectKey; + contentTypeInput.value = contentType; + if (previewImage) { + var objectUrl = URL.createObjectURL(file); + previewImage.src = objectUrl; + previewImage.onload = function () { + URL.revokeObjectURL(objectUrl); + }; + } + setStatus('이미지 업로드가 완료되었습니다.', 'text-success'); + }); + }).catch(function () { + setStatus('이미지 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.', 'text-danger'); + }); + }; + + if (fileInput) { + fileInput.addEventListener('change', function () { + var file = fileInput.files && fileInput.files[0]; + if (!file) { + return; + } + uploadFile(file); + }); + } +})(); diff --git a/src/main/resources/templates/ads/ad-form.html b/src/main/resources/templates/ads/ad-form.html new file mode 100644 index 0000000..757c998 --- /dev/null +++ b/src/main/resources/templates/ads/ad-form.html @@ -0,0 +1,67 @@ + + + + + +
+
+
+

새 광고 등록

+
+
+
+ + + + +
+ + +
+ +
+ + +
광고 클릭 시 이동할 주소를 입력하세요.
+
+ +
+ +
+
+
+ 광고 미리보기 + 광고 미리보기 +
+
+
+ +
이미지 선택 시 자동으로 업로드됩니다. 최소 1200x628 이상의 이미지를 권장합니다.
+
+ 이미지를 업로드해주세요. +
+
+
+
+ +
+ + +
+ +
+ 취소 + +
+
+
+
+
+ + +
+ diff --git a/src/main/resources/templates/ads/list.html b/src/main/resources/templates/ads/list.html new file mode 100644 index 0000000..56efaf0 --- /dev/null +++ b/src/main/resources/templates/ads/list.html @@ -0,0 +1,423 @@ + + + + + +
+
+
+

광고 관리

+

광고, 광고 위치, 노출 스케줄을 한 곳에서 관리합니다.

+
+ +
+ +
+
+
+
+
+ +
+
등록된 광고
+
0
+
+
+
+
+
+
+
+ +
+
광고 위치
+
0
+
+
+
+
+
+
+
+ +
+
활성 스케줄
+
0
+
+
+
+
+ +
+ + 처리가 완료되었습니다. +
+ + + + +
+
+
+
+ +
+ + +
+
+ + 초기화 +
+
+
+
+ +
+ + 광고 목록을 불러오지 못했습니다. +
+ +
+
+
+

광고

+

노출할 광고의 이름, 링크, 이미지를 설정합니다.

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
광고 이미지광고 이름이동할 링크(URL)메모등록일 / 수정일액션
등록된 광고가 없습니다.
+
+ 광고 이미지 +
+
+ +
+
광고 이름 + + https://example.com + + 메모 +
등록 + - +
+
수정 + - +
+
+
+ 수정 +
+ + +
+
+
+
+
+
+ + +
+
+ + 광고 위치를 불러오지 못했습니다. +
+ +
+
+
+

광고 위치

+

+ 광고가 표시될 화면 위치를 등록하고, 기본 광고 표시 비율 등을 설정합니다. +

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
광고 위치 코드설명기본 노출 비율노출 중인 스케줄 수액션
등록된 광고 위치가 없습니다.
HOME_TOP + 설명 + + 50% + + 0 + +
+ 설정 +
+
+
+
+
+ + +
+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+ + 광고 노출 스케줄을 불러오지 못했습니다. +
+ +
+
+ 등록된 광고 노출 스케줄이 없습니다. +
+
+ +
+
+
+

HOME_TOP

+

+ 광고 위치 설명 +

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
광고노출 기간노출 강도노출 상태등록일 / 수정일액션
등록된 스케줄이 없습니다.
+
+
+ 광고 미리보기 +
+
+ +
+
광고 이름
+
+
+
2025.01.01 ~ 2025.01.31
+
노출 중
+
+ 1 + + 노출 중 + +
등록 + - +
+
수정 + - +
+
+
+
+ + + +
+ 수정 +
+ + +
+
+
+
+
+
+ +
+
+ diff --git a/src/main/resources/templates/ads/placement-form.html b/src/main/resources/templates/ads/placement-form.html new file mode 100644 index 0000000..b536816 --- /dev/null +++ b/src/main/resources/templates/ads/placement-form.html @@ -0,0 +1,101 @@ + + + + + +
+
+
+

새 스케줄 등록

+
+
+
+ + +
+ + +
광고는 수정할 수 없습니다. 새 스케줄을 등록하세요.
+
+ +
+ + +
+ +
+
+ + +
시작일
+
+
+ + +
종료일
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + +
+
노출 강도가 높을수록 화면에 등장하는 빈도가 늘어납니다.
+
+
+ +
+ + +
+
꺼짐으로 전환하면 이 스케줄이 즉시 일시 중지됩니다.
+
+
+ +
+ 취소 + +
+
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/ads/slot-form.html b/src/main/resources/templates/ads/slot-form.html new file mode 100644 index 0000000..7cccd92 --- /dev/null +++ b/src/main/resources/templates/ads/slot-form.html @@ -0,0 +1,86 @@ + + + + + +
+
+
+

새 광고 위치 추가

+
+
+
+ + +
+ + +
예: HOME_TOP, COLLECTION_DETAIL_RIGHT 등
+
+ +
+ + +
+ +
+ +
+
+ +
+
+
+ + % +
+
+
+
+ +
+ + +
+ +
+ 취소 + +
+
+
+
+
+ + +
+ diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index edd85f7..aaab3f4 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -9,8 +9,8 @@ 새록 어드민 운영 + th:classappend="${environmentProfileBadge != null} ? ' ' + environmentProfileBadge.cssClass : ''" + th:text="${environmentProfileBadge != null} ? environmentProfileBadge.label : '운영'">운영 @@ -91,9 +95,15 @@
새록 어드민
시스템 알림 발송 + + + 광고 관리 + + + From 136f5c2e71014dbf807c1814c8730bd9a8715220 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:18:01 +0900 Subject: [PATCH 2/7] Revamp ad scheduling UI (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revamp ads scheduling UI * chore: 광고 리스트 배지 클래스 append 방식 수정 및 코드 정리 --- .../apu/saerok_admin/web/AdController.java | 163 ++++-------- .../web/view/ad/AdPlacementGroup.java | 10 + .../web/view/ad/AdPlacementItem.java | 73 +++++- .../web/view/ad/AdSlotListItem.java | 4 + src/main/resources/static/css/ads.css | 64 +++++ src/main/resources/templates/ads/list.html | 244 +++++++++++------- 6 files changed, 348 insertions(+), 210 deletions(-) diff --git a/src/main/java/apu/saerok_admin/web/AdController.java b/src/main/java/apu/saerok_admin/web/AdController.java index cf878eb..ee55d38 100644 --- a/src/main/java/apu/saerok_admin/web/AdController.java +++ b/src/main/java/apu/saerok_admin/web/AdController.java @@ -69,10 +69,6 @@ public AdController(AdminAdClient adminAdClient, Clock clock) { @GetMapping public String index(@RequestParam(name = "tab", defaultValue = "ads") String tab, - @RequestParam(name = "adQuery", required = false) String adQuery, - @RequestParam(name = "slotFilter", required = false) Long slotFilter, - @RequestParam(name = "period", defaultValue = "all") String periodFilter, - @RequestParam(name = "status", defaultValue = "all") String statusFilter, Model model) { model.addAttribute("pageTitle", "광고 관리"); model.addAttribute("activeMenu", "ads"); @@ -144,12 +140,10 @@ public String index(@RequestParam(name = "tab", defaultValue = "ads") String tab slotsLoadError = "광고 위치 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; } - List filteredAds = filterAds(adItems, adQuery); - List filteredPlacements = filterPlacements(placementItems, slotFilter, periodFilter, statusFilter, today); Map slotMap = slotItems.stream() .collect(Collectors.toMap(AdSlotListItem::id, item -> item, (left, right) -> left, LinkedHashMap::new)); - List placementGroups = buildPlacementGroups(slotItems, slotMap, filteredPlacements, slotFilter); + List placementGroups = buildPlacementGroups(slotItems, slotMap, placementItems); int activePlacementCount = (int) placementItems.stream() .filter(item -> item.enabled() && item.timeStatus() == PlacementTimeStatus.ACTIVE) @@ -158,17 +152,13 @@ public String index(@RequestParam(name = "tab", defaultValue = "ads") String tab model.addAttribute("tab", normalizeTab(tab)); model.addAttribute("summary", summaryMetrics); - model.addAttribute("ads", filteredAds); + model.addAttribute("ads", adItems); model.addAttribute("adsLoadError", adsLoadError); - model.addAttribute("adQuery", adQuery != null ? adQuery : ""); model.addAttribute("slots", slotItems); model.addAttribute("slotsLoadError", slotsLoadError); model.addAttribute("placementsLoadError", placementsLoadError); model.addAttribute("placementGroups", placementGroups); - model.addAttribute("placementFilterSlot", slotFilter); - model.addAttribute("placementFilterPeriod", normalizePeriod(periodFilter)); - model.addAttribute("placementFilterStatus", normalizeStatus(statusFilter)); - model.addAttribute("hasPlacements", !filteredPlacements.isEmpty()); + model.addAttribute("hasPlacements", !placementItems.isEmpty()); model.addAttribute("toastMessages", List.of()); return "ads/list"; @@ -466,17 +456,23 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf @RequestParam(name = "description", required = false) String description, @RequestParam("fallbackRatio") Double fallbackRatioPercent, @RequestParam("ttlSeconds") Integer ttlSeconds, + @RequestParam(name = "redirectTab", defaultValue = "slots") String redirectTab, RedirectAttributes redirectAttributes) { + String normalizedRedirectTab = normalizeTab(redirectTab); + boolean redirectToPlacements = "placements".equals(normalizedRedirectTab); + String successRedirect = redirectToPlacements ? "redirect:/ads?tab=placements" : "redirect:/ads?tab=slots"; + String failureRedirect = redirectToPlacements ? "redirect:/ads?tab=placements" : "redirect:/ads/slots/edit?id=" + slotId; + if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); - return "redirect:/ads?tab=slots"; + return successRedirect; } if (fallbackRatioPercent == null || ttlSeconds == null) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); - return "redirect:/ads/slots/edit?id=" + slotId; + return failureRedirect; } double normalizedFallback = Math.max(0, Math.min(100, fallbackRatioPercent)); @@ -486,7 +482,7 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf adminAdClient.updateSlot(slotId, new AdminUpdateSlotRequest(trimToNull(description), fallbackRatio, ttlSeconds)); redirectAttributes.addFlashAttribute("flashStatus", "success"); redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); - return "redirect:/ads?tab=slots"; + return successRedirect; } catch (RestClientResponseException exception) { log.warn("Failed to update slot. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); } catch (RestClientException | IllegalStateException exception) { @@ -495,7 +491,7 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 수정에 실패했습니다. 잠시 후 다시 시도해주세요."); - return "redirect:/ads/slots/edit?id=" + slotId; + return failureRedirect; } @PostMapping("/slots/delete") @@ -801,89 +797,6 @@ private String normalizeTab(String tab) { }; } - private String normalizePeriod(String period) { - if (!StringUtils.hasText(period)) { - return "all"; - } - return switch (period.toLowerCase(Locale.ROOT)) { - case "today" -> "today"; - case "next7", "seven", "upcoming" -> "next7"; - default -> "all"; - }; - } - - private String normalizeStatus(String status) { - if (!StringUtils.hasText(status)) { - return "all"; - } - return switch (status.toLowerCase(Locale.ROOT)) { - case "active" -> "active"; - case "upcoming" -> "upcoming"; - case "ended", "past" -> "ended"; - default -> "all"; - }; - } - - private List filterAds(List ads, String query) { - if (!StringUtils.hasText(query)) { - return ads; - } - String normalized = query.trim().toLowerCase(Locale.ROOT); - return ads.stream() - .filter(item -> containsIgnoreCase(item.name(), normalized) - || containsIgnoreCase(item.targetUrl(), normalized) - || containsIgnoreCase(item.memo(), normalized)) - .toList(); - } - - private List filterPlacements(List placements, - Long slotFilter, - String periodFilter, - String statusFilter, - LocalDate today) { - String normalizedPeriod = normalizePeriod(periodFilter); - String normalizedStatus = normalizeStatus(statusFilter); - return placements.stream() - .filter(item -> slotFilter == null || Objects.equals(item.slotId(), slotFilter)) - .filter(item -> matchesPeriod(item, normalizedPeriod, today)) - .filter(item -> matchesStatus(item, normalizedStatus)) - .sorted(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))) - .toList(); - } - - private boolean matchesPeriod(AdPlacementItem item, String period, LocalDate today) { - if ("today".equals(period)) { - if (item.startDate() == null || item.endDate() == null) { - return false; - } - return !today.isBefore(item.startDate()) && !today.isAfter(item.endDate()); - } - if ("next7".equals(period)) { - if (item.startDate() == null || item.endDate() == null) { - return false; - } - LocalDate rangeEnd = today.plusDays(7); - return !item.endDate().isBefore(today) && !item.startDate().isAfter(rangeEnd); - } - return true; - } - - private boolean matchesStatus(AdPlacementItem item, String status) { - return switch (status) { - case "active" -> item.timeStatus() == PlacementTimeStatus.ACTIVE; - case "upcoming" -> item.timeStatus() == PlacementTimeStatus.UPCOMING; - case "ended" -> item.timeStatus() == PlacementTimeStatus.ENDED; - default -> true; - }; - } - - private boolean containsIgnoreCase(String text, String query) { - if (!StringUtils.hasText(text) || !StringUtils.hasText(query)) { - return false; - } - return text.toLowerCase(Locale.ROOT).contains(query); - } - private AdListItem toAdListItem(AdminAdListResponse.Item item) { return new AdListItem( item.id(), @@ -930,7 +843,8 @@ private AdPlacementItem toPlacementItem(AdminAdPlacementListResponse.Item item, enabled, item.createdAt(), item.updatedAt(), - timeStatus + timeStatus, + 0.0 ); } @@ -951,19 +865,23 @@ private PlacementTimeStatus resolveTimeStatus(AdminAdPlacementListResponse.Item private List buildPlacementGroups(List slotItems, Map slotMap, - List filteredPlacements, - Long slotFilter) { - Map> placementsBySlot = filteredPlacements.stream() + List placements) { + Map> placementsBySlot = placements.stream() .collect(Collectors.groupingBy(AdPlacementItem::slotId, LinkedHashMap::new, Collectors.toCollection(ArrayList::new))); List groups = new ArrayList<>(); for (AdSlotListItem slot : slotItems) { - if (slotFilter != null && !Objects.equals(slot.id(), slotFilter)) { - continue; - } - List placements = new ArrayList<>(placementsBySlot.getOrDefault(slot.id(), List.of())); - placements.sort(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))); - groups.add(new AdPlacementGroup(slot.id(), slot.code(), slot.description(), List.copyOf(placements))); + List slotPlacements = new ArrayList<>(placementsBySlot.getOrDefault(slot.id(), List.of())); + slotPlacements.sort(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))); + List enrichedPlacements = applyProbability(slot.fallbackRatioPercent(), slotPlacements); + groups.add(new AdPlacementGroup( + slot.id(), + slot.code(), + slot.description(), + slot.fallbackRatioPercent(), + slot.ttlSeconds(), + List.copyOf(enrichedPlacements) + )); } // Placements whose slot is missing from slot list @@ -971,17 +889,34 @@ private List buildPlacementGroups(List slotIte if (slotId == null || slotMap.containsKey(slotId)) { return; } - if (slotFilter != null && !Objects.equals(slotId, slotFilter)) { - return; - } items.sort(Comparator.comparing(AdPlacementItem::startDate, Comparator.nullsLast(Comparator.naturalOrder()))); + List enrichedPlacements = applyProbability(0.0, items); AdPlacementItem sample = items.get(0); - groups.add(new AdPlacementGroup(slotId, sample.slotName(), null, List.copyOf(items))); + groups.add(new AdPlacementGroup(slotId, sample.slotName(), null, 0.0, 0, List.copyOf(enrichedPlacements))); }); return groups; } + private List applyProbability(double fallbackRatioPercent, List placements) { + double normalizedFallback = Math.max(0.0, Math.min(100.0, fallbackRatioPercent)); + double remainingRatio = Math.max(0.0, 100.0 - normalizedFallback); + double totalWeight = placements.stream() + .filter(AdPlacementItem::isProbabilityEligible) + .mapToDouble(AdPlacementItem::weight) + .sum(); + + List results = new ArrayList<>(placements.size()); + for (AdPlacementItem item : placements) { + double probability = 0.0; + if (item.isProbabilityEligible() && totalWeight > 0.0) { + probability = remainingRatio * (item.weight() / totalWeight); + } + results.add(item.withDisplayProbability(probability)); + } + return results; + } + private List fetchAdOptions() { try { AdminAdListResponse response = adminAdClient.listAds(); diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java index 1d02187..057ea0f 100644 --- a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java @@ -6,10 +6,20 @@ public record AdPlacementGroup( Long slotId, String slotCode, String slotDescription, + double fallbackRatioPercent, + int slotTtlSeconds, List placements ) { public boolean hasPlacements() { return placements != null && !placements.isEmpty(); } + + public String fallbackProbabilityLabel() { + return Math.round(fallbackRatioPercent) + "%"; + } + + public boolean hasFallbackProbability() { + return fallbackRatioPercent > 0.0; + } } diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java index 017c5bb..c4372b1 100644 --- a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java @@ -17,10 +17,12 @@ public record AdPlacementItem( boolean enabled, OffsetDateTime createdAt, OffsetDateTime updatedAt, - PlacementTimeStatus timeStatus + PlacementTimeStatus timeStatus, + double displayProbabilityPercent ) { private static final DateTimeFormatter PERIOD_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final double PROBABILITY_EPSILON = 1.0E-6; public String periodLabel() { // 시작/종료일이 모두 없는 경우 @@ -42,18 +44,79 @@ public String periodLabel() { return PERIOD_FORMATTER.format(startDate) + " ~ " + PERIOD_FORMATTER.format(endDate); } + public AdPlacementItem withDisplayProbability(double probabilityPercent) { + return new AdPlacementItem( + id, + adId, + adName, + adImageUrl, + slotId, + slotName, + startDate, + endDate, + weight, + enabled, + createdAt, + updatedAt, + timeStatus, + probabilityPercent + ); + } + + public boolean isActiveNow() { + return timeStatus == PlacementTimeStatus.ACTIVE; + } + + public boolean isProbabilityEligible() { + return isActiveNow() && enabled; + } + + public boolean hasPositiveProbability() { + return displayProbabilityPercent > PROBABILITY_EPSILON; + } + + public boolean canToggle() { + return isActiveNow(); + } + public String displayStatusLabel() { - return enabled ? "노출 중" : "일시 중지"; + if (isActiveNow()) { + return enabled ? "노출 중" : "일시 중지"; + } + return switch (timeStatus) { + case UPCOMING -> "시작 예정"; + case ENDED -> "종료"; + case ACTIVE -> "노출 중"; + }; } public String timeStatusLabel() { return switch (timeStatus) { case ACTIVE -> "노출 중"; - case UPCOMING -> "예정"; + case UPCOMING -> "시작 예정"; case ENDED -> "종료"; }; } + public String timeStatusBadgeClass() { + return switch (timeStatus) { + case ACTIVE -> "text-bg-success"; + case UPCOMING -> "text-bg-info"; + case ENDED -> "text-bg-secondary"; + }; + } + + public String statusBadgeClass() { + if (isActiveNow()) { + return enabled ? "text-bg-success" : "text-bg-warning"; + } + return switch (timeStatus) { + case UPCOMING -> "text-bg-info"; + case ENDED -> "text-bg-secondary"; + case ACTIVE -> enabled ? "text-bg-success" : "text-bg-warning"; + }; + } + public String weightLabel() { return Short.toString(weight); } @@ -61,4 +124,8 @@ public String weightLabel() { public boolean hasImage() { return adImageUrl != null && !adImageUrl.isBlank(); } + + public String probabilityLabel() { + return Math.round(displayProbabilityPercent) + "%"; + } } diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java index 9dc38c5..8ca7730 100644 --- a/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java @@ -16,4 +16,8 @@ public record AdSlotListItem( public String fallbackRatioLabel() { return String.format("%.1f%%", fallbackRatioPercent); } + + public String fallbackProbabilityLabel() { + return Math.round(fallbackRatioPercent) + "%"; + } } diff --git a/src/main/resources/static/css/ads.css b/src/main/resources/static/css/ads.css index ff93409..8d4d570 100644 --- a/src/main/resources/static/css/ads.css +++ b/src/main/resources/static/css/ads.css @@ -312,6 +312,70 @@ font-weight: 600; } +.ads-period-badge, +.ads-status-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.35rem 0.75rem; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.ads-status-badge { + font-size: 0.8rem; +} + +.ads-probability-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.25rem; + padding: 0.45rem 0.95rem; + border-radius: 999px; + font-size: 0.95rem; + font-weight: 700; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.ads-probability-badge--active { + background: linear-gradient(135deg, #1d4ed8, #6366f1); + color: #ffffff; + box-shadow: 0 14px 28px rgba(99, 102, 241, 0.3); +} + +.ads-probability-badge--active:hover { + transform: translateY(-2px); + box-shadow: 0 18px 36px rgba(99, 102, 241, 0.35); +} + +.ads-probability-badge--muted { + background: #e2e8f0; + color: #475569; +} + +.ads-fallback-row { + background: linear-gradient(90deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.05)); +} + +.ads-fallback-row td { + border-top: none; +} + +.ads-fallback-icon { + width: 48px; + height: 48px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(99, 102, 241, 0.18)); + color: #1d4ed8; + font-size: 1.5rem; + box-shadow: 0 14px 28px rgba(59, 130, 246, 0.18); +} + @media (max-width: 991.98px) { .ads-tabs { width: 100%; diff --git a/src/main/resources/templates/ads/list.html b/src/main/resources/templates/ads/list.html index 56efaf0..8413702 100644 --- a/src/main/resources/templates/ads/list.html +++ b/src/main/resources/templates/ads/list.html @@ -94,27 +94,6 @@

광고 관리

-
-
-
- -
- - -
-
- - 초기화 -
-
-
-
-
광고 목록을 불러오지 못했습니다. @@ -141,13 +120,15 @@

광고

광고 이름 이동할 링크(URL) 메모 - 등록일 / 수정일 액션 - 등록된 광고가 없습니다. + + 등록된 광고가 없습니다. + @@ -168,14 +149,6 @@

광고

메모 - -
등록 - - -
-
수정 - - -
-
광고 위치
설정 +
+ + +
@@ -261,53 +241,6 @@

광고 위치

-
-
-
- -
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
@@ -344,14 +277,47 @@

HOME_TOP

광고 노출 기간 노출 강도 + 노출 확률 노출 상태 - 등록일 / 수정일 액션 + + +
+
+ +
+
+
기본 광고
+
광고가 없을 때 표시되는 기본 콘텐츠입니다.
+
+
+ + - + - + + 50% + + - + +
+ +
+ + - 등록된 스케줄이 없습니다. + + 등록된 스케줄이 없습니다. + @@ -369,29 +335,29 @@

HOME_TOP

-
2025.01.01 ~ 2025.01.31
-
노출 중
+
2025.01.01 ~ 2025.01.31
+ 노출 중 1 - 노출 중 + 0% -
등록 - - -
-
수정 - - -
+ 노출 중 -
-
+
+
+ + + +
From 41fdb8abb80644da551178f09c0d35c1ae48cc16 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:24:21 +0900 Subject: [PATCH 3/7] Fix sidebar environment badge rendering (#51) --- src/main/resources/templates/fragments/_sidebar.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index aaab3f4..d67abaf 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -9,8 +9,8 @@ 새록 어드민 운영 + th:classappend="${environmentProfileBadge.cssClass}" + th:text="${environmentProfileBadge.label}">운영
From 41de1275b2bec6e6dab7eb97f53b80821463790f Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:53:28 +0900 Subject: [PATCH 4/7] Combine ad location and placement management into a single schedule tab (#52) * Combine ad schedule management into single tab * Refine ad schedule tab layout * Refine ad schedule wording and priority labels * Color-code ad schedule priorities --- .../apu/saerok_admin/web/AdController.java | 71 ++++--- .../web/view/ad/AdPlacementItem.java | 26 ++- src/main/resources/static/css/ads.css | 76 +++++++ src/main/resources/templates/ads/list.html | 192 +++++------------- .../templates/ads/placement-form.html | 24 +-- .../resources/templates/ads/slot-form.html | 2 +- 6 files changed, 208 insertions(+), 183 deletions(-) diff --git a/src/main/java/apu/saerok_admin/web/AdController.java b/src/main/java/apu/saerok_admin/web/AdController.java index ee55d38..510d4c2 100644 --- a/src/main/java/apu/saerok_admin/web/AdController.java +++ b/src/main/java/apu/saerok_admin/web/AdController.java @@ -347,14 +347,14 @@ public String newSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPro if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } model.addAttribute("pageTitle", "새 광고 위치 추가"); model.addAttribute("activeMenu", "ads"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("광고 관리", "/ads?tab=slots"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), Breadcrumb.active("새 광고 위치 추가") )); model.addAttribute("toastMessages", List.of()); @@ -372,7 +372,7 @@ public String createSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } if (!StringUtils.hasText(code) || fallbackRatioPercent == null || ttlSeconds == null) { @@ -388,7 +388,7 @@ public String createSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf adminAdClient.createSlot(new AdminCreateSlotRequest(code.trim(), trimToNull(description), fallbackRatio, ttlSeconds)); redirectAttributes.addFlashAttribute("flashStatus", "success"); redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } catch (RestClientResponseException exception) { log.warn("Failed to create slot. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); } catch (RestClientException | IllegalStateException exception) { @@ -408,7 +408,7 @@ public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPr if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } try { @@ -422,7 +422,7 @@ public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPr if (match.isEmpty()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "해당 광고 위치를 찾을 수 없습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } AdminSlotListResponse.Item item = match.get(); @@ -433,7 +433,7 @@ public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPr model.addAttribute("activeMenu", "ads"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("광고 관리", "/ads?tab=slots"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), Breadcrumb.active("광고 위치 수정") )); model.addAttribute("toastMessages", List.of()); @@ -447,7 +447,7 @@ public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPr redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 정보를 불러오지 못했습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } @PostMapping("/slots/edit") @@ -459,9 +459,18 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf @RequestParam(name = "redirectTab", defaultValue = "slots") String redirectTab, RedirectAttributes redirectAttributes) { String normalizedRedirectTab = normalizeTab(redirectTab); - boolean redirectToPlacements = "placements".equals(normalizedRedirectTab); - String successRedirect = redirectToPlacements ? "redirect:/ads?tab=placements" : "redirect:/ads?tab=slots"; - String failureRedirect = redirectToPlacements ? "redirect:/ads?tab=placements" : "redirect:/ads/slots/edit?id=" + slotId; + String successRedirect = "redirect:/ads?tab=" + normalizedRedirectTab; + boolean redirectFromSchedule = "schedule".equals(normalizedRedirectTab) + && StringUtils.hasText(redirectTab) + && !"slots".equalsIgnoreCase(redirectTab); + String failureRedirect; + if ("ads".equals(normalizedRedirectTab)) { + failureRedirect = successRedirect; + } else if (redirectFromSchedule) { + failureRedirect = successRedirect; + } else { + failureRedirect = "redirect:/ads/slots/edit?id=" + slotId; + } if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); @@ -501,7 +510,7 @@ public String deleteSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 삭제할 권한이 없습니다."); - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } try { @@ -518,7 +527,7 @@ public String deleteSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf redirectAttributes.addFlashAttribute("flashMessage", "광고 위치 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); } - return "redirect:/ads?tab=slots"; + return "redirect:/ads?tab=schedule"; } @GetMapping("/placements/new") @@ -529,7 +538,7 @@ public String newPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAdm if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } List ads = fetchAdOptions(); @@ -539,7 +548,7 @@ public String newPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAdm model.addAttribute("activeMenu", "ads"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("광고 관리", "/ads?tab=placements"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), Breadcrumb.active("새 스케줄 등록") )); model.addAttribute("toastMessages", List.of()); @@ -561,7 +570,7 @@ public String createPlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } try { @@ -576,7 +585,7 @@ public String createPlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi adminAdClient.createPlacement(request); redirectAttributes.addFlashAttribute("flashStatus", "success"); redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } catch (Exception exception) { log.warn("Failed to create placement.", exception); } @@ -594,7 +603,7 @@ public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAd if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } try { @@ -608,7 +617,7 @@ public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAd if (match.isEmpty()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "해당 스케줄을 찾을 수 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } AdminAdPlacementListResponse.Item item = match.get(); @@ -626,7 +635,7 @@ public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAd model.addAttribute("activeMenu", "ads"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("광고 관리", "/ads?tab=placements"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), Breadcrumb.active("스케줄 수정") )); model.addAttribute("toastMessages", List.of()); @@ -642,7 +651,7 @@ public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAd redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 정보를 불러오지 못했습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } @PostMapping("/placements/edit") @@ -657,7 +666,7 @@ public String updatePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } try { @@ -671,7 +680,7 @@ public String updatePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi adminAdClient.updatePlacement(placementId, request); redirectAttributes.addFlashAttribute("flashStatus", "success"); redirectAttributes.addFlashAttribute("flashMessage", "저장되었습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } catch (Exception exception) { log.warn("Failed to update placement.", exception); } @@ -688,7 +697,7 @@ public String deletePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 삭제할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } try { @@ -705,7 +714,7 @@ public String deletePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); } - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } @PostMapping("/placements/toggle") @@ -716,7 +725,7 @@ public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi if (!currentAdminProfile.isAdminEditor()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태를 변경할 권한이 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } try { @@ -730,7 +739,7 @@ public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi if (match.isEmpty()) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "해당 스케줄을 찾을 수 없습니다."); - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } AdminAdPlacementListResponse.Item item = match.get(); @@ -754,7 +763,7 @@ public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요."); } - return "redirect:/ads?tab=placements"; + return "redirect:/ads?tab=schedule"; } @PostMapping("/image/presign") @@ -791,8 +800,10 @@ private String normalizeTab(String tab) { if (!StringUtils.hasText(tab)) { return "ads"; } - return switch (tab.toLowerCase(Locale.ROOT)) { - case "ads", "slots", "placements" -> tab.toLowerCase(Locale.ROOT); + String tabLower = tab.toLowerCase(Locale.ROOT); + return switch (tabLower) { + case "ads" -> "ads"; + case "slots", "placements", "schedule" -> "schedule"; default -> "ads"; }; } diff --git a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java index c4372b1..9ed54c6 100644 --- a/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java @@ -81,18 +81,18 @@ public boolean canToggle() { public String displayStatusLabel() { if (isActiveNow()) { - return enabled ? "노출 중" : "일시 중지"; + return enabled ? "진행 중" : "일시 중지"; } return switch (timeStatus) { case UPCOMING -> "시작 예정"; case ENDED -> "종료"; - case ACTIVE -> "노출 중"; + case ACTIVE -> "진행 중"; }; } public String timeStatusLabel() { return switch (timeStatus) { - case ACTIVE -> "노출 중"; + case ACTIVE -> "진행 중"; case UPCOMING -> "시작 예정"; case ENDED -> "종료"; }; @@ -118,7 +118,25 @@ public String statusBadgeClass() { } public String weightLabel() { - return Short.toString(weight); + return switch (weight) { + case 1 -> "매우 낮음"; + case 2 -> "낮음"; + case 3 -> "보통"; + case 4 -> "높음"; + case 5 -> "매우 높음"; + default -> Short.toString(weight); + }; + } + + public String priorityBadgeClass() { + return switch (weight) { + case 1 -> "ads-priority-badge--very-low"; + case 2 -> "ads-priority-badge--low"; + case 3 -> "ads-priority-badge--medium"; + case 4 -> "ads-priority-badge--high"; + case 5 -> "ads-priority-badge--very-high"; + default -> "ads-priority-badge--unknown"; + }; } public boolean hasImage() { diff --git a/src/main/resources/static/css/ads.css b/src/main/resources/static/css/ads.css index 8d4d570..99e1dbd 100644 --- a/src/main/resources/static/css/ads.css +++ b/src/main/resources/static/css/ads.css @@ -327,6 +327,82 @@ font-size: 0.8rem; } +.ads-priority-badge { + --ads-priority-bg: rgba(226, 232, 240, 0.85); + --ads-priority-border: rgba(148, 163, 184, 0.45); + --ads-priority-color: #334155; + --ads-priority-shadow: 0 12px 24px rgba(148, 163, 184, 0.18); + --ads-priority-shadow-hover: 0 16px 32px rgba(148, 163, 184, 0.24); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.5rem; + padding: 0.45rem 1.15rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.01em; + text-transform: none; + background: var(--ads-priority-bg); + color: var(--ads-priority-color); + border: 1px solid var(--ads-priority-border); + box-shadow: var(--ads-priority-shadow); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.ads-priority-badge:hover { + transform: translateY(-2px); + box-shadow: var(--ads-priority-shadow-hover); +} + +.ads-priority-badge--very-low { + --ads-priority-bg: rgba(148, 163, 184, 0.22); + --ads-priority-border: rgba(148, 163, 184, 0.38); + --ads-priority-color: #475569; + --ads-priority-shadow: 0 12px 26px rgba(148, 163, 184, 0.26); + --ads-priority-shadow-hover: 0 16px 34px rgba(148, 163, 184, 0.3); +} + +.ads-priority-badge--low { + --ads-priority-bg: rgba(110, 231, 183, 0.24); + --ads-priority-border: rgba(16, 185, 129, 0.32); + --ads-priority-color: #047857; + --ads-priority-shadow: 0 12px 26px rgba(16, 185, 129, 0.28); + --ads-priority-shadow-hover: 0 16px 34px rgba(16, 185, 129, 0.32); +} + +.ads-priority-badge--medium { + --ads-priority-bg: rgba(253, 230, 138, 0.28); + --ads-priority-border: rgba(217, 119, 6, 0.32); + --ads-priority-color: #b45309; + --ads-priority-shadow: 0 12px 26px rgba(217, 119, 6, 0.26); + --ads-priority-shadow-hover: 0 16px 34px rgba(217, 119, 6, 0.3); +} + +.ads-priority-badge--high { + --ads-priority-bg: rgba(96, 165, 250, 0.28); + --ads-priority-border: rgba(37, 99, 235, 0.35); + --ads-priority-color: #1d4ed8; + --ads-priority-shadow: 0 12px 26px rgba(59, 130, 246, 0.3); + --ads-priority-shadow-hover: 0 16px 34px rgba(59, 130, 246, 0.35); +} + +.ads-priority-badge--very-high { + --ads-priority-bg: rgba(248, 113, 113, 0.26); + --ads-priority-border: rgba(225, 29, 72, 0.35); + --ads-priority-color: #be123c; + --ads-priority-shadow: 0 12px 26px rgba(225, 29, 72, 0.3); + --ads-priority-shadow-hover: 0 16px 34px rgba(225, 29, 72, 0.34); +} + +.ads-priority-badge--unknown { + --ads-priority-bg: rgba(226, 232, 240, 0.85); + --ads-priority-border: rgba(148, 163, 184, 0.45); + --ads-priority-color: #334155; + --ads-priority-shadow: 0 12px 24px rgba(148, 163, 184, 0.18); + --ads-priority-shadow-hover: 0 16px 32px rgba(148, 163, 184, 0.24); +} + .ads-probability-badge { display: inline-flex; align-items: center; diff --git a/src/main/resources/templates/ads/list.html b/src/main/resources/templates/ads/list.html index 8413702..42f51d4 100644 --- a/src/main/resources/templates/ads/list.html +++ b/src/main/resources/templates/ads/list.html @@ -8,54 +8,18 @@

광고 관리

-

광고, 광고 위치, 노출 스케줄을 한 곳에서 관리합니다.

-
-
- - - 새 광고 등록 - - - - 새 광고 위치 추가 - - - - 새 스케줄 등록 - +

광고와 광고 스케줄을 한 곳에서 관리합니다.

-
-
-
-
- -
-
등록된 광고
-
0
-
-
-
-
-
-
-
- -
-
광고 위치
-
0
-
-
-
-
활성 스케줄
+
지금 노출되고 있는 광고 수
0
@@ -78,15 +42,9 @@

광고 관리

- @@ -99,12 +57,16 @@

광고 관리

광고 목록을 불러오지 못했습니다.
+
+
+

광고

+

노출할 광고의 이름, 링크, 이미지를 설정합니다.

+
+
+
-
-

광고

-

노출할 광고의 이름, 링크, 이미지를 설정합니다.

-
+

광고 목록

- -
-
- - 광고 위치를 불러오지 못했습니다. -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
광고 위치 코드설명기본 노출 비율노출 중인 스케줄 수액션
등록된 광고 위치가 없습니다.
HOME_TOP - 설명 - - 50% - - 0 - -
- 설정 -
- - -
-
-
+ +
+
+
+

광고 스케줄

+

광고를 노출할 스케줄을 설정합니다.

-
- -
광고 노출 스케줄을 불러오지 못했습니다.
+
+ + 광고 위치를 불러오지 못했습니다. +
+
등록된 광고 노출 스케줄이 없습니다. @@ -262,12 +167,22 @@

HOME_TOP

광고 위치 설명

-
+
새 스케줄 등록 + 광고 위치 설정 +
+ + +
@@ -275,11 +190,11 @@

HOME_TOP

광고 - 노출 기간 - 노출 강도 - 노출 확률 - 노출 상태 - 액션 + 진행 기간 + 우선순위 + 광고가 뜰 확률 + 진행 상태 + @@ -309,7 +224,7 @@

HOME_TOP

data-bs-toggle="modal" data-bs-target="#fallbackRatioModal" th:attr="data-slot-id=${group.slotId()}, data-slot-code=${group.slotCode()}, data-fallback-ratio=${group.fallbackRatioPercent()}, data-slot-description=${group.slotDescription() == null ? '' : group.slotDescription()}, data-slot-ttl=${group.slotTtlSeconds()}" - >기본 확률 수정 + >기본 광고가 뜰 확률 수정
@@ -335,14 +250,12 @@

HOME_TOP

-
2025.01.01 ~ 2025.01.31
- 노출 중 +
2025.01.01 ~ 2025.01.31
- 1 + 매우 낮음 HOME_TOP 노출 중 + th:text="${placement.displayStatusLabel()}">진행 중
@@ -382,6 +295,13 @@

HOME_TOP

+ +
@@ -205,7 +205,7 @@

HOME_TOP

-
기본 광고
+
기본 광고 (Kakao AdFit)
광고가 없을 때 표시되는 기본 콘텐츠입니다.
@@ -224,7 +224,7 @@

HOME_TOP

data-bs-toggle="modal" data-bs-target="#fallbackRatioModal" th:attr="data-slot-id=${group.slotId()}, data-slot-code=${group.slotCode()}, data-fallback-ratio=${group.fallbackRatioPercent()}, data-slot-description=${group.slotDescription() == null ? '' : group.slotDescription()}, data-slot-ttl=${group.slotTtlSeconds()}" - >기본 광고가 뜰 확률 수정 + >기본 광고 표시 확률 수정
@@ -310,7 +310,7 @@

HOME_TOP