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..510d4c2 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/AdController.java @@ -0,0 +1,972 @@ + +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, + 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 = "광고 위치 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + } + + Map slotMap = slotItems.stream() + .collect(Collectors.toMap(AdSlotListItem::id, item -> item, (left, right) -> left, LinkedHashMap::new)); + + List placementGroups = buildPlacementGroups(slotItems, slotMap, placementItems); + + 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", adItems); + model.addAttribute("adsLoadError", adsLoadError); + model.addAttribute("slots", slotItems); + model.addAttribute("slotsLoadError", slotsLoadError); + model.addAttribute("placementsLoadError", placementsLoadError); + model.addAttribute("placementGroups", placementGroups); + model.addAttribute("hasPlacements", !placementItems.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=schedule"; + } + + model.addAttribute("pageTitle", "새 광고 위치 추가"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), + 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=schedule"; + } + + 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=schedule"; + } 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=schedule"; + } + + 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=schedule"; + } + + 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=schedule"), + 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=schedule"; + } + + @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, + @RequestParam(name = "redirectTab", defaultValue = "slots") String redirectTab, + RedirectAttributes redirectAttributes) { + String normalizedRedirectTab = normalizeTab(redirectTab); + 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"); + redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); + return successRedirect; + } + + if (fallbackRatioPercent == null || ttlSeconds == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return failureRedirect; + } + + 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 successRedirect; + } 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 failureRedirect; + } + + @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=schedule"; + } + + 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=schedule"; + } + + @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=schedule"; + } + + List ads = fetchAdOptions(); + List slots = fetchSlotOptions(); + + model.addAttribute("pageTitle", "새 스케줄 등록"); + model.addAttribute("activeMenu", "ads"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("광고 관리", "/ads?tab=schedule"), + 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=schedule"; + } + + 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=schedule"; + } 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=schedule"; + } + + 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=schedule"; + } + + 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=schedule"), + 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=schedule"; + } + + @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=schedule"; + } + + 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=schedule"; + } 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=schedule"; + } + + 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=schedule"; + } + + @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=schedule"; + } + + 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=schedule"; + } + + 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=schedule"; + } + + @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"; + } + String tabLower = tab.toLowerCase(Locale.ROOT); + return switch (tabLower) { + case "ads" -> "ads"; + case "slots", "placements", "schedule" -> "schedule"; + default -> "ads"; + }; + } + + 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, + 0.0 + ); + } + + 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 placements) { + Map> placementsBySlot = placements.stream() + .collect(Collectors.groupingBy(AdPlacementItem::slotId, LinkedHashMap::new, Collectors.toCollection(ArrayList::new))); + + List groups = new ArrayList<>(); + for (AdSlotListItem slot : slotItems) { + 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 + placementsBySlot.forEach((slotId, items) -> { + if (slotId == null || slotMap.containsKey(slotId)) { + 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, 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(); + 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/AdminAuditLogController.java b/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java index f643a08..e44690a 100644 --- a/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java +++ b/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java @@ -30,16 +30,28 @@ public class AdminAuditLogController { private static final Logger log = LoggerFactory.getLogger(AdminAuditLogController.class); private static final int DEFAULT_PAGE_SIZE = 20; private static final List PAGE_SIZE_OPTIONS = List.of(10, 20, 50); - private static final Map ACTION_PRESENTATIONS = Map.of( - "REPORT_IGNORED", new ActionPresentation("신고 무시", "신고를 추가 조치 없이 마감했습니다.", "text-bg-secondary"), - "COLLECTION_DELETED", new ActionPresentation("컬렉션 삭제", "신고된 컬렉션을 삭제했습니다.", "text-bg-danger"), - "COMMENT_DELETED", new ActionPresentation("댓글 삭제", "신고된 댓글을 삭제했습니다.", "text-bg-danger") + private static final Map ACTION_PRESENTATIONS = Map.ofEntries( + Map.entry("REPORT_IGNORED", new ActionPresentation("신고 무시", "신고를 추가 조치 없이 마감했습니다.", "text-bg-secondary")), + Map.entry("COLLECTION_DELETED", new ActionPresentation("새록 삭제", "신고된 새록을 삭제했습니다.", "text-bg-danger")), + Map.entry("COMMENT_DELETED", new ActionPresentation("댓글 삭제", "신고된 댓글을 삭제했습니다.", "text-bg-danger")), + Map.entry("AD_CREATED", new ActionPresentation("광고 등록", "새 광고를 등록했습니다.", "text-bg-success")), + Map.entry("AD_UPDATED", new ActionPresentation("광고 수정", "기존 광고 정보를 수정했습니다.", "text-bg-info")), + Map.entry("AD_DELETED", new ActionPresentation("광고 삭제", "광고를 삭제했습니다.", "text-bg-danger")), + Map.entry("SLOT_CREATED", new ActionPresentation("광고 위치 등록", "새 광고 위치를 등록했습니다.", "text-bg-success")), + Map.entry("SLOT_UPDATED", new ActionPresentation("광고 위치 수정", "광고 위치 정보를 수정했습니다.", "text-bg-info")), + Map.entry("SLOT_DELETED", new ActionPresentation("광고 위치 삭제", "광고 위치를 삭제했습니다.", "text-bg-danger")), + Map.entry("AD_PLACEMENT_CREATED", new ActionPresentation("광고 스케줄 등록", "새 광고 스케줄을 등록했습니다.", "text-bg-success")), + Map.entry("AD_PLACEMENT_UPDATED", new ActionPresentation("광고 스케줄 수정", "광고 스케줄을 수정했습니다.", "text-bg-info")), + Map.entry("AD_PLACEMENT_DELETED", new ActionPresentation("광고 스케줄 삭제", "광고 스케줄을 삭제했습니다.", "text-bg-danger")) ); - private static final Map TARGET_LABELS = Map.of( - "REPORT_COLLECTION", "컬렉션 신고", - "REPORT_COMMENT", "댓글 신고", - "COLLECTION", "컬렉션", - "COMMENT", "댓글" + private static final Map TARGET_LABELS = Map.ofEntries( + Map.entry("REPORT_COLLECTION", "새록 신고"), + Map.entry("REPORT_COMMENT", "댓글 신고"), + Map.entry("COLLECTION", "새록"), + Map.entry("COMMENT", "댓글"), + Map.entry("AD", "광고"), + Map.entry("SLOT", "광고 위치"), + Map.entry("AD_PLACEMENT", "광고 스케줄") ); private static final String UNKNOWN_ACTION_LABEL = "기록되지 않은 작업"; private static final String UNKNOWN_ACTION_DESCRIPTION = "정의되지 않은 관리자 활동입니다."; 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..057ea0f --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementGroup.java @@ -0,0 +1,25 @@ +package apu.saerok_admin.web.view.ad; + +import java.util.List; + +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 new file mode 100644 index 0000000..9ed54c6 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdPlacementItem.java @@ -0,0 +1,149 @@ +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, + 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() { + // 시작/종료일이 모두 없는 경우 + 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 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() { + if (isActiveNow()) { + return enabled ? "진행 중" : "일시 중지"; + } + return switch (timeStatus) { + case UPCOMING -> "시작 예정"; + case ENDED -> "종료"; + case ACTIVE -> "진행 중"; + }; + } + + public String timeStatusLabel() { + return switch (timeStatus) { + case ACTIVE -> "진행 중"; + 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 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() { + return adImageUrl != null && !adImageUrl.isBlank(); + } + + public String probabilityLabel() { + return Math.round(displayProbabilityPercent) + "%"; + } +} 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..8ca7730 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ad/AdSlotListItem.java @@ -0,0 +1,23 @@ +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); + } + + public String fallbackProbabilityLabel() { + return Math.round(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..99e1dbd --- /dev/null +++ b/src/main/resources/static/css/ads.css @@ -0,0 +1,483 @@ +: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; +} + +.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-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; + 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%; + 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..b4a9e07 --- /dev/null +++ b/src/main/resources/templates/ads/list.html @@ -0,0 +1,401 @@ + + + + + +
+
+
+

광고 관리

+

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

+
+
+ +
+
+
+
+
+ +
+
지금 진행 중인 광고 수
+
0
+
+
+
+
+ +
+ + 처리가 완료되었습니다. +
+ + + + +
+
+ + 광고 목록을 불러오지 못했습니다. +
+ +
+
+

광고

+

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

+
+
+ +
+
+

광고 목록

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
광고 이미지광고 이름이동할 링크(URL)메모
+ 등록된 광고가 없습니다. +
+
+ 광고 이미지 +
+
+ +
+
광고 이름 + + https://example.com + + 메모 +
+ 수정 +
+ + +
+
+
+
+
+
+ + +
+
+
+

광고 스케줄

+

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

+
+
+ +
+ + 광고 노출 스케줄을 불러오지 못했습니다. +
+ +
+ + 광고 위치를 불러오지 못했습니다. +
+ +
+
+ 등록된 광고 노출 스케줄이 없습니다. +
+
+ +
+
+
+

HOME_TOP

+

+ 광고 위치 설명 +

+
+
+ + + 새 스케줄 등록 + + 광고 위치 설정 +
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
광고진행 기간우선순위광고가 뜰 확률진행 상태
+
+
+ +
+
+
기본 광고 (Kakao AdFit)
+
광고가 없을 때 표시되는 기본 콘텐츠입니다.
+
+
+
-- + 50% + - +
+ +
+
+ 등록된 스케줄이 없습니다. +
+
+
+ 광고 미리보기 +
+
+ +
+
광고 이름
+
+
+
2025.01.01 ~ 2025.01.31
+
+ 매우 낮음 + + 0% + + 진행 중 + +
+
+ + + +
+ 수정 +
+ + +
+
+
+
+
+ + +
+ + + + + +
+
+ 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..b73a523 --- /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..5f220fd --- /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..b6720d7 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}">운영 @@ -81,19 +85,25 @@
새록 어드민
신고 관리 - - - 관리자 활동 로그 + + + 광고 관리 시스템 알림 발송 + + + 관리자 활동 로그 + + +