From 890a4142757686e20c5eec424c97a3435a44bf77 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 5 Jul 2026 11:55:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8F=84=EA=B0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/dex/AdminDexClient.java | 111 ++++++ .../dex/dto/AdminBirdDetailResponse.java | 75 ++++ .../dex/dto/AdminBirdImagePresignRequest.java | 4 + .../dto/AdminBirdImagePresignResponse.java | 7 + .../infra/dex/dto/AdminBirdListResponse.java | 24 ++ .../infra/dex/dto/AdminBirdUpsertRequest.java | 59 +++ .../apu/saerok_admin/web/DexController.java | 371 ++++++++++++++---- .../saerok_admin/web/form/DexUpsertForm.java | 53 +++ .../web/support/DexFormMapper.java | 267 +++++++++++++ .../saerok_admin/web/support/DexOptions.java | 93 +++++ .../apu/saerok_admin/web/view/DexDetail.java | 72 +++- .../saerok_admin/web/view/DexFormModel.java | 9 +- .../saerok_admin/web/view/DexListItem.java | 14 +- .../apu/saerok_admin/web/view/DexOption.java | 4 + .../resources/static/js/dex-image-upload.js | 203 ++++++++++ src/main/resources/templates/dex/detail.html | 257 +++++++++--- src/main/resources/templates/dex/form.html | 339 +++++++++++----- src/main/resources/templates/dex/list.html | 253 ++++++------ .../templates/fragments/_sidebar.html | 11 +- .../infra/dex/AdminDexClientTest.java | 321 +++++++++++++++ .../saerok_admin/web/DexControllerTest.java | 303 ++++++++++++++ .../web/support/DexFormMapperTest.java | 67 ++++ 22 files changed, 2531 insertions(+), 386 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/infra/dex/AdminDexClient.java create mode 100644 src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdDetailResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdListResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdUpsertRequest.java create mode 100644 src/main/java/apu/saerok_admin/web/form/DexUpsertForm.java create mode 100644 src/main/java/apu/saerok_admin/web/support/DexFormMapper.java create mode 100644 src/main/java/apu/saerok_admin/web/support/DexOptions.java create mode 100644 src/main/java/apu/saerok_admin/web/view/DexOption.java create mode 100644 src/main/resources/static/js/dex-image-upload.js create mode 100644 src/test/java/apu/saerok_admin/infra/dex/AdminDexClientTest.java create mode 100644 src/test/java/apu/saerok_admin/web/DexControllerTest.java create mode 100644 src/test/java/apu/saerok_admin/web/support/DexFormMapperTest.java diff --git a/src/main/java/apu/saerok_admin/infra/dex/AdminDexClient.java b/src/main/java/apu/saerok_admin/infra/dex/AdminDexClient.java new file mode 100644 index 0000000..3a6fab9 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/AdminDexClient.java @@ -0,0 +1,111 @@ +package apu.saerok_admin.infra.dex; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.dex.dto.AdminBirdDetailResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdImagePresignRequest; +import apu.saerok_admin.infra.dex.dto.AdminBirdImagePresignResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdListResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdUpsertRequest; +import java.net.URI; +import java.util.List; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminDexClient { + + private static final String[] ADMIN_DEX_BIRD_SEGMENTS = {"admin", "dex", "birds"}; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminDexClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + List missing = saerokApiProps.missingPrefixSegments(); + this.missingPrefixSegments = missing.toArray(new String[0]); + } + + public AdminBirdListResponse listBirds(String query, int page, int size) { + AdminBirdListResponse response = saerokRestClient.get() + .uri(uriBuilder -> { + UriBuilder builder = buildBaseUri(uriBuilder); + if (StringUtils.hasText(query)) { + builder.queryParam("q", query.trim()); + } + builder.queryParam("page", page); + builder.queryParam("size", size); + return builder.build(); + }) + .retrieve() + .body(AdminBirdListResponse.class); + if (response == null) { + throw new IllegalStateException("Empty response from admin dex API"); + } + return response; + } + + public AdminBirdDetailResponse getBird(Long birdId) { + AdminBirdDetailResponse response = saerokRestClient.get() + .uri(uriBuilder -> buildUri(uriBuilder, birdId.toString())) + .retrieve() + .body(AdminBirdDetailResponse.class); + if (response == null) { + throw new IllegalStateException("Empty response from admin dex API"); + } + return response; + } + + public AdminBirdDetailResponse createBird(AdminBirdUpsertRequest request) { + AdminBirdDetailResponse response = saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder)) + .body(request) + .retrieve() + .body(AdminBirdDetailResponse.class); + if (response == null) { + throw new IllegalStateException("Empty response from admin dex API"); + } + return response; + } + + public AdminBirdDetailResponse updateBird(Long birdId, AdminBirdUpsertRequest request) { + AdminBirdDetailResponse response = saerokRestClient.method(HttpMethod.PUT) + .uri(uriBuilder -> buildUri(uriBuilder, birdId.toString())) + .body(request) + .retrieve() + .body(AdminBirdDetailResponse.class); + if (response == null) { + throw new IllegalStateException("Empty response from admin dex API"); + } + return response; + } + + public AdminBirdImagePresignResponse generateImagePresignUrl(AdminBirdImagePresignRequest request) { + AdminBirdImagePresignResponse response = saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder, "image", "presign")) + .body(request) + .retrieve() + .body(AdminBirdImagePresignResponse.class); + if (response == null) { + throw new IllegalStateException("Empty response from admin dex image API"); + } + return response; + } + + private URI buildUri(UriBuilder builder, String... segments) { + UriBuilder base = buildBaseUri(builder); + if (segments != null && segments.length > 0) { + base.pathSegment(segments); + } + return base.build(); + } + + private UriBuilder buildBaseUri(UriBuilder builder) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + return builder.pathSegment(ADMIN_DEX_BIRD_SEGMENTS); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdDetailResponse.java b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdDetailResponse.java new file mode 100644 index 0000000..00e24c9 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdDetailResponse.java @@ -0,0 +1,75 @@ +package apu.saerok_admin.infra.dex.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminBirdDetailResponse( + Long id, + BirdName name, + BirdTaxonomy taxonomy, + BirdDescription description, + Double bodyLengthCm, + String nibrUrl, + String conservationGrade, + List habitats, + List residencies, + List seasonsWithRarity, + List images, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + public record BirdName( + String koreanName, + String scientificName, + String scientificAuthor, + Integer scientificYear + ) { + } + + public record BirdTaxonomy( + String phylumEng, + String phylumKor, + String classEng, + String classKor, + String orderEng, + String orderKor, + String familyEng, + String familyKor, + String genusEng, + String genusKor, + String speciesEng, + String speciesKor + ) { + } + + public record BirdDescription( + String description, + String source, + Boolean isAiGenerated + ) { + } + + public record SeasonWithRarity( + String season, + String rarity, + Integer priority + ) { + } + + public record Residency( + String residencyType, + String rarity, + Integer monthBitmask, + Integer effectiveMonthBitmask + ) { + } + + public record Image( + String objectKey, + String imageUrl, + String originalUrl, + Integer orderIndex, + Boolean isThumb + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignRequest.java b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignRequest.java new file mode 100644 index 0000000..fefbc31 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignRequest.java @@ -0,0 +1,4 @@ +package apu.saerok_admin.infra.dex.dto; + +public record AdminBirdImagePresignRequest(String contentType) { +} diff --git a/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignResponse.java b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignResponse.java new file mode 100644 index 0000000..0d9d217 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdImagePresignResponse.java @@ -0,0 +1,7 @@ +package apu.saerok_admin.infra.dex.dto; + +public record AdminBirdImagePresignResponse( + String presignedUrl, + String objectKey +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdListResponse.java b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdListResponse.java new file mode 100644 index 0000000..b8a7442 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdListResponse.java @@ -0,0 +1,24 @@ +package apu.saerok_admin.infra.dex.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminBirdListResponse( + List birds, + int page, + int size, + long totalElements, + int totalPages +) { + public record Item( + Long id, + String koreanName, + String scientificName, + String conservationGrade, + Double bodyLengthCm, + List habitats, + String thumbImageUrl, + OffsetDateTime updatedAt + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdUpsertRequest.java b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdUpsertRequest.java new file mode 100644 index 0000000..7d84c99 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/dex/dto/AdminBirdUpsertRequest.java @@ -0,0 +1,59 @@ +package apu.saerok_admin.infra.dex.dto; + +import java.util.List; + +public record AdminBirdUpsertRequest( + Name name, + Taxonomy taxonomy, + Description description, + Double bodyLengthCm, + String nibrUrl, + String conservationGrade, + List habitats, + List residencies, + List images +) { + public record Name( + String koreanName, + String scientificName, + String scientificAuthor, + Integer scientificYear + ) { + } + + public record Taxonomy( + String phylumEng, + String phylumKor, + String classEng, + String classKor, + String orderEng, + String orderKor, + String familyEng, + String familyKor, + String genusEng, + String genusKor, + String speciesEng, + String speciesKor + ) { + } + + public record Description( + String description, + String source, + Boolean isAiGenerated + ) { + } + + public record Residency( + String residencyType, + String rarity, + Integer monthBitmask + ) { + } + + public record Image( + String objectKey, + String originalUrl + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/web/DexController.java b/src/main/java/apu/saerok_admin/web/DexController.java index c106dea..a054fa7 100644 --- a/src/main/java/apu/saerok_admin/web/DexController.java +++ b/src/main/java/apu/saerok_admin/web/DexController.java @@ -1,120 +1,335 @@ package apu.saerok_admin.web; +import apu.saerok_admin.infra.dex.AdminDexClient; +import apu.saerok_admin.infra.dex.dto.AdminBirdDetailResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdImagePresignRequest; +import apu.saerok_admin.infra.dex.dto.AdminBirdImagePresignResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdListResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdUpsertRequest; +import apu.saerok_admin.web.form.DexUpsertForm; +import apu.saerok_admin.web.support.DexFormMapper; +import apu.saerok_admin.web.support.DexOptions; import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.CurrentAdminProfile; import apu.saerok_admin.web.view.DexDetail; -import apu.saerok_admin.web.view.DexFormModel; import apu.saerok_admin.web.view.DexListItem; -import apu.saerok_admin.web.view.ToastMessage; -import java.time.LocalDateTime; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.PathVariable; +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; +@Slf4j @Controller +@RequiredArgsConstructor @RequestMapping("/dex") public class DexController { + private final AdminDexClient adminDexClient; + private final ObjectMapper objectMapper; + private final DexFormMapper formMapper; + private final DexOptions dexOptions; + @GetMapping - public String list(@RequestParam(required = false) String q, - @RequestParam(required = false) String rarity, - @RequestParam(required = false) String habitat, - @RequestParam(required = false) String stayType, + public String list(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam(required = false) String q, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, Model model) { model.addAttribute("pageTitle", "도감 관리"); model.addAttribute("activeMenu", "dex"); - model.addAttribute("breadcrumbs", List.of(Breadcrumb.of("대시보드", "/"), Breadcrumb.active("도감"))); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastDexDeleted", "삭제 완료", "도감 항목이 삭제 처리되었습니다.") - )); + model.addAttribute("breadcrumbs", List.of(Breadcrumb.of("대시보드", "/"), Breadcrumb.active("도감 관리"))); + model.addAttribute("toastMessages", List.of()); model.addAttribute("query", q); - model.addAttribute("rarityFilter", rarity); - model.addAttribute("habitatFilter", habitat); - model.addAttribute("stayFilter", stayType); model.addAttribute("page", page); model.addAttribute("size", size); - model.addAttribute("totalPages", 8); - model.addAttribute("totalElements", 146); - model.addAttribute("rarityOptions", List.of("전체", "COMMON", "UNCOMMON", "RARE", "EPIC")); - model.addAttribute("habitatOptions", List.of("전체", "하천", "산림", "해안", "도심")); - model.addAttribute("stayOptions", List.of("전체", "철새", "텃새", "나그네새")); - model.addAttribute("entries", List.of( - new DexListItem(401, "해오라기", "Striated Heron", "Butorides striata", - "하천", "철새", "RARE", LocalDateTime.now().minusDays(1), List.of("야행성", "대형")), - new DexListItem(399, "황조롱이", "Common Kestrel", "Falco tinnunculus", - "도심", "텃새", "EPIC", LocalDateTime.now().minusDays(2), List.of("맹금류", "맑은 날")), - new DexListItem(398, "어치", "Eurasian Jay", "Garrulus glandarius", - "산림", "텃새", "UNCOMMON", LocalDateTime.now().minusDays(3), List.of("지능형", "소리 흉내")), - new DexListItem(397, "개개비", "Oriental Reed Warbler", "Acrocephalus orientalis", - "습지", "철새", "COMMON", LocalDateTime.now().minusDays(4), List.of("아침 활동", "지저귐")), - new DexListItem(396, "쇠백로", "Little Egret", "Egretta garzetta", - "하천", "나그네새", "UNCOMMON", LocalDateTime.now().minusDays(5), List.of("무리 생활", "시각 사냥")) - )); + + try { + AdminBirdListResponse response = adminDexClient.listBirds(q, page, size); + List entries = response.birds() == null + ? List.of() + : response.birds().stream().map(this::toListItem).toList(); + model.addAttribute("entries", entries); + model.addAttribute("totalPages", response.totalPages()); + model.addAttribute("totalElements", response.totalElements()); + model.addAttribute("page", response.page()); + model.addAttribute("size", response.size()); + model.addAttribute("pageNumbers", pageNumbers(response.totalPages())); + model.addAttribute("loadError", null); + } catch (RestClientResponseException exception) { + log.warn("Failed to load dex birds. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + model.addAttribute("loadError", "도감 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + addEmptyListModel(model); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load dex birds.", exception); + model.addAttribute("loadError", "도감 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + addEmptyListModel(model); + } + return "dex/list"; } @GetMapping("/new") public String createForm(Model model) { - model.addAttribute("pageTitle", "도감 등록"); - model.addAttribute("activeMenu", "dex"); - model.addAttribute("breadcrumbs", List.of( - Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("도감", "/dex"), - Breadcrumb.active("신규 등록"))); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastDexSaved", "임시 저장", "변경사항을 저장했습니다.") - )); - model.addAttribute("form", new DexFormModel(null, - List.of("하천", "산림", "해안", "도심"), - List.of("철새", "텃새", "나그네새"), - List.of("COMMON", "UNCOMMON", "RARE", "EPIC"))); + prepareForm(model, new DexUpsertForm(), false, null); return "dex/form"; } - @GetMapping("/{id}") - public String detail(@PathVariable long id, Model model) { - DexDetail detail = new DexDetail(id, "해오라기", "Striated Heron", "Butorides striata", - "얕은 물가에서 주로 활동하는 야행성 물새로, 빠른 반사신경으로 사냥을 한다.", - "https://images.unsplash.com/photo-1503264116251-35a269479413", - List.of("하천", "습지"), "철새", "RARE", List.of("야행성", "대형"), LocalDateTime.now().minusDays(1)); - model.addAttribute("pageTitle", "도감 상세"); - model.addAttribute("activeMenu", "dex"); - model.addAttribute("breadcrumbs", List.of( - Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("도감", "/dex"), - Breadcrumb.active("#" + id))); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastDexDeleted", "삭제 완료", "도감 항목이 삭제 처리되었습니다.") - )); - model.addAttribute("detail", detail); - return "dex/detail"; + @PostMapping + public String create(@ModelAttribute("form") DexUpsertForm form, + Model model, + RedirectAttributes redirectAttributes) { + AdminBirdUpsertRequest request; + try { + request = formMapper.toRequest(form); + } catch (IllegalArgumentException exception) { + return renderFormError(model, form, false, null, exception.getMessage()); + } + + try { + AdminBirdDetailResponse response = adminDexClient.createBird(request); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "도감이 등록되었습니다."); + return "redirect:/dex/" + response.id(); + } catch (RestClientResponseException exception) { + log.warn("Failed to create dex bird. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return renderFormError(model, form, false, null, backendMessage(exception, "도감 등록에 실패했습니다.")); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to create dex bird.", exception); + return renderFormError(model, form, false, null, "도감 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } + + @GetMapping("/{id:\\d+}/edit") + public String editForm(@PathVariable Long id, + Model model, + RedirectAttributes redirectAttributes) { + try { + AdminBirdDetailResponse response = adminDexClient.getBird(id); + prepareForm(model, formMapper.toForm(response), true, id); + return "dex/form"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load dex bird for edit. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", backendMessage(exception, "도감 정보를 불러오지 못했습니다.")); + return "redirect:/dex"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load dex bird for edit.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "도감 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/dex"; + } + } + + @PostMapping("/{id:\\d+}/edit") + public String update(@PathVariable Long id, + @ModelAttribute("form") DexUpsertForm form, + Model model, + RedirectAttributes redirectAttributes) { + AdminBirdUpsertRequest request; + try { + request = formMapper.toRequest(form); + } catch (IllegalArgumentException exception) { + return renderFormError(model, form, true, id, exception.getMessage()); + } + + try { + adminDexClient.updateBird(id, request); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "도감이 수정되었습니다."); + return "redirect:/dex/" + id; + } catch (RestClientResponseException exception) { + log.warn("Failed to update dex bird. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return renderFormError(model, form, true, id, backendMessage(exception, "도감 수정에 실패했습니다.")); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update dex bird.", exception); + return renderFormError(model, form, true, id, "도감 수정에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } + + @GetMapping("/{id:\\d+}") + public String detail(@PathVariable Long id, + Model model, + RedirectAttributes redirectAttributes) { + try { + AdminBirdDetailResponse response = adminDexClient.getBird(id); + model.addAttribute("pageTitle", "도감 상세"); + model.addAttribute("activeMenu", "dex"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("도감 관리", "/dex"), + Breadcrumb.active("#" + id) + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("detail", toDetail(response)); + return "dex/detail"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load dex bird detail. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", backendMessage(exception, "도감 상세를 불러오지 못했습니다.")); + return "redirect:/dex"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load dex bird detail.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "도감 상세를 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/dex"; + } } - @GetMapping("/{id}/edit") - public String editForm(@PathVariable long id, Model model) { - DexDetail detail = new DexDetail(id, "해오라기", "Striated Heron", "Butorides striata", - "얕은 물가에서 주로 활동하는 야행성 물새로, 빠른 반사신경으로 사냥을 한다.", - "https://images.unsplash.com/photo-1503264116251-35a269479413", - List.of("하천", "습지"), "철새", "RARE", List.of("야행성", "대형"), LocalDateTime.now().minusDays(1)); - model.addAttribute("pageTitle", "도감 수정"); + @PostMapping("/image/presign") + @ResponseBody + public ResponseEntity presignImage(@RequestBody Map payload) { + String contentType = payload != null ? payload.get("contentType") : null; + if (!StringUtils.hasText(contentType)) { + return ResponseEntity.badRequest().body(Map.of("message", "contentType이 필요합니다.")); + } + + try { + AdminBirdImagePresignResponse presign = adminDexClient.generateImagePresignUrl( + new AdminBirdImagePresignRequest(contentType)); + return ResponseEntity.ok(Map.of("presignedUrl", presign.presignedUrl(), "objectKey", presign.objectKey())); + } catch (RestClientResponseException exception) { + log.warn("Failed to request dex image presign. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return ResponseEntity.status(exception.getStatusCode()) + .body(Map.of("message", backendMessage(exception, "Presigned URL 발급에 실패했습니다."))); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to request dex image presign.", exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Presigned URL 발급에 실패했습니다.")); + } + } + + private String renderFormError(Model model, DexUpsertForm form, boolean edit, Long birdId, String message) { + form.ensureResidencyRow(); + prepareForm(model, form, edit, birdId); + model.addAttribute("flashStatus", "error"); + model.addAttribute("flashMessage", message); + return "dex/form"; + } + + private void prepareForm(Model model, DexUpsertForm form, boolean edit, Long birdId) { + String activeCrumb = edit ? "#" + birdId + " 수정" : "신규 등록"; + model.addAttribute("pageTitle", edit ? "도감 수정" : "도감 등록"); model.addAttribute("activeMenu", "dex"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("도감", "/dex"), - Breadcrumb.active("수정") + Breadcrumb.of("도감 관리", "/dex"), + Breadcrumb.active(activeCrumb) )); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastDexSaved", "저장 완료", "도감 정보가 저장되었습니다.") - )); - model.addAttribute("form", new DexFormModel(detail, - List.of("하천", "산림", "해안", "도심"), - List.of("철새", "텃새", "나그네새"), - List.of("COMMON", "UNCOMMON", "RARE", "EPIC"))); - return "dex/form"; + model.addAttribute("toastMessages", List.of()); + model.addAttribute("form", form); + model.addAttribute("dexOptions", dexOptions.formModel()); + model.addAttribute("formAction", edit ? "/dex/" + birdId + "/edit" : "/dex"); + model.addAttribute("submitLabel", edit ? "도감 수정" : "도감 등록"); + } + + private DexListItem toListItem(AdminBirdListResponse.Item item) { + return new DexListItem( + item.id(), + item.koreanName(), + item.scientificName(), + dexOptions.conservationGradeLabel(item.conservationGrade()), + item.bodyLengthCm(), + dexOptions.habitatLabels(item.habitats()), + item.thumbImageUrl(), + item.updatedAt() + ); + } + + private DexDetail toDetail(AdminBirdDetailResponse response) { + AdminBirdDetailResponse.BirdName name = response.name(); + AdminBirdDetailResponse.BirdTaxonomy taxonomy = response.taxonomy(); + AdminBirdDetailResponse.BirdDescription description = response.description(); + + return new DexDetail( + response.id(), + name != null ? name.koreanName() : "", + name != null ? name.scientificName() : "", + name != null ? name.scientificAuthor() : null, + name != null ? name.scientificYear() : null, + taxonomy == null ? null : new DexDetail.Taxonomy( + taxonomy.phylumEng(), taxonomy.phylumKor(), taxonomy.classEng(), taxonomy.classKor(), + taxonomy.orderEng(), taxonomy.orderKor(), taxonomy.familyEng(), taxonomy.familyKor(), + taxonomy.genusEng(), taxonomy.genusKor(), taxonomy.speciesEng(), taxonomy.speciesKor()), + description != null ? description.description() : null, + description != null ? description.source() : null, + description != null ? description.isAiGenerated() : null, + response.bodyLengthCm(), + response.nibrUrl(), + dexOptions.conservationGradeLabel(response.conservationGrade()), + dexOptions.habitatLabels(response.habitats()), + response.residencies() == null ? List.of() : response.residencies().stream() + .map(residency -> new DexDetail.Residency( + dexOptions.residencyLabel(residency.residencyType()), + dexOptions.rarityLabel(residency.rarity()), + monthsFromBitmask(residency.effectiveMonthBitmask()), + residency.monthBitmask() == null + )) + .toList(), + response.seasonsWithRarity() == null ? List.of() : response.seasonsWithRarity().stream() + .map(season -> new DexDetail.SeasonWithRarity( + dexOptions.seasonLabel(season.season()), + dexOptions.rarityLabel(season.rarity()), + season.priority() + )) + .toList(), + response.images() == null ? List.of() : response.images().stream() + .map(image -> new DexDetail.Image( + image.objectKey(), image.imageUrl(), image.originalUrl(), image.orderIndex(), image.isThumb())) + .toList(), + response.createdAt(), + response.updatedAt() + ); + } + + private List monthsFromBitmask(Integer bitmask) { + if (bitmask == null) { + return List.of(); + } + return IntStream.rangeClosed(1, 12) + .filter(month -> (bitmask & (1 << (month - 1))) != 0) + .boxed() + .toList(); + } + + private List pageNumbers(int totalPages) { + return totalPages <= 0 ? List.of() : IntStream.rangeClosed(1, totalPages).boxed().toList(); + } + + private void addEmptyListModel(Model model) { + model.addAttribute("entries", List.of()); + model.addAttribute("totalPages", 0); + model.addAttribute("totalElements", 0L); + model.addAttribute("pageNumbers", List.of()); + } + + private String backendMessage(RestClientResponseException exception, String fallback) { + String body = exception.getResponseBodyAsString(); + if (!StringUtils.hasText(body)) { + return fallback; + } + try { + String message = objectMapper.readTree(body).path("message").asText(); + return StringUtils.hasText(message) ? message : fallback; + } catch (Exception ignored) { + return fallback; + } } } diff --git a/src/main/java/apu/saerok_admin/web/form/DexUpsertForm.java b/src/main/java/apu/saerok_admin/web/form/DexUpsertForm.java new file mode 100644 index 0000000..d1fe8f1 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/form/DexUpsertForm.java @@ -0,0 +1,53 @@ +package apu.saerok_admin.web.form; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DexUpsertForm { + + private String koreanName; + private String scientificName; + private String scientificAuthor; + private String scientificYear; + private String bodyLengthCm; + private String nibrUrl; + private String conservationGrade = "NONE"; + private String phylumEng = "Chordata"; + private String phylumKor = "척삭동물문"; + private String classEng = "Aves"; + private String classKor = "조강"; + private String orderEng; + private String orderKor; + private String familyEng; + private String familyKor; + private String genusEng; + private String genusKor; + private String speciesEng; + private String speciesKor; + private List habitats = new ArrayList<>(); + private List residencies = new ArrayList<>(List.of(new ResidencyRow())); + private String objectKey; + private String contentType; + private String originalUrl; + private String description; + private String descriptionSource; + private boolean descriptionAiGenerated; + + public void ensureResidencyRow() { + if (residencies == null || residencies.isEmpty()) { + residencies = new ArrayList<>(List.of(new ResidencyRow())); + } + } + + @Getter + @Setter + public static class ResidencyRow { + private String residencyType; + private String rarity; + private List months = new ArrayList<>(); + } +} diff --git a/src/main/java/apu/saerok_admin/web/support/DexFormMapper.java b/src/main/java/apu/saerok_admin/web/support/DexFormMapper.java new file mode 100644 index 0000000..7cd4cc5 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/support/DexFormMapper.java @@ -0,0 +1,267 @@ +package apu.saerok_admin.web.support; + +import apu.saerok_admin.infra.dex.dto.AdminBirdDetailResponse; +import apu.saerok_admin.infra.dex.dto.AdminBirdUpsertRequest; +import apu.saerok_admin.web.form.DexUpsertForm; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class DexFormMapper { + + public AdminBirdUpsertRequest toRequest(DexUpsertForm form) { + if (form == null || !StringUtils.hasText(form.getKoreanName()) || !StringUtils.hasText(form.getScientificName())) { + throw new IllegalArgumentException("국문명과 학명을 입력해 주세요."); + } + if (!allHaveText( + form.getPhylumEng(), form.getPhylumKor(), form.getClassEng(), form.getClassKor(), + form.getOrderEng(), form.getOrderKor(), form.getFamilyEng(), form.getFamilyKor(), + form.getGenusEng(), form.getGenusKor(), form.getSpeciesEng(), form.getSpeciesKor())) { + throw new IllegalArgumentException("분류 정보를 모두 입력해 주세요."); + } + if (!StringUtils.hasText(form.getConservationGrade())) { + throw new IllegalArgumentException("보호등급을 선택해 주세요."); + } + if (form.getHabitats() == null || form.getHabitats().stream().noneMatch(StringUtils::hasText)) { + throw new IllegalArgumentException("서식지를 하나 이상 선택해 주세요."); + } + if (!StringUtils.hasText(form.getObjectKey()) || !StringUtils.hasText(form.getOriginalUrl())) { + throw new IllegalArgumentException("대표 이미지와 원본 출처 URL을 입력해 주세요."); + } + + String originalUrl = requireHttpUrl(form.getOriginalUrl(), "이미지 원본 출처 URL은 http 또는 https URL이어야 합니다."); + String nibrUrl = optionalHttpUrl(form.getNibrUrl(), "NIBR URL은 http 또는 https URL이어야 합니다."); + List residencies = mapResidencies(form.getResidencies()); + if (residencies.isEmpty()) { + throw new IllegalArgumentException("체류/희귀도 정보를 하나 이상 입력해 주세요."); + } + + return new AdminBirdUpsertRequest( + new AdminBirdUpsertRequest.Name( + form.getKoreanName().trim(), + form.getScientificName().trim(), + trimToNull(form.getScientificAuthor()), + parseOptionalInteger(form.getScientificYear(), "학명 명명년도는 숫자로 입력해 주세요.") + ), + new AdminBirdUpsertRequest.Taxonomy( + form.getPhylumEng().trim(), + form.getPhylumKor().trim(), + form.getClassEng().trim(), + form.getClassKor().trim(), + form.getOrderEng().trim(), + form.getOrderKor().trim(), + form.getFamilyEng().trim(), + form.getFamilyKor().trim(), + form.getGenusEng().trim(), + form.getGenusKor().trim(), + form.getSpeciesEng().trim(), + form.getSpeciesKor().trim() + ), + new AdminBirdUpsertRequest.Description( + trimToNull(form.getDescription()), + trimToNull(form.getDescriptionSource()), + form.isDescriptionAiGenerated() + ), + parseOptionalPositiveDouble(form.getBodyLengthCm()), + nibrUrl, + form.getConservationGrade().trim(), + form.getHabitats().stream().filter(StringUtils::hasText).map(String::trim).distinct().toList(), + residencies, + List.of(new AdminBirdUpsertRequest.Image(form.getObjectKey().trim(), originalUrl)) + ); + } + + public DexUpsertForm toForm(AdminBirdDetailResponse detail) { + DexUpsertForm form = new DexUpsertForm(); + + AdminBirdDetailResponse.BirdName name = detail.name(); + if (name != null) { + form.setKoreanName(name.koreanName()); + form.setScientificName(name.scientificName()); + form.setScientificAuthor(name.scientificAuthor()); + form.setScientificYear(name.scientificYear() == null ? null : String.valueOf(name.scientificYear())); + } + + AdminBirdDetailResponse.BirdTaxonomy taxonomy = detail.taxonomy(); + if (taxonomy != null) { + form.setPhylumEng(taxonomy.phylumEng()); + form.setPhylumKor(taxonomy.phylumKor()); + form.setClassEng(taxonomy.classEng()); + form.setClassKor(taxonomy.classKor()); + form.setOrderEng(taxonomy.orderEng()); + form.setOrderKor(taxonomy.orderKor()); + form.setFamilyEng(taxonomy.familyEng()); + form.setFamilyKor(taxonomy.familyKor()); + form.setGenusEng(taxonomy.genusEng()); + form.setGenusKor(taxonomy.genusKor()); + form.setSpeciesEng(taxonomy.speciesEng()); + form.setSpeciesKor(taxonomy.speciesKor()); + } + + AdminBirdDetailResponse.BirdDescription description = detail.description(); + if (description != null) { + form.setDescription(description.description()); + form.setDescriptionSource(description.source()); + form.setDescriptionAiGenerated(Boolean.TRUE.equals(description.isAiGenerated())); + } + + form.setBodyLengthCm(detail.bodyLengthCm() == null ? null : stripTrailingZero(detail.bodyLengthCm())); + form.setNibrUrl(detail.nibrUrl()); + if (detail.conservationGrade() != null) { + form.setConservationGrade(detail.conservationGrade()); + } + form.setHabitats(detail.habitats() == null ? new ArrayList<>() : new ArrayList<>(detail.habitats())); + + List rows = new ArrayList<>(); + if (detail.residencies() != null) { + for (AdminBirdDetailResponse.Residency residency : detail.residencies()) { + DexUpsertForm.ResidencyRow row = new DexUpsertForm.ResidencyRow(); + row.setResidencyType(residency.residencyType()); + row.setRarity(residency.rarity()); + row.setMonths(monthsFromBitmask(residency.monthBitmask())); + rows.add(row); + } + } + form.setResidencies(rows); + form.ensureResidencyRow(); + + AdminBirdDetailResponse.Image image = primaryImage(detail.images()); + if (image != null) { + form.setObjectKey(image.objectKey()); + form.setOriginalUrl(image.originalUrl()); + } + + return form; + } + + private AdminBirdDetailResponse.Image primaryImage(List images) { + if (images == null || images.isEmpty()) { + return null; + } + return images.stream() + .filter(image -> Boolean.TRUE.equals(image.isThumb())) + .findFirst() + .orElse(images.get(0)); + } + + private List monthsFromBitmask(Integer bitmask) { + if (bitmask == null) { + return new ArrayList<>(); + } + return IntStream.rangeClosed(1, 12) + .filter(month -> (bitmask & (1 << (month - 1))) != 0) + .boxed() + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } + + private String stripTrailingZero(double value) { + if (value == Math.floor(value) && !Double.isInfinite(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } + + private List mapResidencies(List rows) { + if (rows == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (DexUpsertForm.ResidencyRow row : rows) { + if (row == null) { + throw new IllegalArgumentException("체류/희귀도 정보를 입력해 주세요."); + } + boolean hasType = StringUtils.hasText(row.getResidencyType()); + boolean hasRarity = StringUtils.hasText(row.getRarity()); + boolean hasMonths = row.getMonths() != null && !row.getMonths().isEmpty(); + if (!hasType && !hasRarity && !hasMonths) { + continue; + } + if (!hasType || !hasRarity) { + throw new IllegalArgumentException("체류 형태와 희귀도를 모두 선택해 주세요."); + } + result.add(new AdminBirdUpsertRequest.Residency( + row.getResidencyType().trim(), + row.getRarity().trim(), + toMonthBitmask(row.getMonths()) + )); + } + return result; + } + + private Integer toMonthBitmask(List months) { + if (months == null || months.isEmpty()) { + return null; + } + int bitmask = 0; + for (Integer month : months) { + if (month == null || month < 1 || month > 12) { + throw new IllegalArgumentException("월은 1월부터 12월까지만 선택할 수 있습니다."); + } + bitmask |= 1 << (month - 1); + } + return bitmask; + } + + private Integer parseOptionalInteger(String value, String message) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(message); + } + } + + private Double parseOptionalPositiveDouble(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + double parsed = Double.parseDouble(value.trim()); + if (parsed <= 0) { + throw new IllegalArgumentException("체장은 0보다 커야 합니다."); + } + return parsed; + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("체장은 숫자로 입력해 주세요."); + } + } + + private String optionalHttpUrl(String value, String message) { + return StringUtils.hasText(value) ? requireHttpUrl(value, message) : null; + } + + private String requireHttpUrl(String value, String message) { + String trimmed = value.trim(); + try { + URI uri = new URI(trimmed); + String scheme = uri.getScheme(); + if (("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && StringUtils.hasText(uri.getHost())) { + return trimmed; + } + } catch (URISyntaxException ignored) { + // Fall through to the form error below. + } + throw new IllegalArgumentException(message); + } + + private String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private boolean allHaveText(String... values) { + for (String value : values) { + if (!StringUtils.hasText(value)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/apu/saerok_admin/web/support/DexOptions.java b/src/main/java/apu/saerok_admin/web/support/DexOptions.java new file mode 100644 index 0000000..44588fc --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/support/DexOptions.java @@ -0,0 +1,93 @@ +package apu.saerok_admin.web.support; + +import apu.saerok_admin.web.view.DexFormModel; +import apu.saerok_admin.web.view.DexOption; +import java.util.List; +import java.util.stream.IntStream; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class DexOptions { + + private static final List HABITATS = List.of( + new DexOption("MUDFLAT", "갯벌"), + new DexOption("FARMLAND", "경작지/들판"), + new DexOption("FOREST", "산림/계곡"), + new DexOption("MARINE", "해양"), + new DexOption("RESIDENTIAL", "거주지역"), + new DexOption("PLAINS_FOREST", "평지숲"), + new DexOption("RIVER_LAKE", "하천/호수"), + new DexOption("ARTIFICIAL", "인공시설"), + new DexOption("CAVE", "동굴"), + new DexOption("WETLAND", "습지"), + new DexOption("OTHERS", "기타") + ); + private static final List RESIDENCIES = List.of( + new DexOption("RESIDENT", "텃새"), + new DexOption("SUMMER", "여름철새"), + new DexOption("WINTER", "겨울철새"), + new DexOption("PASSAGE", "나그네새"), + new DexOption("VAGRANT", "길잃은새") + ); + private static final List RARITIES = List.of( + new DexOption("COMMON", "흔함"), + new DexOption("RARE", "드묾"), + new DexOption("UNSPECIFIED", "미상") + ); + private static final List CONSERVATION_GRADES = List.of( + new DexOption("NONE", "해당 없음"), + new DexOption("GRADE_I", "멸종위기 야생생물 I급"), + new DexOption("GRADE_II", "멸종위기 야생생물 II급") + ); + private static final List SEASONS = List.of( + new DexOption("SPRING", "봄"), + new DexOption("SUMMER", "여름"), + new DexOption("AUTUMN", "가을"), + new DexOption("WINTER", "겨울") + ); + + public DexFormModel formModel() { + return new DexFormModel( + HABITATS, + RESIDENCIES, + RARITIES, + CONSERVATION_GRADES, + IntStream.rangeClosed(1, 12).boxed().toList() + ); + } + + public List habitatLabels(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream().map(value -> label(HABITATS, value)).toList(); + } + + public String residencyLabel(String value) { + return label(RESIDENCIES, value); + } + + public String rarityLabel(String value) { + return label(RARITIES, value); + } + + public String conservationGradeLabel(String value) { + return label(CONSERVATION_GRADES, value); + } + + public String seasonLabel(String value) { + return label(SEASONS, value); + } + + private String label(List options, String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + return options.stream() + .filter(option -> option.value().equals(value)) + .map(DexOption::label) + .findFirst() + .orElse(value); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/DexDetail.java b/src/main/java/apu/saerok_admin/web/view/DexDetail.java index 6f255da..d9679cc 100644 --- a/src/main/java/apu/saerok_admin/web/view/DexDetail.java +++ b/src/main/java/apu/saerok_admin/web/view/DexDetail.java @@ -1,9 +1,73 @@ package apu.saerok_admin.web.view; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; -public record DexDetail(long id, String koreanName, String englishName, String scientificName, - String description, String imageUrl, List habitats, String stayType, String rarity, - List tags, LocalDateTime updatedAt) { +public record DexDetail( + long id, + String koreanName, + String scientificName, + String scientificAuthor, + Integer scientificYear, + Taxonomy taxonomy, + String description, + String descriptionSource, + Boolean descriptionAiGenerated, + Double bodyLengthCm, + String nibrUrl, + String conservationGrade, + List habitats, + List residencies, + List seasonsWithRarity, + List images, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + public DexDetail { + habitats = habitats == null ? List.of() : List.copyOf(habitats); + residencies = residencies == null ? List.of() : List.copyOf(residencies); + seasonsWithRarity = seasonsWithRarity == null ? List.of() : List.copyOf(seasonsWithRarity); + images = images == null ? List.of() : List.copyOf(images); + } + + public Image primaryImage() { + return images.stream() + .filter(image -> Boolean.TRUE.equals(image.isThumb())) + .findFirst() + .orElseGet(() -> images.isEmpty() ? null : images.getFirst()); + } + + public record Taxonomy( + String phylumEng, + String phylumKor, + String classEng, + String classKor, + String orderEng, + String orderKor, + String familyEng, + String familyKor, + String genusEng, + String genusKor, + String speciesEng, + String speciesKor + ) { + } + + public record SeasonWithRarity(String season, String rarity, Integer priority) { + } + + public record Residency(String residencyType, String rarity, List months, boolean usesDefaultMonths) { + public Residency { + months = months == null ? List.of() : List.copyOf(months); + } + + public String monthLabel() { + return months.isEmpty() + ? "선택 월 없음" + : months.stream().map(month -> month + "월").reduce((left, right) -> left + ", " + right).orElse(""); + } + } + + public record Image(String objectKey, String imageUrl, String originalUrl, Integer orderIndex, Boolean isThumb) { + } } diff --git a/src/main/java/apu/saerok_admin/web/view/DexFormModel.java b/src/main/java/apu/saerok_admin/web/view/DexFormModel.java index b4cf649..ef2b90a 100644 --- a/src/main/java/apu/saerok_admin/web/view/DexFormModel.java +++ b/src/main/java/apu/saerok_admin/web/view/DexFormModel.java @@ -2,6 +2,11 @@ import java.util.List; -public record DexFormModel(DexDetail detail, List habitatOptions, List stayOptions, - List rarityOptions) { +public record DexFormModel( + List habitatOptions, + List residencyOptions, + List rarityOptions, + List conservationGradeOptions, + List months +) { } diff --git a/src/main/java/apu/saerok_admin/web/view/DexListItem.java b/src/main/java/apu/saerok_admin/web/view/DexListItem.java index 09d7e62..2d0b514 100644 --- a/src/main/java/apu/saerok_admin/web/view/DexListItem.java +++ b/src/main/java/apu/saerok_admin/web/view/DexListItem.java @@ -1,8 +1,16 @@ package apu.saerok_admin.web.view; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; -public record DexListItem(long id, String koreanName, String englishName, String scientificName, - String habitat, String stayType, String rarity, LocalDateTime updatedAt, List tags) { +public record DexListItem( + long id, + String koreanName, + String scientificName, + String conservationGrade, + Double bodyLengthCm, + List habitats, + String thumbImageUrl, + OffsetDateTime updatedAt +) { } diff --git a/src/main/java/apu/saerok_admin/web/view/DexOption.java b/src/main/java/apu/saerok_admin/web/view/DexOption.java new file mode 100644 index 0000000..33e9d1b --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/DexOption.java @@ -0,0 +1,4 @@ +package apu.saerok_admin.web.view; + +public record DexOption(String value, String label) { +} diff --git a/src/main/resources/static/js/dex-image-upload.js b/src/main/resources/static/js/dex-image-upload.js new file mode 100644 index 0000000..aa2fec0 --- /dev/null +++ b/src/main/resources/static/js/dex-image-upload.js @@ -0,0 +1,203 @@ +(function () { + var form = document.getElementById('dexForm'); + if (!form) { + return; + } + + var fileInput = document.getElementById('dexImageFile'); + var objectKeyInput = document.getElementById('dexObjectKey'); + var contentTypeInput = document.getElementById('dexContentType'); + var statusElement = document.getElementById('dexImageUploadStatus'); + var previewImage = document.getElementById('dexImagePreview'); + var previewEmpty = document.getElementById('dexImagePreviewEmpty'); + var residencyRows = document.getElementById('residencyRows'); + var addResidencyRowButton = document.getElementById('addResidencyRow'); + + 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 setPreview = function (file) { + if (!previewImage || !file) { + return; + } + var objectUrl = URL.createObjectURL(file); + previewImage.src = objectUrl; + previewImage.classList.remove('d-none'); + if (previewEmpty) { + previewEmpty.classList.add('d-none'); + } + previewImage.onload = function () { + URL.revokeObjectURL(objectUrl); + }; + }; + + var uploadFile = function (file) { + if (!file) { + return; + } + var contentType = file.type || 'application/octet-stream'; + if (objectKeyInput) { + objectKeyInput.value = ''; + } + if (fileInput) { + fileInput.required = true; + } + if (contentTypeInput) { + contentTypeInput.value = ''; + } + setStatus('이미지 업로드 URL을 요청하고 있습니다...', 'text-muted'); + + fetch('/dex/image/presign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentType: contentType }) + }).then(function (response) { + if (!response.ok) { + return response.json().catch(function () { + return {}; + }).then(function (body) { + throw new Error(body.message || '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'); + } + if (objectKeyInput) { + objectKeyInput.value = data.objectKey; + } + if (fileInput) { + fileInput.required = false; + } + if (contentTypeInput) { + contentTypeInput.value = contentType; + } + setPreview(file); + setStatus('이미지 업로드가 완료되었습니다.', 'text-success'); + }); + }).catch(function (error) { + setStatus(error.message || '이미지 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.', 'text-danger'); + }); + }; + + var refreshRemoveButtons = function () { + if (!residencyRows) { + return; + } + var rows = residencyRows.querySelectorAll('.residency-row'); + rows.forEach(function (row) { + var removeButton = row.querySelector('.residency-row-remove'); + if (removeButton) { + removeButton.disabled = rows.length <= 1; + } + }); + }; + + var reindexResidencyRows = function () { + if (!residencyRows) { + return; + } + residencyRows.querySelectorAll('.residency-row').forEach(function (row, index) { + row.querySelectorAll('[data-field]').forEach(function (control) { + var field = control.dataset.field; + control.name = 'residencies[' + index + '].' + field; + if (field === 'residencyType' || field === 'rarity') { + var id = 'residency-' + index + '-' + (field === 'residencyType' ? 'type' : 'rarity'); + control.id = id; + var label = control.parentElement.querySelector('label'); + if (label) { + label.htmlFor = id; + } + } else if (field === 'months') { + var monthId = 'residency-' + index + '-month-' + control.value; + control.id = monthId; + var monthLabel = control.parentElement.querySelector('label'); + if (monthLabel) { + monthLabel.htmlFor = monthId; + } + } + }); + }); + }; + + var addResidencyRow = function () { + if (!residencyRows) { + return; + } + var sourceRow = residencyRows.querySelector('.residency-row'); + if (!sourceRow) { + return; + } + var row = sourceRow.cloneNode(true); + row.querySelectorAll('select').forEach(function (select) { + select.value = ''; + }); + row.querySelectorAll('input[type="checkbox"]').forEach(function (checkbox) { + checkbox.checked = false; + }); + residencyRows.appendChild(row); + reindexResidencyRows(); + refreshRemoveButtons(); + }; + + if (fileInput) { + fileInput.addEventListener('change', function () { + var file = fileInput.files && fileInput.files[0]; + if (!file) { + return; + } + uploadFile(file); + }); + } + + if (addResidencyRowButton) { + addResidencyRowButton.addEventListener('click', addResidencyRow); + } + + if (residencyRows) { + residencyRows.addEventListener('click', function (event) { + var button = event.target.closest('.residency-row-remove'); + if (!button || button.disabled) { + return; + } + var row = button.closest('.residency-row'); + if (row) { + row.remove(); + reindexResidencyRows(); + refreshRemoveButtons(); + } + }); + } + + form.addEventListener('submit', function (event) { + if (objectKeyInput && !objectKeyInput.value) { + event.preventDefault(); + setStatus('대표 이미지 업로드가 완료된 뒤 등록해 주세요.', 'text-danger'); + if (fileInput) { + fileInput.focus(); + } + } + }); + + if (fileInput && objectKeyInput) { + fileInput.required = !objectKeyInput.value; + } + reindexResidencyRows(); + refreshRemoveButtons(); +})(); diff --git a/src/main/resources/templates/dex/detail.html b/src/main/resources/templates/dex/detail.html index 47bc3be..eabd231 100644 --- a/src/main/resources/templates/dex/detail.html +++ b/src/main/resources/templates/dex/detail.html @@ -2,86 +2,215 @@ -
-
-
-
-
-
-
- RARE - 태그 -
-

해오라기

-

Striated Heron

-

Butorides striata

-

설명

-
+
+ + 처리가 완료되었습니다. +
+ +
+
+

해오라기

+

Butorides striata

+
+ +
+ +
+
+
+
+
+ 대표 이미지
-
-
-
-

최근 업데이트

+
+
+ 이미지 없음 +
-
-
-
ID
-
#401
-
업데이트
-
2024.06.01 12:00
-
태그
+
+
+ 해당 없음 + 하천/호수 +
+
+
학명 저자
+
-
+
명명년도
+
1758
+
체장
+
+ 45 cm + - +
+
NIBR URL
- 태그 + NIBR + -
+

설명

+

설명

+
+ + 출처: 출처 + + AI 생성 +
-
-
-
-

서식 정보

-
    -
  • - 하천 -
  • -
  • 보호 등급 RARE
  • -
-
- 도감 수정 - -
-
+ +
+
+

분류 정보

-
- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
분류영문국문
Chordata척삭동물문
Aves조강
Pelecaniformes사다새목
Ardeidae왜가리과
Butorides해오라기속
striata해오라기
분류 정보가 없습니다.
-