diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2362e34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.gradle +.idea +.codex +build +out +context +.env +.env.* +!.env.example +*.log +logs +HELP.md diff --git a/.github/workflows/deploy-to-dev-ec2-docker.yml b/.github/workflows/deploy-to-dev-ec2-docker.yml index 7b14954..a3095e1 100644 --- a/.github/workflows/deploy-to-dev-ec2-docker.yml +++ b/.github/workflows/deploy-to-dev-ec2-docker.yml @@ -1,10 +1,19 @@ -name: Deploy Admin to Dev (EC2 + Docker Compose) +name: Deploy Admin to Dev (ARM64 EC2 + Docker Compose) on: - workflow_dispatch: + push: + branches: [ develop ] + workflow_dispatch: {} + +concurrency: + group: deploy-admin-dev + cancel-in-progress: false jobs: deploy: + # 서버 전환이 끝나기 전에는 develop 머지가 새 서버를 자동으로 변경하지 않게 한다. + # 검증이 끝난 뒤 저장소 변수 DEV_ARM64_AUTODEPLOY를 true로 설정한다. + if: ${{ github.event_name == 'workflow_dispatch' || vars.DEV_ARM64_AUTODEPLOY == 'true' }} runs-on: ubuntu-latest permissions: contents: read @@ -12,24 +21,29 @@ jobs: environment: dev steps: - - uses: actions/checkout@v4 + - name: 🧾 Checkout + uses: actions/checkout@v4 - # (옵션) BE와 동일 스타일 - 리포지토리명 소문자/메타 태그 - - name: Normalize image repo (lowercase) + - name: 🔡 Normalize image repo (lowercase) id: normalize run: echo "image_repo=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - name: Docker Buildx + - name: 🏗️ Setup QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: 🧱 Setup Buildx uses: docker/setup-buildx-action@v3 - - name: GHCR login + - name: 🔐 Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GHCR_USERNAME }} password: ${{ secrets.GHCR_TOKEN }} - - name: Meta (tags/labels) + - name: 🏷️ Meta (tags/labels) id: meta uses: docker/metadata-action@v5 with: @@ -38,28 +52,42 @@ jobs: type=raw,value=dev-latest type=sha,format=long,prefix=dev- - - name: Build & Push + - name: 🏗️ Build & Push ARM64 Image + id: build uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache - cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache,mode=max + cache-from: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64 + cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64,mode=max + + - name: 📁 Prepare deployment directory + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + # 최초 배포 시에도 사용자가 디렉터리를 직접 만들 필요가 없게 한다. + install -d -m 0750 "${HOME}/saerok-admin" - - name: Copy compose files to EC2 + - name: 📤 Copy compose files to EC2 uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} source: "deploy/docker-compose.yml,deploy/docker-compose.dev.yml" - target: "~/saerok-admin" + # 기존 서버와 동일하게 ubuntu 사용자의 홈 아래에 배포한다. + target: ~/saerok-admin/ strip_components: 1 - - name: Deploy on EC2 + - name: 🚀 Deploy on EC2 uses: appleboy/ssh-action@v1 with: host: ${{ secrets.EC2_HOST }} @@ -67,54 +95,79 @@ jobs: key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} script: | set -euo pipefail - cd ~/saerok-admin - - # 0) Docker & Compose must exist + + DEPLOY_DIR="${HOME}/saerok-admin" + IMAGE_REF="${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" + cd "$DEPLOY_DIR" + + # 0) Docker와 Compose가 설치되어 있는지 확인한다. docker --version docker compose version - - # 1) GHCR login for pull + + # 1) 관리자 이미지 pull을 위해 GHCR에 로그인한다. echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin - - # 2) Create ephemeral env file in RAM (owned by current user) and ensure cleanup - # /run 은 tmpfs라 재부팅 시 날아감(의도된 동작) + + # 2) 인증정보는 디스크의 배포 폴더가 아닌 /run의 임시 파일로 전달한다. sudo install -d -m 0700 -o "$(id -un)" -g "$(id -gn)" /run/saerok-admin - ENV_FILE="/run/saerok-admin/env.dev" umask 177 : > "$ENV_FILE" cleanup() { shred -u "$ENV_FILE" || rm -f "$ENV_FILE"; } trap cleanup EXIT - - add_kv() { printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"; } - - # 2-1) Runtime envs (dev) + + add_kv() { printf '%s\n' "$1=$2" >> "$ENV_FILE"; } + add_kv SPRING_PROFILES_ACTIVE "dev" add_kv SAEROK_API_BASE_URL "${{ secrets.SAEROK_API_BASE_URL }}" - add_kv SAEROK_API_PREFIX "${{ secrets.SAEROK_API_PREFIX }}" - - # Social login (admin 전용 redirect) - add_kv KAKAO_CLIENT_ID "${{ secrets.KAKAO_CLIENT_ID }}" - add_kv KAKAO_REDIRECT_URI "${{ secrets.KAKAO_REDIRECT_URI }}" - add_kv APPLE_CLIENT_ID "${{ secrets.APPLE_CLIENT_ID }}" - add_kv APPLE_REDIRECT_URI "${{ secrets.APPLE_REDIRECT_URI }}" + add_kv SAEROK_API_PREFIX "${{ secrets.SAEROK_API_PREFIX }}" + add_kv KAKAO_CLIENT_ID "${{ secrets.KAKAO_CLIENT_ID }}" + add_kv KAKAO_REDIRECT_URI "${{ secrets.KAKAO_REDIRECT_URI }}" + add_kv APPLE_CLIENT_ID "${{ secrets.APPLE_CLIENT_ID }}" + add_kv APPLE_REDIRECT_URI "${{ secrets.APPLE_REDIRECT_URI }}" add_kv UNSPLASH_ACCESS_KEY "${{ secrets.UNSPLASH_ACCESS_KEY }}" - add_kv UNSPLASH_APP_NAME "${{ secrets.UNSPLASH_APP_NAME }}" - - # 3) Pull & Up with dev overlay (limits in dev.yml) - docker compose -p saerok-admin -f docker-compose.yml -f docker-compose.dev.yml pull --quiet - docker compose -p saerok-admin -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate --quiet-pull - - # 4) Healthcheck: /login (HEAD OK) + add_kv UNSPLASH_APP_NAME "${{ secrets.UNSPLASH_APP_NAME }}" + add_kv IMAGE_REF "$IMAGE_REF" + add_kv ENV_FILE "$ENV_FILE" + + # 3) 이번 실행에서 만든 digest 이미지로 관리자 컨테이너를 교체한다. + docker pull "$IMAGE_REF" + docker compose \ + --env-file "$ENV_FILE" \ + -p saerok-admin \ + -f docker-compose.yml \ + -f docker-compose.dev.yml \ + up -d --force-recreate --quiet-pull app + + docker compose \ + --env-file "$ENV_FILE" \ + -p saerok-admin \ + -f docker-compose.yml \ + -f docker-compose.dev.yml \ + ps + + # 4) Nginx를 거치기 전 localhost에서 로그인 화면을 확인한다. + echo "Waiting for admin login page..." for i in {1..45}; do - if curl -fsS -I http://localhost:8081/login > /dev/null; then - echo "✅ admin up" - docker image prune -af --filter "until=24h" || true - docker builder prune -af --filter "until=24h" || true - exit 0 - fi - echo "⏳ ($i/45)"; sleep 3 + if curl -fsS -I http://127.0.0.1:8081/login > /dev/null; then + echo "Admin healthcheck passed" + docker image prune -af --filter "until=24h" || true + docker builder prune -af --filter "until=24h" || true + exit 0 + fi + echo "Waiting ($i/45)..." + sleep 3 done - - echo "❌ admin health check failed" - docker logs saerok-admin-dev --tail=200 || true - exit 1 \ No newline at end of file + + echo "Admin healthcheck failed after retries" + # 세션이나 인증정보가 포함될 수 있는 원문 로그는 공개 Actions에 출력하지 않는다. + docker inspect --format 'admin status={{.State.Status}} health={{.State.Health.Status}} restart={{.RestartCount}}' saerok-admin-dev || true + exit 1 + + - name: Summarize deployment + if: ${{ success() }} + run: | + { + echo "## Dev admin arm64 deploy" + echo "- Platform: linux/arm64" + echo "- Image: ${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" + echo "- Commit: ${{ github.sha }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-to-prod-ec2-docker.yml b/.github/workflows/deploy-to-prod-ec2-docker.yml index b10598f..37b569e 100644 --- a/.github/workflows/deploy-to-prod-ec2-docker.yml +++ b/.github/workflows/deploy-to-prod-ec2-docker.yml @@ -5,14 +5,30 @@ on: branches: - main workflow_dispatch: + inputs: + deploy_mode: + description: 앱을 배포하거나 이전 검증을 위해 이미지만 빌드·업로드합니다 + type: choice + required: true + default: full + options: + - full + - image-only + +concurrency: + group: saerok-admin-prod-deploy + cancel-in-progress: false jobs: deploy: + if: ${{ github.event_name == 'workflow_dispatch' || vars.PROD_ARM64_AUTODEPLOY == 'true' }} runs-on: ubuntu-latest permissions: contents: read packages: write environment: prod + env: + DEPLOY_MODE: ${{ inputs.deploy_mode || 'full' }} steps: - uses: actions/checkout@v4 @@ -22,6 +38,11 @@ jobs: id: normalize run: echo "image_repo=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: Set up ARM64 emulation + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Docker Buildx uses: docker/setup-buildx-action@v3 @@ -38,14 +59,17 @@ jobs: with: images: ${{ steps.normalize.outputs.image_repo }} tags: | - type=raw,value=prod-latest - type=sha,format=long,prefix=prod- + type=raw,value=prod-latest,enable=${{ env.DEPLOY_MODE == 'full' }} + type=sha,format=long,prefix=prod-,enable=${{ env.DEPLOY_MODE == 'full' }} + type=raw,value=migration-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ env.DEPLOY_MODE == 'image-only' }} - name: Build & Push + id: build uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -53,6 +77,7 @@ jobs: cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache,mode=max - name: Copy compose files to EC2 + if: ${{ env.DEPLOY_MODE == 'full' }} uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.EC2_HOST }} @@ -63,6 +88,7 @@ jobs: strip_components: 1 - name: Deploy on EC2 + if: ${{ env.DEPLOY_MODE == 'full' }} uses: appleboy/ssh-action@v1 with: host: ${{ secrets.EC2_HOST }} @@ -84,6 +110,7 @@ jobs: sudo install -d -m 0700 -o "$(id -un)" -g "$(id -gn)" /run/saerok-admin ENV_FILE="/run/saerok-admin/env.prod" + export IMAGE_REF="${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" umask 177 : > "$ENV_FILE" cleanup() { shred -u "$ENV_FILE" || rm -f "$ENV_FILE"; } @@ -109,7 +136,7 @@ jobs: # 4) Healthcheck: /login (HEAD OK) for i in {1..45}; do - if curl -fsS -I http://localhost:8081/login > /dev/null; then + if curl -fsS --max-time 2 -I http://localhost:8081/login > /dev/null; then echo "✅ admin up" docker image prune -af --filter "until=24h" || true docker builder prune -af --filter "until=24h" || true @@ -119,5 +146,7 @@ jobs: done echo "❌ admin health check failed" - docker logs saerok-admin-prod --tail=200 || true - exit 1 \ No newline at end of file + # 세션이나 인증정보가 포함될 수 있는 원문 로그는 공개 Actions에 출력하지 않는다. + # 상세 원인은 EC2에 접속해 권한이 있는 운영자가 직접 확인한다. + docker inspect --format 'admin status={{.State.Status}} oom={{.State.OOMKilled}} restart={{.RestartCount}}' saerok-admin-prod || true + exit 1 diff --git a/Dockerfile b/Dockerfile index 0c9c39e..4f05f9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ -# ===== Build stage ===== -FROM gradle:8.8-jdk21 AS builder +# syntax=docker/dockerfile:1 + +# Spring Boot JAR는 CPU 아키텍처에 종속되지 않으므로 빌드는 CI 러너에서 수행한다. +FROM --platform=$BUILDPLATFORM gradle:8.8-jdk21 AS builder WORKDIR /app COPY build.gradle settings.gradle gradlew ./ @@ -11,7 +13,7 @@ COPY . . RUN chmod +x gradlew RUN ./gradlew --no-daemon clean bootJar -# ===== Runtime stage ===== +# 실행 이미지는 Buildx가 요청한 대상 아키텍처(개발 arm64, 운영 amd64)를 따른다. FROM eclipse-temurin:21-jre ENV TZ=Asia/Seoul WORKDIR /app diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 1b168e0..1bd3df0 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -1,18 +1,22 @@ services: app: - image: ghcr.io/devkor-github/saerok-admin:dev-latest + image: ${IMAGE_REF:-ghcr.io/devkor-github/saerok-admin:dev-latest} container_name: saerok-admin-dev restart: unless-stopped + init: true stop_grace_period: 15s - ports: ["8081:8081"] + ports: + # 외부에서는 Nginx의 80/443 포트로만 접근한다. + - "127.0.0.1:8081:8081" - # t2.micro 안전 제한 (BE와 합쳐도 1GiB 넘지 않게) - cpus: "0.10" + # t4g.micro에서 BE, PostgreSQL, Redis와 함께 실행할 수 있게 제한한다. + cpus: "0.25" mem_limit: "256m" + mem_reservation: "64m" # 런타임에 만드는 RAM env 파일 env_file: - - /run/saerok-admin/env.dev + - ${ENV_FILE:-/run/saerok-admin/env.dev} environment: # dev 오버레이에서 명시적으로 고정 @@ -28,3 +32,16 @@ services: -XX:ReservedCodeCacheSize=32m -XX:+UseSerialGC -XX:+ExitOnOutOfMemoryError + + healthcheck: + test: ["CMD-SHELL", "curl -fsSI http://localhost:8081/login >/dev/null || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 30s + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 8f82340..497d05a 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: app: - image: ghcr.io/devkor-github/saerok-admin:prod-latest + image: ${IMAGE_REF} container_name: saerok-admin-prod restart: unless-stopped stop_grace_period: 15s 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/infra/stat/AdminStatClient.java b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java index 5d43716..4b3a10a 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java +++ b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java @@ -1,7 +1,5 @@ package apu.saerok_admin.infra.stat; -import apu.saerok_admin.infra.SaerokApiProps; -import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; import java.net.URI; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -11,10 +9,15 @@ import org.springframework.web.client.RestClient; import org.springframework.web.util.UriBuilder; +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.stat.dto.CurrentUserStatResponse; +import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; + @Component public class AdminStatClient { private static final String[] ADMIN_STATS_SEGMENTS = {"admin", "stats"}; + private static final String CURRENT_USERS_SEGMENT = "current-users"; private static final String SERIES_SEGMENT = "series"; private final RestClient saerokRestClient; @@ -44,6 +47,19 @@ public StatSeriesResponse fetchSeries(Collection metrics, LocalDate return response; } + public CurrentUserStatResponse fetchCurrentUserStats() { + CurrentUserStatResponse response = saerokRestClient.get() + .uri(this::buildCurrentUserStatsUri) + .retrieve() + .body(CurrentUserStatResponse.class); + + if (response == null) { + throw new IllegalStateException("Empty response from current user stats API"); + } + + return response; + } + private URI buildSeriesUri(UriBuilder builder, Collection metrics, LocalDate startDate, LocalDate endDate) { if (missingPrefixSegments.length > 0) { builder.pathSegment(missingPrefixSegments); @@ -63,6 +79,15 @@ private URI buildSeriesUri(UriBuilder builder, Collection metrics, L return builder.build(); } + private URI buildCurrentUserStatsUri(UriBuilder builder) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_STATS_SEGMENTS); + builder.pathSegment(CURRENT_USERS_SEGMENT); + return builder.build(); + } + private String buildPeriodQuery(LocalDate startDate, LocalDate endDate) { String startToken = startDate != null ? ISO_DATE.format(startDate) : ""; String endToken = endDate != null ? ISO_DATE.format(endDate) : ""; diff --git a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java index cab2b87..c6100a4 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -97,20 +97,12 @@ public enum StatMetric { Map.of(), false ), - USER_SIGNUP_SOURCE_TOTAL( - "가입 경로별 누적 사용자", - "가입 경로(소셜 로그인 종류)별 누적 가입자 수", + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE( + "플랫폼별 가입자 증가 추이", + "최초 집계일의 기존 가입자를 기준값으로 두고, 이후 가입 완료와 첫 기기 등록이 모두 충족된 사용자를 누적합니다. 복수 플랫폼은 중복 포함됩니다.", MetricUnit.COUNT, true, - Map.of(), - false - ), - USER_DEVICE_PLATFORM_TOTAL( - "기기 플랫폼별 누적 사용자", - "기기 플랫폼(iOS/Android/Web 등)별 누적 사용자 수", - MetricUnit.COUNT, - true, - Map.of(), + platformComponentLabels(), false ); @@ -153,6 +145,13 @@ private static Map orderedComponentLabels() { return Collections.unmodifiableMap(labels); } + private static Map platformComponentLabels() { + Map labels = new LinkedHashMap<>(); + labels.put("IOS", "iOS"); + labels.put("ANDROID", "Android"); + return Collections.unmodifiableMap(labels); + } + public enum MetricUnit { COUNT, RATIO, diff --git a/src/main/java/apu/saerok_admin/infra/stat/dto/CurrentUserStatResponse.java b/src/main/java/apu/saerok_admin/infra/stat/dto/CurrentUserStatResponse.java new file mode 100644 index 0000000..167181d --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/stat/dto/CurrentUserStatResponse.java @@ -0,0 +1,13 @@ +package apu.saerok_admin.infra.stat.dto; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record CurrentUserStatResponse( + long completedUserCount, + Map signupSourceCounts, + Map activePushUserCountsByPlatform +) { +} 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/ServiceInsightController.java b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java index 357fa67..cc3aff8 100644 --- a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java +++ b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java @@ -6,6 +6,7 @@ import apu.saerok_admin.web.serviceinsight.ServiceInsightService; import apu.saerok_admin.web.view.Breadcrumb; import apu.saerok_admin.web.view.ServiceInsightViewModel; +import apu.saerok_admin.web.view.ServiceInsightViewModel.CurrentUserStats; import apu.saerok_admin.web.view.ToastMessage; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -46,7 +47,7 @@ public ServiceInsightController(ServiceInsightService serviceInsightService, Obj .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } - @GetMapping(value = "/service-insight", produces = MediaType.TEXT_HTML_VALUE) + @GetMapping(value = {"/service-insight", "/service-insight/time-series"}, produces = MediaType.TEXT_HTML_VALUE) public String serviceInsight( Model model, @RequestParam(value = "range", required = false) String rangeParam, @@ -55,11 +56,12 @@ public String serviceInsight( @RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate ) { - model.addAttribute("pageTitle", "서비스 인사이트"); - model.addAttribute("activeMenu", "serviceInsight"); + model.addAttribute("pageTitle", "서비스 인사이트 · 시계열"); + model.addAttribute("activeMenu", "serviceInsightTimeSeries"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.active("서비스 인사이트") + Breadcrumb.of("서비스 인사이트", "/service-insight/time-series"), + Breadcrumb.active("시계열") )); ensureToastMessages(model); @@ -84,7 +86,24 @@ public String serviceInsight( return "service-insight/index"; } - @GetMapping(value = "/service-insight", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/service-insight/subscriber-status", produces = MediaType.TEXT_HTML_VALUE) + public String subscriberStatus(Model model) { + model.addAttribute("pageTitle", "서비스 인사이트 · 가입자 현황"); + model.addAttribute("activeMenu", "serviceInsightSubscriberStatus"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("서비스 인사이트", "/service-insight/time-series"), + Breadcrumb.active("가입자 현황") + )); + ensureToastMessages(model); + + CurrentUserStats currentUserStats = serviceInsightService.loadCurrentUserStats(); + model.addAttribute("currentUserStats", currentUserStats); + + return "service-insight/subscriber-status"; + } + + @GetMapping(value = {"/service-insight", "/service-insight/time-series"}, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity serviceInsightData( @RequestParam(value = "range", required = false) String rangeParam, @RequestParam(value = "startDate", required = false) 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/serviceinsight/ServiceInsightService.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java index 693ef64..59dc1af 100644 --- a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java @@ -1,13 +1,5 @@ package apu.saerok_admin.web.serviceinsight; -import apu.saerok_admin.infra.stat.AdminStatClient; -import apu.saerok_admin.infra.stat.StatMetric; -import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; -import apu.saerok_admin.web.view.ServiceInsightViewModel; -import apu.saerok_admin.web.view.ServiceInsightViewModel.ComponentSeries; -import apu.saerok_admin.web.view.ServiceInsightViewModel.MetricOption; -import apu.saerok_admin.web.view.ServiceInsightViewModel.Point; -import apu.saerok_admin.web.view.ServiceInsightViewModel.Series; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -17,10 +9,36 @@ import java.util.Optional; import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; + +import apu.saerok_admin.infra.stat.AdminStatClient; +import apu.saerok_admin.infra.stat.StatMetric; +import apu.saerok_admin.infra.stat.dto.CurrentUserStatResponse; +import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; +import apu.saerok_admin.web.view.ServiceInsightViewModel; +import apu.saerok_admin.web.view.ServiceInsightViewModel.CategoryCount; +import apu.saerok_admin.web.view.ServiceInsightViewModel.ComponentSeries; +import apu.saerok_admin.web.view.ServiceInsightViewModel.CurrentUserStats; +import apu.saerok_admin.web.view.ServiceInsightViewModel.MetricOption; +import apu.saerok_admin.web.view.ServiceInsightViewModel.Point; +import apu.saerok_admin.web.view.ServiceInsightViewModel.Series; @Service public class ServiceInsightService { + private static final Map SIGNUP_SOURCE_LABELS = Map.of( + "INSTAGRAM", "인스타그램", + "OTHER_SNS", "기타 SNS", + "FRIEND", "지인 추천", + "COMMUNITY", "커뮤니티", + "ETC", "기타", + "UNKNOWN", "미기록" + ); + private static final Map PLATFORM_LABELS = Map.of( + "IOS", "iOS", + "ANDROID", "Android" + ); + private final AdminStatClient adminStatClient; public ServiceInsightService(AdminStatClient adminStatClient) { @@ -35,14 +53,28 @@ public ServiceInsightViewModel loadViewModel(ServiceInsightQuery query) { LocalDate startDate = query != null ? query.startDate() : null; LocalDate endDate = query != null ? query.endDate() : null; StatSeriesResponse response = adminStatClient.fetchSeries(List.of(StatMetric.values()), startDate, endDate); - return buildViewModel(response); + CurrentUserStatResponse currentUserStats = fetchCurrentUserStatsIfAvailable(); + return buildViewModel(response, currentUserStats); } public ServiceInsightViewModel defaultViewModel() { - return buildViewModel(null); + return buildViewModel(null, null); + } + + public CurrentUserStats loadCurrentUserStats() { + return toCurrentUserStats(fetchCurrentUserStatsIfAvailable()); } - private ServiceInsightViewModel buildViewModel(StatSeriesResponse response) { + private CurrentUserStatResponse fetchCurrentUserStatsIfAvailable() { + try { + return adminStatClient.fetchCurrentUserStats(); + } catch (RestClientException | IllegalStateException ignored) { + // 새 현황 API가 아직 배포되지 않은 백엔드와의 순차 배포를 지원한다. + return null; + } + } + + private ServiceInsightViewModel buildViewModel(StatSeriesResponse response, CurrentUserStatResponse currentUserStats) { Map responseMap = Optional.ofNullable(response) .map(StatSeriesResponse::series) .orElseGet(List::of) @@ -67,7 +99,36 @@ private ServiceInsightViewModel buildViewModel(StatSeriesResponse response) { } } - return new ServiceInsightViewModel(metricOptions, chartSeries, componentLabels); + return new ServiceInsightViewModel( + metricOptions, + chartSeries, + componentLabels, + toCurrentUserStats(currentUserStats) + ); + } + + private CurrentUserStats toCurrentUserStats(CurrentUserStatResponse source) { + if (source == null) { + return null; + } + return new CurrentUserStats( + source.completedUserCount(), + toCategoryCounts(source.signupSourceCounts(), SIGNUP_SOURCE_LABELS), + toCategoryCounts(source.activePushUserCountsByPlatform(), PLATFORM_LABELS) + ); + } + + private List toCategoryCounts(Map source, Map labels) { + if (source == null || source.isEmpty()) { + return List.of(); + } + return source.entrySet().stream() + .map(entry -> new CategoryCount( + entry.getKey(), + labels.getOrDefault(entry.getKey(), entry.getKey()), + entry.getValue() != null ? entry.getValue() : 0L + )) + .toList(); } private MetricOption toMetricOption(StatMetric metric) { 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/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java index c2d82c2..744159e 100644 --- a/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java +++ b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java @@ -1,17 +1,34 @@ package apu.saerok_admin.web.view; -import apu.saerok_admin.infra.stat.StatMetric.MetricUnit; -import com.fasterxml.jackson.annotation.JsonProperty; import java.time.LocalDate; import java.util.List; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonProperty; + +import apu.saerok_admin.infra.stat.StatMetric.MetricUnit; + public record ServiceInsightViewModel( @JsonProperty("metricOptions") List metricOptions, @JsonProperty("series") List series, - @JsonProperty("componentLabels") Map> componentLabels + @JsonProperty("componentLabels") Map> componentLabels, + @JsonProperty("currentUserStats") CurrentUserStats currentUserStats ) { + public record CurrentUserStats( + @JsonProperty("completedUserCount") long completedUserCount, + @JsonProperty("signupSources") List signupSources, + @JsonProperty("activePushPlatforms") List activePushPlatforms + ) { + } + + public record CategoryCount( + @JsonProperty("key") String key, + @JsonProperty("label") String label, + @JsonProperty("count") long count + ) { + } + public record MetricOption( @JsonProperty("metric") String metric, @JsonProperty("label") String label, @@ -40,4 +57,4 @@ public record ComponentSeries( @JsonProperty("points") List points ) { } -} \ No newline at end of file +} diff --git a/src/main/resources/static/css/admin-theme.css b/src/main/resources/static/css/admin-theme.css index 6b9b8aa..02a84f5 100644 --- a/src/main/resources/static/css/admin-theme.css +++ b/src/main/resources/static/css/admin-theme.css @@ -31,6 +31,7 @@ --spacing-md: 1.25rem; --spacing-lg: 2rem; --spacing-xl: 3rem; + --sidebar-width: 260px; /* 추가: 아바타 이미지 변수 기본값 */ --avatar-image: none; @@ -40,6 +41,7 @@ body { font-family: var(--font-sans); background: var(--surface-page); color: var(--text-primary); + overflow-x: hidden; } p, @@ -59,15 +61,21 @@ a:hover, a:focus { color: var(--accent-primary-strong); } .app-shell { min-height: 100vh; background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(248, 250, 252, 0.6) 100%); + overflow-x: clip; } +.app-layout { min-height: 100vh; width: 100%; min-width: 0; } +.app-sidebar-slot { display: contents; } + .app-sidebar { - width: 260px; + width: var(--sidebar-width); min-height: 100vh; background: var(--surface-sidebar); color: var(--text-inverse); padding: var(--spacing-lg) var(--spacing-md); box-shadow: var(--shadow-soft); + transition: transform 0.2s ease, box-shadow 0.2s ease; + will-change: transform; } .app-sidebar__brand { @@ -80,6 +88,34 @@ a:hover, a:focus { color: var(--accent-primary-strong); } .app-sidebar__nav { display: flex; flex-direction: column; gap: 0.5rem; } +.app-sidebar__group { margin: 0.125rem 0; } +.app-sidebar__group-label { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.5rem 1rem 0.25rem; + color: rgba(248, 250, 252, 0.58); + font-size: 0.875rem; font-weight: 700; +} +.app-sidebar__group.is-active .app-sidebar__group-label { color: var(--text-inverse); } +.app-sidebar__subnav { + display: flex; flex-direction: column; gap: 0.25rem; + margin: 0.25rem 0 0 1.45rem; padding-left: 0.75rem; + border-left: 1px solid rgba(148, 163, 184, 0.3); +} +.app-sidebar__sublink { + display: block; padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + color: rgba(248, 250, 252, 0.68); + font-size: 0.9rem; font-weight: 500; text-decoration: none; + transition: background 0.2s ease, color 0.2s ease; +} +.app-sidebar__sublink:hover, .app-sidebar__sublink:focus { + background: var(--surface-sidebar-hover); color: var(--text-inverse); +} +.app-sidebar__sublink.is-active { + background: var(--surface-sidebar-active); color: var(--text-inverse); + font-weight: 700; +} + .app-sidebar__link { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); @@ -101,12 +137,17 @@ a:hover, a:focus { color: var(--accent-primary-strong); } } .app-content { - flex-grow: 1; min-height: 100vh; + flex: 1 1 0; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; background: transparent; } -@media (min-width: 992px) { .app-content { margin-left: 260px; } } -.app-main { padding: 1.5rem 1.5rem 2.5rem; } +@media (min-width: 992px) { + .app-sidebar-slot { display: block; flex: 0 0 var(--sidebar-width); width: var(--sidebar-width); transition: flex-basis 0.2s ease, width 0.2s ease; } + .app-sidebar-is-collapsed .app-sidebar-slot { flex-basis: 0; width: 0; } + .app-sidebar-is-collapsed .app-sidebar { transform: translateX(calc(-100% - 1rem)); box-shadow: none; pointer-events: none; } +} + +.app-main { min-width: 0; padding: 1.5rem 1.5rem 2.5rem; } @media (min-width: 992px) { .app-main { padding: 2rem 2.5rem 3.5rem; } } .card-elevated { @@ -146,6 +187,11 @@ a:hover, a:focus { color: var(--accent-primary-strong); } .table-responsive .table { margin-bottom: 0; } .table-shell { border-radius: var(--radius-lg); overflow: hidden; border: 1px solid var(--border-subtle); } .table-shell .table { margin-bottom: 0; } +.dex-list__table-scroll { + width: 100%; max-width: 100%; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; +} +.dex-list__table-scroll .table { min-width: 860px; } .btn-icon { width: 2.5rem; height: 2.5rem; @@ -267,6 +313,12 @@ a:hover, a:focus { color: var(--accent-primary-strong); } .offcanvas .app-sidebar__link.is-active { color: var(--accent-primary); border-left: none; background: rgba(59, 130, 246, 0.12); } +.offcanvas .app-sidebar__group-label { color: var(--text-muted); } +.offcanvas .app-sidebar__group.is-active .app-sidebar__group-label { color: var(--text-primary); } +.offcanvas .app-sidebar__subnav { border-left-color: var(--border-subtle); } +.offcanvas .app-sidebar__sublink { color: var(--text-muted); } +.offcanvas .app-sidebar__sublink:hover, .offcanvas .app-sidebar__sublink:focus { color: var(--accent-primary); background: rgba(59, 130, 246, 0.08); } +.offcanvas .app-sidebar__sublink.is-active { color: var(--accent-primary); background: rgba(59, 130, 246, 0.12); } .text-muted-soft { color: rgba(100, 116, 139, 0.85) !important; } .alert-elevated { border-radius: var(--radius-md); border: none; box-shadow: var(--shadow-soft); } diff --git a/src/main/resources/static/css/service-insight.css b/src/main/resources/static/css/service-insight.css index fa342cd..754ca2b 100644 --- a/src/main/resources/static/css/service-insight.css +++ b/src/main/resources/static/css/service-insight.css @@ -22,6 +22,87 @@ /* 플롯 영역 여백 + 포커스 그림자 */ .service-insight .service-insight__chart { margin-left: 16px; } +.service-insight .si-current-user-stats { + margin: 0 0 1rem; + padding: 1rem; + border: 1px solid rgba(148, 163, 184, .35); + border-radius: .9rem; + background: linear-gradient(135deg, #f8fbff, #ffffff 60%); +} +.service-insight .si-current-user-stats__heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: .9rem; +} +.service-insight .si-current-user-stats__heading h2 { + margin: 0; + color: #0f172a; + font-size: 1.1rem; +} +.service-insight .si-current-user-stats__heading p { + margin: 0; + color: #64748b; + font-size: .82rem; +} +.service-insight .si-current-user-stats__eyebrow { + margin-bottom: .18rem !important; + color: #2563eb !important; + font-size: .72rem !important; + font-weight: 700; + letter-spacing: .06em; +} +.service-insight .si-current-user-stats__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .75rem; +} +.service-insight .si-current-stat { + min-width: 0; + padding: .85rem; + border: 1px solid rgba(148, 163, 184, .28); + border-radius: .7rem; + background: #fff; +} +.service-insight .si-current-stat > span { + display: block; + color: #475569; + font-size: .82rem; + font-weight: 600; +} +.service-insight .si-current-stat--total strong { + display: block; + margin: .28rem 0; + color: #1d4ed8; + font-size: 1.8rem; + line-height: 1.1; +} +.service-insight .si-current-stat small { + display: block; + margin-top: .45rem; + color: #64748b; + font-size: .72rem; + line-height: 1.35; +} +.service-insight .si-current-stat__list { + margin: .45rem 0 0; +} +.service-insight .si-current-stat__list > div { + display: flex; + justify-content: space-between; + gap: .5rem; + padding: .18rem 0; + border-bottom: 1px solid #f1f5f9; + font-size: .8rem; +} +.service-insight .si-current-stat__list > div:last-child { border-bottom: 0; } +.service-insight .si-current-stat__list dt { color: #64748b; font-weight: 500; } +.service-insight .si-current-stat__list dd { margin: 0; color: #0f172a; font-weight: 700; } +@media (max-width: 991.98px) { + .service-insight .si-current-user-stats__grid { grid-template-columns: 1fr; } + .service-insight .si-current-user-stats__heading { flex-direction: column; gap: .35rem; } +} .service-insight .si-plots__toolbar { display: flex; align-items: flex-start; 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/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index 0c08f66..7bab2ea 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -73,8 +73,7 @@ USER_DAU: 'USER_DAU', USER_WAU: 'USER_WAU', USER_MAU: 'USER_MAU', - USER_SIGNUP_SOURCE_TOTAL: 'USER_SIGNUP_SOURCE_TOTAL', - USER_DEVICE_PLATFORM_TOTAL: 'USER_DEVICE_PLATFORM_TOTAL', + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE: 'USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE', }; // (아래부터는 기존 로직 그대로입니다. 색상/그룹/플롯/툴팁/박스플롯 렌더링 등 전체 원본 유지) @@ -170,7 +169,7 @@ { key:'user', name:'유저', metrics:[ METRICS.USER_COMPLETED_TOTAL, METRICS.USER_SIGNUP_DAILY, METRICS.USER_WITHDRAWAL_DAILY, METRICS.USER_DAU, METRICS.USER_WAU, METRICS.USER_MAU, - METRICS.USER_SIGNUP_SOURCE_TOTAL, METRICS.USER_DEVICE_PLATFORM_TOTAL + METRICS.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE ] }, { key:'id', name:'동정 요청', metrics:[METRICS.PENDING_COUNT, METRICS.RESOLVED_COUNT, METRICS.RESOLUTION_STATS] }, ]; 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해오라기
분류 정보가 없습니다.
-