From 89563a048698ccc2b80e90c94260f5eaa2940155 Mon Sep 17 00:00:00 2001
From: nahyunho1999
Date: Wed, 22 Apr 2026 19:45:04 +0900
Subject: [PATCH 1/5] chore: add automated CD workflow for EC2 deployment
---
.github/workflows/cd.yml | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 .github/workflows/cd.yml
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..de6ce1b
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,23 @@
+name: CD
+
+on:
+ push:
+ branches: [ dev, main ]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Deploy to AWS EC2
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USERNAME }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ script: |
+ cd /root/cocktail-api
+ git pull origin dev
+ docker-compose down
+ docker-compose up --build -d
+ docker system prune -f
From 1c2f09d64d326c8139309397aea385c3e7788293 Mon Sep 17 00:00:00 2001
From: nahyunho1999
Date: Sat, 25 Apr 2026 18:10:14 +0900
Subject: [PATCH 2/5] feat: 1:1 inquiry, guide admin, image storage, bilingual
search
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 1:1 문의 풀스택: entity/repo/service + REST + admin Thymeleaf
- 가이드 admin CRUD: 12 endpoints + reorder transaction + dashboard menu
- 이미지 스토리지 추상화: LocalFile / S3 자동 전환 (AWS 키 유무로)
- 영문 검색 버그 수정: kor_name AND eng_name → OR + case-insensitive
- 이미지 variant 컬럼 4개 + 응답 DTO 노출
- 이미지 최적화 CLI (--image-pipeline) — WebP 변환, dry-run 가능
- 환경 분리: application-local/docker/prod 평문 secret 제거
- AdminViewController 신규 (admin UI 라우팅 복구)
- uploads/ .gitignore 추가
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.gitignore | 6 +-
build.gradle | 6 +
docker-compose.yml | 6 +-
.../common/auth/config/SecurityConfig.java | 1 +
.../common/auth/jwt/JWTFilter.java | 14 +-
.../common/storage/ImageStorage.java | 31 ++
.../common/storage/ImageStorageConfig.java | 42 ++
.../common/storage/LocalFileImageStorage.java | 92 ++++
.../common/storage/S3ImageStorage.java | 99 ++++
.../storage/StaticUploadsWebConfig.java | 33 ++
.../admin/controller/AdminAjaxController.java | 374 ++++++++++++++
.../controller/AdminGuideController.java | 352 +++++++++++++
.../controller/AdminInquiryController.java | 73 +++
.../admin/controller/AdminViewController.java | 52 ++
.../admin/service/AdminGuideService.java | 210 ++++++++
.../response/CocktailDetailResponseDto.java | 8 +
.../dto/response/CocktailResponseDto.java | 24 +
.../domain/cocktail/entity/Cocktail.java | 16 +
.../repository/CocktailRepository.java | 13 +-
.../repository/GuideDetailRepository.java | 44 ++
.../Impl/CocktailRepositoryImpl.java | 14 +-
.../cocktail/service/CocktailService.java | 10 +-
.../inquiry/controller/InquiryController.java | 109 ++++
.../inquiry/dto/request/InquiryCreateReq.java | 35 ++
.../dto/response/InquiryCreateRes.java | 20 +
.../inquiry/dto/response/InquiryPageRes.java | 50 ++
.../inquiry/dto/response/InquiryRes.java | 76 +++
.../domain/inquiry/entity/Inquiry.java | 70 +++
.../domain/inquiry/entity/InquiryStatus.java | 19 +
.../inquiry/repository/InquiryRepository.java | 16 +
.../inquiry/service/InquiryService.java | 116 +++++
.../ImagePipelineProperties.java | 49 ++
.../imagepipeline/ImagePipelineRunner.java | 286 +++++++++++
.../imagepipeline/ImageVariantGenerator.java | 132 +++++
.../tools/imagepipeline/S3UploaderV2.java | 65 +++
src/main/resources/application-docker.yml | 19 +
src/main/resources/application-local.yml | 19 +
src/main/resources/application.yml | 10 +
.../templates/admin/cocktail/detail.html | 18 +-
.../resources/templates/admin/cocktails.html | 10 +-
.../resources/templates/admin/dashboard.html | 6 +-
.../resources/templates/admin/guide/edit.html | 466 ++++++++++++++++++
.../resources/templates/admin/guides.html | 104 ++++
.../resources/templates/admin/inquiries.html | 96 ++++
.../templates/admin/inquiry-detail.html | 93 ++++
src/main/resources/templates/admin/tag.html | 10 +-
46 files changed, 3383 insertions(+), 31 deletions(-)
create mode 100644 src/main/java/com/application/common/storage/ImageStorage.java
create mode 100644 src/main/java/com/application/common/storage/ImageStorageConfig.java
create mode 100644 src/main/java/com/application/common/storage/LocalFileImageStorage.java
create mode 100644 src/main/java/com/application/common/storage/S3ImageStorage.java
create mode 100644 src/main/java/com/application/common/storage/StaticUploadsWebConfig.java
create mode 100644 src/main/java/com/application/domain/admin/controller/AdminAjaxController.java
create mode 100644 src/main/java/com/application/domain/admin/controller/AdminGuideController.java
create mode 100644 src/main/java/com/application/domain/admin/controller/AdminInquiryController.java
create mode 100644 src/main/java/com/application/domain/admin/controller/AdminViewController.java
create mode 100644 src/main/java/com/application/domain/admin/service/AdminGuideService.java
create mode 100644 src/main/java/com/application/domain/cocktail/repository/GuideDetailRepository.java
create mode 100644 src/main/java/com/application/domain/inquiry/controller/InquiryController.java
create mode 100644 src/main/java/com/application/domain/inquiry/dto/request/InquiryCreateReq.java
create mode 100644 src/main/java/com/application/domain/inquiry/dto/response/InquiryCreateRes.java
create mode 100644 src/main/java/com/application/domain/inquiry/dto/response/InquiryPageRes.java
create mode 100644 src/main/java/com/application/domain/inquiry/dto/response/InquiryRes.java
create mode 100644 src/main/java/com/application/domain/inquiry/entity/Inquiry.java
create mode 100644 src/main/java/com/application/domain/inquiry/entity/InquiryStatus.java
create mode 100644 src/main/java/com/application/domain/inquiry/repository/InquiryRepository.java
create mode 100644 src/main/java/com/application/domain/inquiry/service/InquiryService.java
create mode 100644 src/main/java/com/application/tools/imagepipeline/ImagePipelineProperties.java
create mode 100644 src/main/java/com/application/tools/imagepipeline/ImagePipelineRunner.java
create mode 100644 src/main/java/com/application/tools/imagepipeline/ImageVariantGenerator.java
create mode 100644 src/main/java/com/application/tools/imagepipeline/S3UploaderV2.java
create mode 100644 src/main/resources/application-docker.yml
create mode 100644 src/main/resources/application-local.yml
create mode 100644 src/main/resources/templates/admin/guide/edit.html
create mode 100644 src/main/resources/templates/admin/guides.html
create mode 100644 src/main/resources/templates/admin/inquiries.html
create mode 100644 src/main/resources/templates/admin/inquiry-detail.html
diff --git a/.gitignore b/.gitignore
index e047b6f..529fdf8 100755
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,8 @@ application-local.properties
application-*.properties
### Editor ###
-.claude
\ No newline at end of file
+.claude
+# 로컬 이미지 업로드 (S3 모드 아닐 때) — 절대 commit 금지
+uploads/
+!uploads/.gitkeep
+
diff --git a/build.gradle b/build.gradle
index 541dd4b..ec15a09 100755
--- a/build.gradle
+++ b/build.gradle
@@ -102,6 +102,12 @@ dependencies {
//aws s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+ // --- image pipeline (one-shot tool): direct AWS SDK v2 + resizer + webp encoder ---
+ implementation 'software.amazon.awssdk:s3:2.25.60'
+ implementation 'net.coobird:thumbnailator:0.4.20'
+ // Pure-java WebP encoder/decoder (registers ImageIO SPI). No native dep.
+ implementation 'org.sejda.imageio:webp-imageio:0.1.6'
+
// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14'
diff --git a/docker-compose.yml b/docker-compose.yml
index d60c61a..39c7a62 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -37,6 +37,10 @@ services:
- APPLE_PUBLIC_KEY_URL=${APPLE_PUBLIC_KEY_URL}
- APPLE_TOKEN_URL=${APPLE_TOKEN_URL}
+ # 로컬 파일 이미지 저장소 (S3 키 들어오기 전엔 여기 저장됨)
+ volumes:
+ - ./uploads:/app/uploads
+
depends_on:
- db # 'db' 서비스가 먼저 실행된 후에 실행됨
@@ -45,7 +49,7 @@ services:
image: postgres
container_name: postgres-cocktail
ports:
- - "5432:5432"
+ - "5433:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
diff --git a/src/main/java/com/application/common/auth/config/SecurityConfig.java b/src/main/java/com/application/common/auth/config/SecurityConfig.java
index ea4d9de..52cd586 100755
--- a/src/main/java/com/application/common/auth/config/SecurityConfig.java
+++ b/src/main/java/com/application/common/auth/config/SecurityConfig.java
@@ -87,6 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
.requestMatchers("/api/auth/naver/login-url", "/api/auth/google/login-url", "/api/auth/kakao/login-url", "/api/auth/apple/login-url").permitAll()
.requestMatchers("/api/location/**", "/api/search/**", "/api/bar/**", "/api/item/public/**").permitAll()
.requestMatchers("/api/public/**", "/.well-known/acme-challenge/**" ,"/error", "/images/**").permitAll()
+ .requestMatchers("/uploads/**", "/onz/uploads/**").permitAll()
// swagger
.requestMatchers(SWAGGER_URLS).permitAll()
.requestMatchers("/webjars/**", "/favicon.ico").permitAll()
diff --git a/src/main/java/com/application/common/auth/jwt/JWTFilter.java b/src/main/java/com/application/common/auth/jwt/JWTFilter.java
index eb67c86..198e4e2 100755
--- a/src/main/java/com/application/common/auth/jwt/JWTFilter.java
+++ b/src/main/java/com/application/common/auth/jwt/JWTFilter.java
@@ -102,7 +102,13 @@ public class JWTFilter extends OncePerRequestFilter {
"^/api/v2/cocktails/bookmarks/batch$", // 칵테일 배치 북마크 토글
"^/onz/api/v2/cocktails/[0-9]+/bookmarks$", // 칵테일 북마크 토글 (onz 경로)
"^/onz/api/v2/cocktails/bookmarks$", // 내 북마크 목록 조회 (onz 경로)
- "^/onz/api/v2/cocktails/bookmarks/batch$" // 칵테일 배치 북마크 토글 (onz 경로)
+ "^/onz/api/v2/cocktails/bookmarks/batch$", // 칵테일 배치 북마크 토글 (onz 경로)
+
+ // 1:1 문의 API (mine 목록 / 단건 조회는 인증 필요, POST는 optional)
+ "^/api/v2/inquiry/mine$", // 내 문의 목록
+ "^/api/v2/inquiry/[0-9]+$", // 문의 단건 조회
+ "^/onz/api/v2/inquiry/mine$", // 내 문의 목록 (onz 경로)
+ "^/onz/api/v2/inquiry/[0-9]+$" // 문의 단건 조회 (onz 경로)
);
// 선택적 인증 경로 (JWT 토큰이 있으면 검증하고, 없으면 익명 사용자로 통과)
@@ -123,7 +129,11 @@ public class JWTFilter extends OncePerRequestFilter {
"^/onz/api/v2/cocktails/specific$", // 특정 칵테일 조회 (onz 경로)
"^/onz/api/v2/cocktails/refresh$", // 상큼한 칵테일 추천 (onz 경로)
"^/onz/api/v2/cocktails/beginner$", // 입문자용 칵테일 (onz 경로)
- "^/onz/api/v2/cocktails/intermediate$" // 중급자용 칵테일 (onz 경로)
+ "^/onz/api/v2/cocktails/intermediate$", // 중급자용 칵테일 (onz 경로)
+
+ // 1:1 문의 등록 (로그인 사용자 / 비로그인 사용자 모두 가능)
+ "^/api/v2/inquiry$", // 문의 등록
+ "^/onz/api/v2/inquiry$" // 문의 등록 (onz 경로)
);
// [기존 방식 : jwt 예외 필터 적용 - 주석 처리]
diff --git a/src/main/java/com/application/common/storage/ImageStorage.java b/src/main/java/com/application/common/storage/ImageStorage.java
new file mode 100644
index 0000000..99b60ab
--- /dev/null
+++ b/src/main/java/com/application/common/storage/ImageStorage.java
@@ -0,0 +1,31 @@
+package com.application.common.storage;
+
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+/**
+ * 이미지 스토리지 추상화.
+ *
+ * 로컬 개발 환경에선 파일시스템에 저장하고, 운영 환경에선 S3 PutObject로 저장.
+ * 어떤 구현이 활성화될지는 ImageStorageConfig 의 @Bean 팩토리가
+ * aws.s3.access-key 가 비어있는지 여부로 결정한다.
+ */
+public interface ImageStorage {
+
+ /**
+ * @param pathPrefix e.g. "cocktails", "guides"
+ * @param file 업로드된 파일
+ * @return 공개 URL (저장 후 즉시 GET 으로 접근 가능해야 함)
+ */
+ String upload(String pathPrefix, MultipartFile file) throws IOException;
+
+ /**
+ * @param url upload() 이 반환했던 URL. 로컬 모드면 파일 삭제, S3 모드면 DeleteObject.
+ * @return 삭제 성공 여부 (대상이 이미 없으면 false 반환, 예외는 throw)
+ */
+ boolean delete(String url) throws IOException;
+
+ /** 로깅/관리용 모드 식별자 (LOCAL or S3) */
+ String mode();
+}
diff --git a/src/main/java/com/application/common/storage/ImageStorageConfig.java b/src/main/java/com/application/common/storage/ImageStorageConfig.java
new file mode 100644
index 0000000..b884a5f
--- /dev/null
+++ b/src/main/java/com/application/common/storage/ImageStorageConfig.java
@@ -0,0 +1,42 @@
+package com.application.common.storage;
+
+import com.application.tools.imagepipeline.ImagePipelineProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * ImageStorage 빈 팩토리.
+ *
+ * AWS S3 자격증명(AWS_S3_ACCESS_KEY / SECRET_KEY) 가 모두 채워져 있으면 → S3 모드
+ * 비어 있거나 부족하면 → 로컬 파일 모드 (개발/테스트 환경)
+ *
+ * 운영 EC2 에선 환경변수만 주입하면 자동으로 S3 모드가 활성화된다.
+ */
+@Slf4j
+@Configuration
+public class ImageStorageConfig {
+
+ @Bean
+ public ImageStorage imageStorage(
+ ImagePipelineProperties s3Props,
+ @Value("${image.upload.local-dir:./uploads}") String localDir,
+ @Value("${image.upload.public-base-url:http://127.0.0.1/onz/uploads}") String publicBaseUrl
+ ) {
+ boolean hasAwsCreds = s3Props.getAccessKey() != null && !s3Props.getAccessKey().isBlank()
+ && s3Props.getSecretKey() != null && !s3Props.getSecretKey().isBlank();
+
+ if (hasAwsCreds) {
+ log.info("[ImageStorageConfig] AWS creds detected → using S3 storage");
+ return new S3ImageStorage(
+ s3Props.getAccessKey(),
+ s3Props.getSecretKey(),
+ s3Props.getRegion(),
+ s3Props.getBucket()
+ );
+ }
+ log.info("[ImageStorageConfig] no AWS creds → using LOCAL file storage (dir={})", localDir);
+ return new LocalFileImageStorage(localDir, publicBaseUrl);
+ }
+}
diff --git a/src/main/java/com/application/common/storage/LocalFileImageStorage.java b/src/main/java/com/application/common/storage/LocalFileImageStorage.java
new file mode 100644
index 0000000..c992b26
--- /dev/null
+++ b/src/main/java/com/application/common/storage/LocalFileImageStorage.java
@@ -0,0 +1,92 @@
+package com.application.common.storage;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.UUID;
+
+/**
+ * 로컬 파일시스템 기반 이미지 저장소.
+ *
+ * Spring 의 ResourceHandler 를 통해 publicBaseUrl 경로로 노출된다 (WebConfig 에서 매핑).
+ *
+ * 디렉토리 구조:
+ * {localDir}/cocktails/_.png
+ * {localDir}/guides/_.png
+ *
+ * 반환 URL 형식:
+ * {publicBaseUrl}/cocktails/_.png
+ * ex) http://127.0.0.1/onz/uploads/cocktails/abc123_whisky.png
+ */
+@Slf4j
+public class LocalFileImageStorage implements ImageStorage {
+
+ private final Path localDir;
+ private final String publicBaseUrl;
+
+ public LocalFileImageStorage(String localDir, String publicBaseUrl) {
+ this.localDir = Paths.get(localDir).toAbsolutePath().normalize();
+ this.publicBaseUrl = publicBaseUrl.endsWith("/")
+ ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
+ : publicBaseUrl;
+ try {
+ Files.createDirectories(this.localDir);
+ } catch (IOException e) {
+ throw new IllegalStateException("로컬 업로드 디렉토리 생성 실패: " + this.localDir, e);
+ }
+ log.info("[ImageStorage] LOCAL mode initialized — dir={} publicBaseUrl={}",
+ this.localDir, this.publicBaseUrl);
+ }
+
+ @Override
+ public String upload(String pathPrefix, MultipartFile file) throws IOException {
+ if (file == null || file.isEmpty()) {
+ throw new IOException("업로드 파일이 비어있습니다.");
+ }
+ String safePrefix = sanitize(pathPrefix);
+ String originalName = sanitize(file.getOriginalFilename() == null ? "image" : file.getOriginalFilename());
+ String filename = UUID.randomUUID().toString().replace("-", "").substring(0, 12) + "_" + originalName;
+
+ Path targetDir = localDir.resolve(safePrefix);
+ Files.createDirectories(targetDir);
+
+ Path target = targetDir.resolve(filename);
+ Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
+
+ String url = publicBaseUrl + "/" + safePrefix + "/" + filename;
+ log.info("[ImageStorage:LOCAL] uploaded {} bytes → {}", file.getSize(), url);
+ return url;
+ }
+
+ @Override
+ public boolean delete(String url) throws IOException {
+ if (url == null || url.isBlank()) return false;
+ // publicBaseUrl 로 시작하지 않으면 우리 스토리지 소관 아님 (외부 S3 등) → no-op
+ if (!url.startsWith(publicBaseUrl)) {
+ log.warn("[ImageStorage:LOCAL] delete skipped — url not in our prefix: {}", url);
+ return false;
+ }
+ String relative = url.substring(publicBaseUrl.length());
+ if (relative.startsWith("/")) relative = relative.substring(1);
+ Path target = localDir.resolve(relative).normalize();
+ // 디렉토리 탈출 방지
+ if (!target.startsWith(localDir)) {
+ throw new IOException("잘못된 경로: " + url);
+ }
+ boolean deleted = Files.deleteIfExists(target);
+ log.info("[ImageStorage:LOCAL] delete {} → {}", url, deleted ? "removed" : "not found");
+ return deleted;
+ }
+
+ @Override
+ public String mode() {
+ return "LOCAL";
+ }
+
+ /** path traversal 방지 + 안전한 파일명. */
+ private static String sanitize(String s) {
+ return s.replaceAll("[^a-zA-Z0-9._\\-]", "_").replaceAll("_+", "_");
+ }
+}
diff --git a/src/main/java/com/application/common/storage/S3ImageStorage.java b/src/main/java/com/application/common/storage/S3ImageStorage.java
new file mode 100644
index 0000000..bf00b0e
--- /dev/null
+++ b/src/main/java/com/application/common/storage/S3ImageStorage.java
@@ -0,0 +1,99 @@
+package com.application.common.storage;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * S3 기반 이미지 저장소. AWS SDK v2 사용.
+ *
+ * AWS_S3_ACCESS_KEY / AWS_S3_SECRET_KEY / S3_BUCKET 가 주입돼야 동작.
+ * 자격증명이 없으면 ImageStorageConfig 에서 LocalFileImageStorage 가 대신 활성화됨.
+ *
+ * S3 키 패턴: {pathPrefix}/{uuid}_{original-filename}
+ * 반환 URL : https://{bucket}.s3.{region}.amazonaws.com/{key}
+ */
+@Slf4j
+public class S3ImageStorage implements ImageStorage {
+
+ private final S3Client client;
+ private final String bucket;
+ private final String region;
+
+ public S3ImageStorage(String accessKey, String secretKey, String region, String bucket) {
+ this.bucket = bucket;
+ this.region = region;
+ this.client = S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)))
+ .build();
+ log.info("[ImageStorage] S3 mode initialized — bucket={} region={}", bucket, region);
+ }
+
+ @Override
+ public String upload(String pathPrefix, MultipartFile file) throws IOException {
+ if (file == null || file.isEmpty()) {
+ throw new IOException("업로드 파일이 비어있습니다.");
+ }
+ String safePrefix = sanitize(pathPrefix);
+ String safeName = sanitize(file.getOriginalFilename() == null ? "image" : file.getOriginalFilename());
+ String key = safePrefix + "/" + UUID.randomUUID().toString().replace("-", "").substring(0, 12) + "_" + safeName;
+
+ PutObjectRequest req = PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .contentType(file.getContentType())
+ .build();
+
+ try {
+ client.putObject(req, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
+ } catch (S3Exception e) {
+ log.error("[ImageStorage:S3] put failed bucket={} key={} : {}", bucket, key, e.awsErrorDetails().errorMessage(), e);
+ throw new IOException("S3 업로드 실패: " + e.awsErrorDetails().errorMessage(), e);
+ }
+
+ String url = "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key;
+ log.info("[ImageStorage:S3] uploaded {} bytes → {}", file.getSize(), url);
+ return url;
+ }
+
+ @Override
+ public boolean delete(String url) throws IOException {
+ if (url == null || url.isBlank()) return false;
+ // URL → key 추출
+ String marker = ".amazonaws.com/";
+ int idx = url.indexOf(marker);
+ if (idx < 0) {
+ log.warn("[ImageStorage:S3] delete skipped — url not in S3 form: {}", url);
+ return false;
+ }
+ String key = url.substring(idx + marker.length());
+ try {
+ client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build());
+ log.info("[ImageStorage:S3] deleted s3://{}/{}", bucket, key);
+ return true;
+ } catch (S3Exception e) {
+ throw new IOException("S3 삭제 실패: " + e.awsErrorDetails().errorMessage(), e);
+ }
+ }
+
+ @Override
+ public String mode() {
+ return "S3";
+ }
+
+ private static String sanitize(String s) {
+ return s.replaceAll("[^a-zA-Z0-9._\\-]", "_").replaceAll("_+", "_");
+ }
+}
diff --git a/src/main/java/com/application/common/storage/StaticUploadsWebConfig.java b/src/main/java/com/application/common/storage/StaticUploadsWebConfig.java
new file mode 100644
index 0000000..181ceb7
--- /dev/null
+++ b/src/main/java/com/application/common/storage/StaticUploadsWebConfig.java
@@ -0,0 +1,33 @@
+package com.application.common.storage;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.nio.file.Paths;
+
+/**
+ * 로컬 업로드 디렉토리를 /uploads/** 경로로 노출하기 위한 ResourceHandler.
+ *
+ * 컨텍스트 패스 /onz 가 자동 prepend 되므로 실제 외부 URL 은
+ * http://127.0.0.1/onz/uploads/cocktails/
+ * 가 된다 (LocalFileImageStorage 가 반환하는 URL 과 일치).
+ *
+ * S3 모드일 때는 사용하지 않지만 핸들러가 등록돼 있어도 무해 (디렉토리가 비어있으면 404).
+ */
+@Configuration
+public class StaticUploadsWebConfig implements WebMvcConfigurer {
+
+ @Value("${image.upload.local-dir:./uploads}")
+ private String uploadDir;
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ String absolute = Paths.get(uploadDir).toAbsolutePath().normalize().toString();
+ // file: 프리픽스 + 끝에 / 필수
+ registry.addResourceHandler("/uploads/**")
+ .addResourceLocations("file:" + absolute + "/")
+ .setCachePeriod(60); // 1분 (개발 편의)
+ }
+}
diff --git a/src/main/java/com/application/domain/admin/controller/AdminAjaxController.java b/src/main/java/com/application/domain/admin/controller/AdminAjaxController.java
new file mode 100644
index 0000000..14dff7b
--- /dev/null
+++ b/src/main/java/com/application/domain/admin/controller/AdminAjaxController.java
@@ -0,0 +1,374 @@
+package com.application.domain.admin.controller;
+
+import com.application.common.exception.custom.CustomApiException;
+import com.application.common.response.ResponseDto;
+import com.application.common.storage.ImageStorage;
+import com.application.domain.cocktail.controller.CocktailV2Controller;
+import com.application.domain.cocktail.dto.IngredientDto;
+import com.application.domain.cocktail.dto.TagDto;
+import com.application.domain.cocktail.dto.TagGroupDto;
+import com.application.domain.cocktail.entity.Cocktail;
+import com.application.domain.cocktail.entity.CocktailTag;
+import com.application.domain.cocktail.entity.Ingredient;
+import com.application.domain.cocktail.entity.Tag;
+import com.application.domain.cocktail.enums.Season;
+import com.application.domain.cocktail.enums.TagType;
+import com.application.domain.cocktail.repository.CocktailRepository;
+import com.application.domain.cocktail.repository.TagRepository;
+import com.application.domain.cocktail.service.CocktailService;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.persistence.EntityManager;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 관리자 화면(Thymeleaf)에서 호출되는 AJAX 엔드포인트 모음.
+ *
+ * 보안: /admin/** 은 SecurityConfig 의 adminSecurity 체인(form-login + 세션) 으로 보호되므로
+ * 별도 인증 코드 없이 동작. CSRF 는 admin 체인에서 disable 되어 있음.
+ *
+ * 응답 포맷: 공통 ResponseDto ({code, msg, data}) 사용. JSON 필드는 snake_case 로 직렬화하기 위해
+ * DTO 에 @JsonProperty 명시 (전역 NamingStrategy 가 설정되어 있지 않음).
+ *
+ * 이미지 업로드/삭제 두 엔드포인트는 S3 자격증명이 환경변수로 주입되지 않으면 동작 불가하므로,
+ * 503 + code:-1 stub 으로 명확히 응답한다. 실제 구현은 AWS_S3_ACCESS_KEY / AWS_S3_SECRET_KEY / S3_BUCKET
+ * 가 주입되는 시점에 작성한다.
+ */
+@RestController
+@RequestMapping("/admin")
+@RequiredArgsConstructor
+@Slf4j
+public class AdminAjaxController {
+
+ private final CocktailService cocktailService;
+ private final CocktailRepository cocktailRepository;
+ private final TagRepository tagRepository;
+ private final EntityManager entityManager;
+ private final ImageStorage imageStorage;
+
+ /* =========================================================================================
+ * 1) GET /admin/search/cocktail
+ * - cocktails.html 의 ag-grid 채우기
+ * - 응답 row 의 필드명은 cocktails.html columnDefs 의 field 값과 정확히 일치해야 한다.
+ * (id, cocktail_kr, cocktail_en, abv_band, taste_level, createdAt)
+ * ========================================================================================= */
+ @GetMapping("/search/cocktail")
+ public ResponseEntity>> searchCocktail(
+ @RequestParam(value = "page", defaultValue = "0") int page,
+ @RequestParam(value = "size", defaultValue = "100") int size,
+ @RequestParam(value = "cocktailName", required = false) String cocktailName
+ ) {
+ // 가장 단순한 방식: findAll + 메모리 필터.
+ // (대부분 100 건 내외 칵테일이며, 관리자 화면이라 부하가 거의 없음.)
+ List all = cocktailRepository.findAll();
+
+ List filtered;
+ if (cocktailName != null && !cocktailName.isBlank()) {
+ String needle = cocktailName.toLowerCase(Locale.ROOT);
+ filtered = all.stream()
+ .filter(c -> {
+ String kor = c.getKorName() != null ? c.getKorName() : c.getCocktailKR();
+ String eng = c.getEngName() != null ? c.getEngName() : c.getCocktailEN();
+ boolean korHit = kor != null && kor.toLowerCase(Locale.ROOT).contains(needle);
+ boolean engHit = eng != null && eng.toLowerCase(Locale.ROOT).contains(needle);
+ return korHit || engHit;
+ })
+ .toList();
+ } else {
+ filtered = all;
+ }
+
+ // 페이징 (관리자 화면은 default size=100 이므로 사실상 단일 페이지)
+ int from = Math.min(page * size, filtered.size());
+ int to = Math.min(from + size, filtered.size());
+ List pageContent = filtered.subList(from, to);
+
+ List rows = pageContent.stream()
+ .map(AdminCocktailRow::from)
+ .toList();
+
+ return ResponseEntity.ok(ResponseDto.onSuccess("칵테일 목록 조회 성공", rows));
+ }
+
+ /* =========================================================================================
+ * 2) GET /admin/cocktail/info?cocktailId=X
+ * - cocktail/detail.html fillForm() 이 기대하는 필드:
+ * cocktail_kr, cocktail_en, max_alcohol, min_alcohol, abv_band, taste_level,
+ * origin_text, image_url, seasons (string[]), ingredients ([{name, amount}]),
+ * tags ([{type, tags:[{id, name}]}])
+ * ========================================================================================= */
+ @GetMapping("/cocktail/info")
+ public ResponseEntity> cocktailInfo(
+ @RequestParam("cocktailId") Long cocktailId
+ ) {
+ Cocktail cocktail = cocktailRepository.findById(cocktailId)
+ .orElseThrow(() -> new CustomApiException("칵테일이 존재하지 않습니다."));
+
+ return ResponseEntity.ok(ResponseDto.onSuccess("칵테일 상세 조회 성공", AdminCocktailDetail.from(cocktail)));
+ }
+
+ /* =========================================================================================
+ * 3) GET /admin/cocktail/tags
+ * - tag.html / cocktail/detail.html 모두 사용
+ * - 응답 형태: { "FLAVOR":[{id,name},...], "MOOD":[...], "BASE":[...], "GLASS":[...] }
+ * - CocktailService.getCocktailTags(null) 가 그대로 이 형태를 반환 (Map)
+ * ========================================================================================= */
+ @GetMapping("/cocktail/tags")
+ public ResponseEntity>> cocktailTags() {
+ Map tags = cocktailService.getCocktailTags(null);
+ return ResponseEntity.ok(ResponseDto.onSuccess("태그 조회 성공", tags));
+ }
+
+ /* =========================================================================================
+ * 4) GET /admin/manage/tag
+ * - tag.html 의 태그 목록 화면
+ * - 응답 형태는 #3 와 동일 (Object.entries(allTags).forEach 로 그룹별 렌더링)
+ * - JS 가 이 동일 path 에 ?id= 쿼리로 "삭제 요청" 도 보내므로(아래 deleteTag 참고) 분리 처리한다.
+ * ========================================================================================= */
+ @GetMapping("/manage/tag")
+ public ResponseEntity> manageTagGetOrDelete(
+ @RequestParam(value = "id", required = false) Long deleteId
+ ) {
+ if (deleteId != null) {
+ // tag.html 의 deleteTag() 가 GET /admin/manage/tag?id=X 로 요청한다. (의도적인 RPC-over-GET)
+ return deleteTagInternal(deleteId);
+ }
+ Map tags = cocktailService.getCocktailTags(null);
+ return ResponseEntity.ok(ResponseDto.onSuccess("태그 목록 조회 성공", tags));
+ }
+
+ /* =========================================================================================
+ * 5) POST /admin/manage/tag
+ * - tag.html addTag() 로부터 호출. body: { type: "FLAVOR", name: "달콤" }
+ * ========================================================================================= */
+ @PostMapping("/manage/tag")
+ @Transactional
+ public ResponseEntity> createTag(@RequestBody TagSaveReq req) {
+ if (req == null || req.getName() == null || req.getName().isBlank()
+ || req.getType() == null || req.getType().isBlank()) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "태그 type/name 누락"));
+ }
+ TagType type;
+ try {
+ type = TagType.valueOf(req.getType().trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "지원하지 않는 태그 타입: " + req.getType()));
+ }
+
+ // 동일 (type, name) 중복 방지: name unique 제약 + 동일 type 인 경우 멱등성 보장
+ Tag existing = tagRepository.findByType(type).stream()
+ .filter(t -> t.getName().equals(req.getName().trim()))
+ .findFirst()
+ .orElse(null);
+
+ if (existing != null) {
+ return ResponseEntity.ok(ResponseDto.onSuccess("이미 존재하는 태그", new TagDto(existing.getId(), existing.getName())));
+ }
+
+ Tag saved = tagRepository.save(new Tag(type, req.getName().trim()));
+ return ResponseEntity.ok(ResponseDto.onSuccess("태그 생성 성공", new TagDto(saved.getId(), saved.getName())));
+ }
+
+ /* =========================================================================================
+ * 6) GET /admin/cocktails/delete?cocktailId=X
+ * - cocktails.html 의 row 삭제 버튼 + cocktail/detail.html 의 삭제 버튼이 사용
+ * - 칵테일 엔티티에 soft-delete 컬럼이 없으므로 hard-delete 수행
+ * ========================================================================================= */
+ @GetMapping("/cocktails/delete")
+ @Transactional
+ public ResponseEntity> deleteCocktail(@RequestParam("cocktailId") Long cocktailId) {
+ if (!cocktailRepository.existsById(cocktailId)) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "존재하지 않는 칵테일입니다. id=" + cocktailId));
+ }
+ try {
+ cocktailRepository.deleteById(cocktailId);
+ entityManager.flush();
+ return ResponseEntity.ok(ResponseDto.onSuccess("삭제되었습니다.", null));
+ } catch (Exception ex) {
+ log.error("[admin] cocktail delete failed id={} : {}", cocktailId, ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: 외래키 제약 등으로 인해 삭제할 수 없습니다."));
+ }
+ }
+
+ /* =========================================================================================
+ * 내부: 태그 삭제 (manage/tag?id=X 에서 호출)
+ * ========================================================================================= */
+ @Transactional
+ public ResponseEntity> deleteTagInternal(Long tagId) {
+ if (!tagRepository.existsById(tagId)) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "존재하지 않는 태그입니다. id=" + tagId));
+ }
+ try {
+ tagRepository.deleteById(tagId);
+ entityManager.flush();
+ return ResponseEntity.ok(ResponseDto.onSuccess("태그 삭제 성공", null));
+ } catch (Exception ex) {
+ log.error("[admin] tag delete failed id={} : {}", tagId, ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: 칵테일에 연결된 태그입니다."));
+ }
+ }
+
+ /* =========================================================================================
+ * 이미지 업로드/삭제: ImageStorage 추상화 사용.
+ * - AWS 자격증명 없으면 LocalFileImageStorage (./uploads), 있으면 S3ImageStorage 자동 선택.
+ * - cocktail/detail.html 클라이언트는 response.data 에서 URL 문자열을 읽어 input/preview 에 세팅.
+ * ========================================================================================= */
+ @PostMapping("/cocktail/image/upload")
+ public ResponseEntity> uploadCocktailImage(@RequestParam("file") MultipartFile file) {
+ try {
+ String url = imageStorage.upload("cocktails", file);
+ return ResponseEntity.ok(ResponseDto.onSuccess("업로드 성공 (" + imageStorage.mode() + ")", url));
+ } catch (Exception ex) {
+ log.error("[admin] cocktail image upload failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "업로드 실패: " + ex.getMessage()));
+ }
+ }
+
+ @PostMapping("/cocktail/image/delete")
+ public ResponseEntity> deleteCocktailImage(@RequestBody ImageDeleteReq req) {
+ try {
+ boolean deleted = imageStorage.delete(req.getUrl());
+ return ResponseEntity.ok(ResponseDto.onSuccess("삭제 처리 완료 (" + imageStorage.mode() + ")", deleted));
+ } catch (Exception ex) {
+ log.error("[admin] cocktail image delete failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: " + ex.getMessage()));
+ }
+ }
+
+ @Getter @Setter
+ public static class ImageDeleteReq {
+ private String url;
+ }
+
+ /* ======================================== DTO ======================================== */
+
+ /**
+ * 칵테일 목록 한 행 (ag-grid). 필드명은 cocktails.html columnDefs 의 field 와 일치해야 한다.
+ */
+ @Getter
+ @AllArgsConstructor
+ public static class AdminCocktailRow {
+ private final Long id;
+
+ @JsonProperty("cocktail_kr")
+ private final String cocktailKr;
+
+ @JsonProperty("cocktail_en")
+ private final String cocktailEn;
+
+ @JsonProperty("abv_band")
+ private final String abvBand;
+
+ @JsonProperty("taste_level")
+ private final String tasteLevel;
+
+ @JsonProperty("createdAt")
+ private final LocalDateTime createdAt;
+
+ public static AdminCocktailRow from(Cocktail c) {
+ String kor = c.getKorName() != null ? c.getKorName() : c.getCocktailKR();
+ String eng = c.getEngName() != null ? c.getEngName() : c.getCocktailEN();
+ String abv = c.getAbvBand() != null ? c.getAbvBand().name() : null;
+ String taste = c.getTasteLevel() != null ? c.getTasteLevel().name() : null;
+ return new AdminCocktailRow(c.getId(), kor, eng, abv, taste, c.getCreatedAt());
+ }
+ }
+
+ /**
+ * 칵테일 상세 (편집 화면). 필드명은 detail.html fillForm() 이 읽는 키와 일치.
+ */
+ @Getter
+ @AllArgsConstructor
+ public static class AdminCocktailDetail {
+ @JsonProperty("id")
+ private final Long id;
+
+ @JsonProperty("cocktail_kr")
+ private final String cocktailKr;
+
+ @JsonProperty("cocktail_en")
+ private final String cocktailEn;
+
+ @JsonProperty("max_alcohol")
+ private final Integer maxAlcohol;
+
+ @JsonProperty("min_alcohol")
+ private final Integer minAlcohol;
+
+ @JsonProperty("abv_band")
+ private final String abvBand;
+
+ @JsonProperty("taste_level")
+ private final String tasteLevel;
+
+ @JsonProperty("origin_text")
+ private final String originText;
+
+ @JsonProperty("image_url")
+ private final String imageUrl;
+
+ @JsonProperty("seasons")
+ private final List seasons;
+
+ @JsonProperty("ingredients")
+ private final List ingredients;
+
+ @JsonProperty("tags")
+ private final List tags;
+
+ public static AdminCocktailDetail from(Cocktail c) {
+ String kor = c.getKorName() != null ? c.getKorName() : c.getCocktailKR();
+ String eng = c.getEngName() != null ? c.getEngName() : c.getCocktailEN();
+ String abv = c.getAbvBand() != null ? c.getAbvBand().name() : null;
+ String taste = c.getTasteLevel() != null ? c.getTasteLevel().name() : null;
+
+ List seasonStrs = c.getSeasons() == null ? List.of()
+ : c.getSeasons().stream().filter(Objects::nonNull).map(Season::name).toList();
+
+ List ingredients = new ArrayList<>();
+ if (c.getIngredients() != null) {
+ for (Ingredient i : c.getIngredients()) {
+ ingredients.add(new IngredientDto(i.getName(), i.getAmount()));
+ }
+ }
+
+ // 태그 그룹핑 (CocktailMapper 와 동일 로직)
+ Map grouped = new LinkedHashMap<>();
+ if (c.getTags() != null) {
+ for (CocktailTag ct : c.getTags()) {
+ if (ct.getTag() == null) continue;
+ TagType type = ct.getTag().getType();
+ TagGroupDto g = grouped.computeIfAbsent(type, TagGroupDto::new);
+ g.getTags().add(new TagDto(ct.getTag().getId(), ct.getTag().getName()));
+ }
+ }
+ List tagGroups = new ArrayList<>(grouped.values());
+
+ return new AdminCocktailDetail(c.getId(), kor, eng, c.getMaxAlcohol(), c.getMinAlcohol(),
+ abv, taste, c.getOriginText(), c.getImageUrl(),
+ seasonStrs, ingredients, tagGroups);
+ }
+ }
+
+ @Getter
+ @Setter
+ public static class TagSaveReq {
+ @JsonProperty("type")
+ private String type;
+
+ @JsonProperty("name")
+ private String name;
+ }
+}
diff --git a/src/main/java/com/application/domain/admin/controller/AdminGuideController.java b/src/main/java/com/application/domain/admin/controller/AdminGuideController.java
new file mode 100644
index 0000000..e6b4aa9
--- /dev/null
+++ b/src/main/java/com/application/domain/admin/controller/AdminGuideController.java
@@ -0,0 +1,352 @@
+package com.application.domain.admin.controller;
+
+import com.application.common.exception.custom.CustomApiException;
+import com.application.common.response.ResponseDto;
+import com.application.common.storage.ImageStorage;
+import com.application.domain.admin.service.AdminGuideService;
+import com.application.domain.cocktail.entity.guide.Guide;
+import com.application.domain.cocktail.entity.guide.GuideDetail;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+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.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 관리자 가이드(칵테일 가이드) 관리 컨트롤러.
+ *
+ * 뷰 라우트와 AJAX 엔드포인트를 한 클래스에 묶어, AdminInquiryController 와 동일한
+ * 구조(뷰 + REST 혼합)로 운영자 동선을 단순화한다.
+ *
+ * 보안: /admin/** 은 SecurityConfig 의 adminSecurity 체인(form-login + 세션) 으로 보호.
+ * 별도 인증 코드 불필요. CSRF 는 admin 체인에서 disable.
+ *
+ * 응답: AJAX 는 ResponseDto ({code, msg, data}) 사용.
+ * JSON 필드는 snake_case 가 필요한 경우 @JsonProperty 명시.
+ *
+ * 이미지 업로드/삭제: 환경변수 미주입 상태이므로 503 + code:-1 stub 응답.
+ */
+@Controller
+@RequestMapping("/admin")
+@RequiredArgsConstructor
+@Slf4j
+public class AdminGuideController {
+
+ private final AdminGuideService adminGuideService;
+ private final ImageStorage imageStorage;
+
+ /* ====================================================================================
+ * VIEW ROUTES
+ * ==================================================================================== */
+
+ /** GET /admin/guides — 가이드 목록 화면 */
+ @GetMapping("/guides")
+ public String listView(Model model) {
+ List guides = adminGuideService.findAll();
+ List rows = new ArrayList<>();
+ for (Guide g : guides) {
+ int detailCount = g.getDetails() == null ? 0 : g.getDetails().size();
+ rows.add(new GuideRow(g.getPart(), g.getTitle(), g.getImageUrl(), detailCount));
+ }
+ model.addAttribute("guides", rows);
+ model.addAttribute("totalCount", rows.size());
+ return "admin/guides";
+ }
+
+ /** GET /admin/guide/edit?part=X — 기존 가이드 편집 화면 */
+ @GetMapping("/guide/edit")
+ public String editView(@RequestParam("part") Integer part, Model model) {
+ // 컨트롤러에서는 빈 모델만 넘기고, 실제 데이터는 화면 JS 가 /admin/guide/info 로 가져옴
+ model.addAttribute("mode", "edit");
+ model.addAttribute("part", part);
+ return "admin/guide/edit";
+ }
+
+ /** GET /admin/guide/new — 신규 가이드 등록 화면 (편집 화면과 동일 템플릿) */
+ @GetMapping("/guide/new")
+ public String newView(Model model) {
+ model.addAttribute("mode", "new");
+ model.addAttribute("part", null);
+ return "admin/guide/edit";
+ }
+
+ /* ====================================================================================
+ * AJAX ENDPOINTS
+ * ==================================================================================== */
+
+ /** GET /admin/guide/list — 전체 가이드 목록 JSON (요약, details 미포함) */
+ @GetMapping("/guide/list")
+ @ResponseBody
+ public ResponseEntity>> ajaxList() {
+ List guides = adminGuideService.findAll();
+ List rows = new ArrayList<>();
+ for (Guide g : guides) {
+ int detailCount = g.getDetails() == null ? 0 : g.getDetails().size();
+ rows.add(new GuideRow(g.getPart(), g.getTitle(), g.getImageUrl(), detailCount));
+ }
+ return ResponseEntity.ok(ResponseDto.onSuccess("가이드 목록 조회 성공", rows));
+ }
+
+ /** GET /admin/guide/info?part=X — 단일 가이드 + details */
+ @GetMapping("/guide/info")
+ @ResponseBody
+ public ResponseEntity> ajaxInfo(@RequestParam("part") Integer part) {
+ Guide g = adminGuideService.findByPart(part);
+ List details = adminGuideService.findDetailsByPart(part);
+
+ GuideHeader header = new GuideHeader(g.getPart(), g.getTitle(), g.getImageUrl());
+ List detailRows = new ArrayList<>();
+ for (GuideDetail d : details) {
+ detailRows.add(new DetailRow(d.getId(), d.getDisplayOrder(),
+ d.getSubtitle(), d.getDescription(), d.getImageUrl()));
+ }
+ return ResponseEntity.ok(ResponseDto.onSuccess("가이드 상세 조회 성공",
+ new GuideInfo(header, detailRows)));
+ }
+
+ /** POST /admin/guide/save — 생성 또는 업데이트 (guide level only) */
+ @PostMapping("/guide/save")
+ @ResponseBody
+ public ResponseEntity> ajaxSave(@RequestBody GuideSaveReq req) {
+ if (req == null) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "요청 본문이 비어 있습니다."));
+ }
+ try {
+ Guide saved = adminGuideService.save(req.getPart(), req.getTitle(), req.getImageUrl());
+ return ResponseEntity.ok(ResponseDto.onSuccess("저장 성공",
+ new GuideHeader(saved.getPart(), saved.getTitle(), saved.getImageUrl())));
+ } catch (CustomApiException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, ex.getMessage()));
+ } catch (Exception ex) {
+ log.error("[admin] guide save failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "저장 실패: " + ex.getMessage()));
+ }
+ }
+
+ /** GET /admin/guide/delete?part=X — 가이드 삭제 (cascade detail 자동 삭제) */
+ @GetMapping("/guide/delete")
+ @ResponseBody
+ public ResponseEntity> ajaxDelete(@RequestParam("part") Integer part) {
+ try {
+ adminGuideService.delete(part);
+ return ResponseEntity.ok(ResponseDto.onSuccess("삭제되었습니다.", null));
+ } catch (CustomApiException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, ex.getMessage()));
+ } catch (Exception ex) {
+ log.error("[admin] guide delete failed part={} : {}", part, ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: " + ex.getMessage()));
+ }
+ }
+
+ /** POST /admin/guide/detail/save — detail 단건 저장 (id 있으면 update) */
+ @PostMapping("/guide/detail/save")
+ @ResponseBody
+ public ResponseEntity> ajaxDetailSave(@RequestBody DetailSaveReq req) {
+ if (req == null) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "요청 본문이 비어 있습니다."));
+ }
+ try {
+ GuideDetail saved = adminGuideService.saveDetail(
+ req.getId(), req.getGuidePart(), req.getDisplayOrder(),
+ req.getSubtitle(), req.getDescription(), req.getImageUrl());
+ return ResponseEntity.ok(ResponseDto.onSuccess("저장 성공",
+ new DetailRow(saved.getId(), saved.getDisplayOrder(),
+ saved.getSubtitle(), saved.getDescription(), saved.getImageUrl())));
+ } catch (CustomApiException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, ex.getMessage()));
+ } catch (Exception ex) {
+ log.error("[admin] guide detail save failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "저장 실패: " + ex.getMessage()));
+ }
+ }
+
+ /** GET /admin/guide/detail/delete?id=X — detail 단건 삭제 */
+ @GetMapping("/guide/detail/delete")
+ @ResponseBody
+ public ResponseEntity> ajaxDetailDelete(@RequestParam("id") Long id) {
+ try {
+ adminGuideService.deleteDetail(id);
+ return ResponseEntity.ok(ResponseDto.onSuccess("삭제되었습니다.", null));
+ } catch (CustomApiException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, ex.getMessage()));
+ } catch (Exception ex) {
+ log.error("[admin] guide detail delete failed id={} : {}", id, ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: " + ex.getMessage()));
+ }
+ }
+
+ /** POST /admin/guide/detail/reorder — bulk reorder */
+ @PostMapping("/guide/detail/reorder")
+ @ResponseBody
+ public ResponseEntity> ajaxDetailReorder(@RequestBody ReorderReq req) {
+ if (req == null || req.getGuidePart() == null || req.getOrder() == null) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "요청 본문이 올바르지 않습니다."));
+ }
+ try {
+ List pairs = new ArrayList<>();
+ for (ReorderItem item : req.getOrder()) {
+ if (item.getId() == null || item.getDisplayOrder() == null) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1,
+ "order 항목에 id/display_order 누락이 있습니다."));
+ }
+ pairs.add(new long[]{item.getId(), item.getDisplayOrder()});
+ }
+ adminGuideService.reorderDetails(req.getGuidePart(), pairs);
+ return ResponseEntity.ok(ResponseDto.onSuccess("순서 변경 성공", null));
+ } catch (CustomApiException ex) {
+ return ResponseEntity.ok(ResponseDto.onFail(-1, ex.getMessage()));
+ } catch (Exception ex) {
+ log.error("[admin] guide detail reorder failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "순서 변경 실패: " + ex.getMessage()));
+ }
+ }
+
+ /* ====================================================================================
+ * 이미지 업로드/삭제: ImageStorage 추상화 사용.
+ * 로컬 환경(AWS 키 없음) → LocalFileImageStorage, 운영 → S3ImageStorage 자동 선택.
+ * ==================================================================================== */
+
+ @PostMapping("/guide/image/upload")
+ @ResponseBody
+ public ResponseEntity> uploadGuideImage(
+ @RequestParam("file") org.springframework.web.multipart.MultipartFile file) {
+ try {
+ String url = imageStorage.upload("guides", file);
+ return ResponseEntity.ok(ResponseDto.onSuccess("업로드 성공 (" + imageStorage.mode() + ")", url));
+ } catch (Exception ex) {
+ log.error("[admin] guide image upload failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "업로드 실패: " + ex.getMessage()));
+ }
+ }
+
+ @PostMapping("/guide/image/delete")
+ @ResponseBody
+ public ResponseEntity> deleteGuideImage(@RequestBody GuideImageDeleteReq req) {
+ try {
+ boolean deleted = imageStorage.delete(req.getUrl());
+ return ResponseEntity.ok(ResponseDto.onSuccess("삭제 처리 완료 (" + imageStorage.mode() + ")", deleted));
+ } catch (Exception ex) {
+ log.error("[admin] guide image delete failed: {}", ex.getMessage(), ex);
+ return ResponseEntity.ok(ResponseDto.onFail(-1, "삭제 실패: " + ex.getMessage()));
+ }
+ }
+
+ @Getter @Setter
+ public static class GuideImageDeleteReq {
+ private String url;
+ }
+
+ /* ====================================================================================
+ * DTOs
+ * ==================================================================================== */
+
+ /** 목록 row */
+ @Getter
+ @AllArgsConstructor
+ public static class GuideRow {
+ private final Integer part;
+ private final String title;
+
+ @JsonProperty("image_url")
+ private final String imageUrl;
+
+ @JsonProperty("detail_count")
+ private final Integer detailCount;
+ }
+
+ /** /info 응답: header + details */
+ @Getter
+ @AllArgsConstructor
+ public static class GuideInfo {
+ @JsonProperty("guide")
+ private final GuideHeader guide;
+
+ @JsonProperty("details")
+ private final List details;
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static class GuideHeader {
+ private final Integer part;
+ private final String title;
+
+ @JsonProperty("image_url")
+ private final String imageUrl;
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static class DetailRow {
+ private final Long id;
+
+ @JsonProperty("display_order")
+ private final Integer displayOrder;
+
+ private final String subtitle;
+ private final String description;
+
+ @JsonProperty("image_url")
+ private final String imageUrl;
+ }
+
+ @Getter @Setter
+ public static class GuideSaveReq {
+ @JsonProperty("part")
+ private Integer part;
+
+ @JsonProperty("title")
+ private String title;
+
+ @JsonProperty("image_url")
+ private String imageUrl;
+ }
+
+ @Getter @Setter
+ public static class DetailSaveReq {
+ @JsonProperty("id")
+ private Long id; // null → insert
+
+ @JsonProperty("guide_part")
+ private Integer guidePart;
+
+ @JsonProperty("display_order")
+ private Integer displayOrder;
+
+ @JsonProperty("subtitle")
+ private String subtitle;
+
+ @JsonProperty("description")
+ private String description;
+
+ @JsonProperty("image_url")
+ private String imageUrl;
+ }
+
+ @Getter @Setter
+ public static class ReorderReq {
+ @JsonProperty("guide_part")
+ private Integer guidePart;
+
+ @JsonProperty("order")
+ private List order;
+ }
+
+ @Getter @Setter
+ public static class ReorderItem {
+ @JsonProperty("id")
+ private Long id;
+
+ @JsonProperty("display_order")
+ private Integer displayOrder;
+ }
+}
diff --git a/src/main/java/com/application/domain/admin/controller/AdminInquiryController.java b/src/main/java/com/application/domain/admin/controller/AdminInquiryController.java
new file mode 100644
index 0000000..e21e10b
--- /dev/null
+++ b/src/main/java/com/application/domain/admin/controller/AdminInquiryController.java
@@ -0,0 +1,73 @@
+package com.application.domain.admin.controller;
+
+import com.application.domain.inquiry.entity.Inquiry;
+import com.application.domain.inquiry.entity.InquiryStatus;
+import com.application.domain.inquiry.service.InquiryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 관리자용 1:1 문의 관리 Thymeleaf 컨트롤러.
+ *
+ * 보안: /admin/** 은 기존 SecurityConfig 의 adminSecurity 체인으로 보호되므로
+ * 별도 권한 검사 로직 없이 뷰를 반환한다.
+ */
+@Controller
+@RequestMapping("/admin/inquiries")
+@RequiredArgsConstructor
+@Slf4j
+public class AdminInquiryController {
+
+ private final InquiryService inquiryService;
+
+ /** 목록 페이지 */
+ @GetMapping
+ public String list(@RequestParam(value = "status", required = false) String statusParam,
+ @RequestParam(value = "page", defaultValue = "0") int page,
+ @RequestParam(value = "size", defaultValue = "20") int size,
+ Model model) {
+
+ InquiryStatus status = null;
+ if (statusParam != null && !statusParam.isBlank() && !"ALL".equalsIgnoreCase(statusParam)) {
+ status = InquiryStatus.fromString(statusParam).orElse(null);
+ }
+
+ Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
+ Page result = inquiryService.adminList(status, pageable);
+
+ model.addAttribute("inquiries", result.getContent());
+ model.addAttribute("currentStatus", statusParam == null ? "ALL" : statusParam.toUpperCase());
+ model.addAttribute("page", result.getNumber());
+ model.addAttribute("totalPages", result.getTotalPages());
+ model.addAttribute("totalElements", result.getTotalElements());
+ model.addAttribute("size", result.getSize());
+ model.addAttribute("statuses", InquiryStatus.values());
+
+ return "admin/inquiries";
+ }
+
+ /** 상세 페이지 */
+ @GetMapping("/{id}")
+ public String detail(@PathVariable Long id, Model model) {
+ Inquiry inquiry = inquiryService.adminGet(id);
+ model.addAttribute("inquiry", inquiry);
+ model.addAttribute("statuses", InquiryStatus.values());
+ return "admin/inquiry-detail";
+ }
+
+ /** 상태/메모 수정 */
+ @PostMapping("/{id}")
+ public String update(@PathVariable Long id,
+ @RequestParam(value = "status", required = false) String status,
+ @RequestParam(value = "adminMemo", required = false) String adminMemo) {
+ inquiryService.adminUpdate(id, status, adminMemo);
+ return "redirect:/admin/inquiries/" + id;
+ }
+}
diff --git a/src/main/java/com/application/domain/admin/controller/AdminViewController.java b/src/main/java/com/application/domain/admin/controller/AdminViewController.java
new file mode 100644
index 0000000..d35edd7
--- /dev/null
+++ b/src/main/java/com/application/domain/admin/controller/AdminViewController.java
@@ -0,0 +1,52 @@
+package com.application.domain.admin.controller;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * Admin 화면 라우팅 공통 컨트롤러.
+ * Spring Security formLogin 이 /admin/login 을 loginPage 로 참조하지만
+ * 해당 뷰를 서빙하는 컨트롤러가 없었기에, 템플릿을 반환하는 최소 라우터를 추가.
+ *
+ * SecurityConfig 는 건드리지 않고 기존 체인 위에서 동작.
+ */
+@Controller
+@RequestMapping("/admin")
+public class AdminViewController {
+
+ @GetMapping("/login")
+ public String login(@RequestParam(value = "error", required = false) String error,
+ @RequestParam(value = "logout", required = false) String logout,
+ Model model) {
+ if (error != null) {
+ model.addAttribute("errorMsg", "아이디 또는 비밀번호가 올바르지 않습니다.");
+ }
+ if (logout != null) {
+ model.addAttribute("logoutMsg", "로그아웃 되었습니다.");
+ }
+ return "admin/login";
+ }
+
+ @GetMapping({"", "/", "/dashboard"})
+ public String dashboard() {
+ return "admin/dashboard";
+ }
+
+ @GetMapping("/cocktails")
+ public String cocktails() {
+ return "admin/cocktails";
+ }
+
+ @GetMapping("/cocktail/detail")
+ public String cocktailDetail() {
+ return "admin/cocktail/detail";
+ }
+
+ @GetMapping("/cocktail/tag")
+ public String tag() {
+ return "admin/tag";
+ }
+}
diff --git a/src/main/java/com/application/domain/admin/service/AdminGuideService.java b/src/main/java/com/application/domain/admin/service/AdminGuideService.java
new file mode 100644
index 0000000..99d85bb
--- /dev/null
+++ b/src/main/java/com/application/domain/admin/service/AdminGuideService.java
@@ -0,0 +1,210 @@
+package com.application.domain.admin.service;
+
+import com.application.common.exception.custom.CustomApiException;
+import com.application.domain.cocktail.entity.guide.Guide;
+import com.application.domain.cocktail.entity.guide.GuideDetail;
+import com.application.domain.cocktail.repository.GuideDetailRepository;
+import com.application.domain.cocktail.repository.GuideRepository;
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * 관리자 가이드 CRUD 비즈니스 로직.
+ *
+ * - guide PK 는 part(int) 이며 직접 입력. 신규 등록 시 중복 검증 필요.
+ * - guide_detail UNIQUE(guide_part, display_order) 제약 때문에 reorder 시 두 단계 업데이트.
+ * 1) 해당 guide_part 모든 행의 display_order 를 음수 영역으로 일괄 이동
+ * 2) 새 순서로 한 행씩 양수 갱신
+ * 동일 트랜잭션 안에서 끝내야 무결성 깨지지 않음.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AdminGuideService {
+
+ private final GuideRepository guideRepository;
+ private final GuideDetailRepository guideDetailRepository;
+ private final EntityManager entityManager;
+
+ /* ============================ 조회 ============================ */
+
+ public List findAll() {
+ // part(PK, int) 오름차순 정렬
+ List all = guideRepository.findAll();
+ all.sort((a, b) -> Integer.compare(
+ a.getPart() == null ? Integer.MAX_VALUE : a.getPart(),
+ b.getPart() == null ? Integer.MAX_VALUE : b.getPart()));
+ return all;
+ }
+
+ public Guide findByPart(Integer part) {
+ return guideRepository.findByPart(part)
+ .orElseThrow(() -> new CustomApiException("존재하지 않는 가이드입니다. part=" + part));
+ }
+
+ public List findDetailsByPart(Integer part) {
+ return guideDetailRepository.findByGuide_PartOrderByDisplayOrderAsc(part);
+ }
+
+ /* ============================ Guide 자체 ============================ */
+
+ @Transactional
+ public Guide save(Integer part, String title, String imageUrl) {
+ if (part == null) {
+ throw new CustomApiException("part 는 필수입니다.");
+ }
+ if (title == null || title.isBlank()) {
+ throw new CustomApiException("title 은 필수입니다.");
+ }
+
+ Optional existing = guideRepository.findByPart(part);
+ Guide entity;
+ if (existing.isPresent()) {
+ // 업데이트: getter only 엔티티이므로 직접 필드 갱신을 위해 새 빌더로 교체
+ Guide old = existing.get();
+ // details 컬렉션을 보존한 채 title / imageUrl 만 변경
+ // Guide 는 setter 가 없으므로 detach 후 builder 로 재생성하지 않고,
+ // EntityManager.merge 를 위해 새 인스턴스 + 기존 details 리스트로 빌드
+ entity = Guide.builder()
+ .part(old.getPart())
+ .title(title)
+ .imageUrl(imageUrl)
+ .details(old.getDetails()) // OneToMany 컬렉션 유지
+ .build();
+ return entityManager.merge(entity);
+ }
+
+ // 신규
+ entity = Guide.builder()
+ .part(part)
+ .title(title)
+ .imageUrl(imageUrl)
+ .details(new ArrayList<>())
+ .build();
+ return guideRepository.save(entity);
+ }
+
+ @Transactional
+ public void delete(Integer part) {
+ Guide g = guideRepository.findByPart(part)
+ .orElseThrow(() -> new CustomApiException("존재하지 않는 가이드입니다. part=" + part));
+ // CascadeType.ALL + orphanRemoval=true 설정으로 details 가 함께 삭제됨
+ guideRepository.delete(g);
+ }
+
+ /* ============================ GuideDetail ============================ */
+
+ @Transactional
+ public GuideDetail saveDetail(Long id, Integer guidePart, Integer displayOrder,
+ String subtitle, String description, String imageUrl) {
+ if (guidePart == null) throw new CustomApiException("guide_part 는 필수입니다.");
+ if (displayOrder == null) throw new CustomApiException("display_order 는 필수입니다.");
+ if (description == null || description.isBlank()) {
+ throw new CustomApiException("description 은 필수입니다.");
+ }
+
+ Guide guide = guideRepository.findByPart(guidePart)
+ .orElseThrow(() -> new CustomApiException("부모 가이드가 존재하지 않습니다. part=" + guidePart));
+
+ // 동일 (guide_part, display_order) 충돌 체크: 본인을 제외한 다른 행이 사용 중인지
+ Optional sameSlot = guideDetailRepository
+ .findByGuide_PartAndDisplayOrder(guidePart, displayOrder);
+ if (sameSlot.isPresent() && (id == null || !sameSlot.get().getId().equals(id))) {
+ throw new CustomApiException(
+ "이미 사용 중인 순서입니다. (guide_part=" + guidePart + ", display_order=" + displayOrder + ")");
+ }
+
+ GuideDetail detail;
+ if (id != null) {
+ detail = guideDetailRepository.findById(id)
+ .orElseThrow(() -> new CustomApiException("존재하지 않는 detail 입니다. id=" + id));
+ // 기존 엔티티는 setter 가 없으므로 builder 로 재생성 후 merge
+ GuideDetail updated = GuideDetail.builder()
+ .id(detail.getId())
+ .guide(guide)
+ .displayOrder(displayOrder)
+ .subtitle(subtitle)
+ .description(description)
+ .imageUrl(imageUrl)
+ .build();
+ return entityManager.merge(updated);
+ }
+
+ // 신규
+ detail = GuideDetail.builder()
+ .guide(guide)
+ .displayOrder(displayOrder)
+ .subtitle(subtitle)
+ .description(description)
+ .imageUrl(imageUrl)
+ .build();
+ return guideDetailRepository.save(detail);
+ }
+
+ @Transactional
+ public void deleteDetail(Long id) {
+ if (!guideDetailRepository.existsById(id)) {
+ throw new CustomApiException("존재하지 않는 detail 입니다. id=" + id);
+ }
+ guideDetailRepository.deleteById(id);
+ }
+
+ /**
+ * reorder 벌크 업데이트.
+ *
+ * UNIQUE(guide_part, display_order) 제약 때문에 한 줄씩 단순히 update 하면
+ * 중간 단계에서 동일 (part, order) 가 두 행에 존재하는 순간이 생겨 SQLException.
+ *
+ * 해결: 1) 모든 행의 display_order 를 음수 영역으로 이동(temporary)
+ * 2) 받은 order 리스트대로 한 행씩 양수 값 갱신
+ * 3) 같은 트랜잭션이므로 외부에서는 항상 일관된 상태만 보임
+ */
+ @Transactional
+ public void reorderDetails(Integer guidePart, List idOrderPairs) {
+ if (guidePart == null) throw new CustomApiException("guide_part 누락");
+ if (idOrderPairs == null || idOrderPairs.isEmpty()) {
+ throw new CustomApiException("order 리스트가 비어 있습니다.");
+ }
+
+ // 같은 displayOrder 가 두 번 들어왔는지, id 가 해당 part 의 detail 인지 검증
+ Set seenOrders = new HashSet<>();
+ for (long[] pair : idOrderPairs) {
+ int order = (int) pair[1];
+ if (!seenOrders.add(order)) {
+ throw new CustomApiException("동일 display_order 가 중복 지정되었습니다: " + order);
+ }
+ }
+
+ List existing = guideDetailRepository.findByGuide_PartOrderByDisplayOrderAsc(guidePart);
+ Set existingIds = new HashSet<>();
+ for (GuideDetail d : existing) existingIds.add(d.getId());
+
+ for (long[] pair : idOrderPairs) {
+ if (!existingIds.contains(pair[0])) {
+ throw new CustomApiException("해당 가이드에 속하지 않은 detail id: " + pair[0]);
+ }
+ }
+
+ // 1단계: 음수 영역으로 이동 (영속성 컨텍스트와 동기화 위해 flush+clear)
+ entityManager.flush();
+ guideDetailRepository.shiftToTemporaryNegative(guidePart);
+ entityManager.clear();
+
+ // 2단계: 새 order 적용
+ for (long[] pair : idOrderPairs) {
+ int updated = guideDetailRepository.updateDisplayOrder(pair[0], guidePart, (int) pair[1]);
+ if (updated == 0) {
+ throw new CustomApiException("display_order 갱신 실패. id=" + pair[0]);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/application/domain/cocktail/dto/response/CocktailDetailResponseDto.java b/src/main/java/com/application/domain/cocktail/dto/response/CocktailDetailResponseDto.java
index abd10a1..76cdd56 100644
--- a/src/main/java/com/application/domain/cocktail/dto/response/CocktailDetailResponseDto.java
+++ b/src/main/java/com/application/domain/cocktail/dto/response/CocktailDetailResponseDto.java
@@ -30,8 +30,12 @@ public record CocktailDetailResponseDto(
String style,
String glassType,
String glassImageUrl,
+ String glassImageUrlThumb,
+ String glassImageUrlDetail,
String base,
String imageUrl,
+ String imageUrlThumb,
+ String imageUrlDetail,
@Schema(description = "맛 태그 목록", example = "[\"상큼한\", \"달콤한\"]")
List flavors,
@@ -63,8 +67,12 @@ public static CocktailDetailResponseDto from(Cocktail cocktail, Boolean isBookma
cocktail.getStyle(),
cocktail.getGlassType(),
cocktail.getGlassImageUrl(),
+ cocktail.getGlassImageUrlThumb(),
+ cocktail.getGlassImageUrlDetail(),
cocktail.getBase(),
cocktail.getImageUrl(),
+ cocktail.getImageUrlThumb(),
+ cocktail.getImageUrlDetail(),
cocktail.getFlavors().stream()
.map(CocktailFlavor::getFlavorName)
.toList(),
diff --git a/src/main/java/com/application/domain/cocktail/dto/response/CocktailResponseDto.java b/src/main/java/com/application/domain/cocktail/dto/response/CocktailResponseDto.java
index 79d7e1d..0575596 100644
--- a/src/main/java/com/application/domain/cocktail/dto/response/CocktailResponseDto.java
+++ b/src/main/java/com/application/domain/cocktail/dto/response/CocktailResponseDto.java
@@ -49,12 +49,24 @@ public record CocktailResponseDto(
@Schema(description = "글라스 이미지 URL")
String glassImageUrl,
+ @Schema(description = "글라스 이미지 URL (thumbnail variant)")
+ String glassImageUrlThumb,
+
+ @Schema(description = "글라스 이미지 URL (detail variant)")
+ String glassImageUrlDetail,
+
@Schema(description = "베이스 술", example = "진")
String base,
@Schema(description = "칵테일 이미지 URL")
String imageUrl,
+ @Schema(description = "칵테일 이미지 URL (thumbnail variant)")
+ String imageUrlThumb,
+
+ @Schema(description = "칵테일 이미지 URL (detail variant)")
+ String imageUrlDetail,
+
@Schema(description = "맛 태그 목록", example = "[\"상큼한\", \"달콤한\"]")
List flavors,
@@ -102,8 +114,12 @@ public static CocktailResponseDto from(Cocktail cocktail, Long userId, ReactionT
cocktail.getStyle(),
cocktail.getGlassType(),
cocktail.getGlassImageUrl(),
+ cocktail.getGlassImageUrlThumb(),
+ cocktail.getGlassImageUrlDetail(),
cocktail.getBase(),
cocktail.getImageUrl(),
+ cocktail.getImageUrlThumb(),
+ cocktail.getImageUrlDetail(),
parseFlavors(cocktail),
parseMoods(cocktail),
cocktail.isBookmarkedBy(userId),
@@ -129,8 +145,12 @@ public static CocktailResponseDto from(Cocktail cocktail, ReactionType myReactio
cocktail.getStyle(),
cocktail.getGlassType(),
cocktail.getGlassImageUrl(),
+ cocktail.getGlassImageUrlThumb(),
+ cocktail.getGlassImageUrlDetail(),
cocktail.getBase(),
cocktail.getImageUrl(),
+ cocktail.getImageUrlThumb(),
+ cocktail.getImageUrlDetail(),
parseFlavors(cocktail),
parseMoods(cocktail),
isBookmarked,
@@ -156,8 +176,12 @@ public static CocktailResponseDto from(Cocktail cocktail, boolean isBookmarked)
cocktail.getStyle(),
cocktail.getGlassType(),
cocktail.getGlassImageUrl(),
+ cocktail.getGlassImageUrlThumb(),
+ cocktail.getGlassImageUrlDetail(),
cocktail.getBase(),
cocktail.getImageUrl(),
+ cocktail.getImageUrlThumb(),
+ cocktail.getImageUrlDetail(),
parseFlavors(cocktail),
parseMoods(cocktail),
isBookmarked,
diff --git a/src/main/java/com/application/domain/cocktail/entity/Cocktail.java b/src/main/java/com/application/domain/cocktail/entity/Cocktail.java
index f883406..3ea0d51 100755
--- a/src/main/java/com/application/domain/cocktail/entity/Cocktail.java
+++ b/src/main/java/com/application/domain/cocktail/entity/Cocktail.java
@@ -59,6 +59,14 @@ public class Cocktail {
@Comment("이미지 URL")
private String imageUrl;
+ @Column(name = "image_url_thumb", length = 1000)
+ @Comment("이미지 URL (thumbnail variant)")
+ private String imageUrlThumb;
+
+ @Column(name = "image_url_detail", length = 1000)
+ @Comment("이미지 URL (detail variant)")
+ private String imageUrlDetail;
+
// FIXME enum으로 변경
private String season;
@@ -89,6 +97,14 @@ public class Cocktail {
private String glassImageUrl;
+ @Column(name = "glass_image_url_thumb", length = 255)
+ @Comment("글라스 이미지 URL (thumbnail variant)")
+ private String glassImageUrlThumb;
+
+ @Column(name = "glass_image_url_detail", length = 255)
+ @Comment("글라스 이미지 URL (detail variant)")
+ private String glassImageUrlDetail;
+
// FIXME ENUM으로 하면 좋을듯
private String base;
diff --git a/src/main/java/com/application/domain/cocktail/repository/CocktailRepository.java b/src/main/java/com/application/domain/cocktail/repository/CocktailRepository.java
index fa36726..b29c944 100755
--- a/src/main/java/com/application/domain/cocktail/repository/CocktailRepository.java
+++ b/src/main/java/com/application/domain/cocktail/repository/CocktailRepository.java
@@ -17,12 +17,23 @@ public interface CocktailRepository extends JpaRepository, Cockt
/**
*
* [JpaRepository 메서드 쿼리]
- * 특정 문자열로 시작하는 칵테일 최대 5개 조회
+ * 특정 문자열로 시작하는 칵테일 최대 5개 조회 (한글 이름)
*
* @param searchText 검색어
*/
List findTop5ByKorNameStartingWith(String searchText);
+ /**
+ * 한국어/영어 이름 어느 쪽이든 contains (case-insensitive) 매칭. 최대 5개.
+ * 영문 검색을 지원하기 위해 추가됨.
+ */
+ @Query(value = "SELECT c.kor_name AS korName, c.eng_name AS engName FROM cocktail c " +
+ "WHERE LOWER(c.kor_name) LIKE LOWER(CONCAT('%', :q, '%')) " +
+ " OR LOWER(c.eng_name) LIKE LOWER(CONCAT('%', :q, '%')) " +
+ "ORDER BY c.kor_name ASC LIMIT 5",
+ nativeQuery = true)
+ List findTop5SuggestionsBilingual(@Param("q") String q);
+
/**
*
* recommendCount 기준으로 내림차순 정렬하여 상위 10개의 칵테일 엔티티를 조회합니다.
diff --git a/src/main/java/com/application/domain/cocktail/repository/GuideDetailRepository.java b/src/main/java/com/application/domain/cocktail/repository/GuideDetailRepository.java
new file mode 100644
index 0000000..48cf9c2
--- /dev/null
+++ b/src/main/java/com/application/domain/cocktail/repository/GuideDetailRepository.java
@@ -0,0 +1,44 @@
+package com.application.domain.cocktail.repository;
+
+import com.application.domain.cocktail.entity.guide.GuideDetail;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 가이드 세부 단계 Repository.
+ *
+ * UNIQUE(guide_part, display_order) 제약이 있어 reorder 시 임시 음수 offset 트릭이 필요하므로
+ * 벌크 업데이트용 native query 두 개를 함께 제공한다.
+ */
+public interface GuideDetailRepository extends JpaRepository {
+
+ List findByGuide_PartOrderByDisplayOrderAsc(Integer guidePart);
+
+ Optional findByGuide_PartAndDisplayOrder(Integer guidePart, Integer displayOrder);
+
+ long countByGuide_Part(Integer guidePart);
+
+ /**
+ * reorder 1단계: 해당 guide_part 의 모든 detail 의 display_order 를 음수 영역으로 옮긴다.
+ * (UNIQUE(guide_part, display_order) 충돌 회피용 임시 값.)
+ */
+ @Modifying
+ @Query(value = "UPDATE guide_detail SET display_order = -display_order - 100000 " +
+ "WHERE guide_part = :guidePart", nativeQuery = true)
+ int shiftToTemporaryNegative(@Param("guidePart") Integer guidePart);
+
+ /**
+ * reorder 2단계: 한 행씩 새 display_order 로 갱신.
+ */
+ @Modifying
+ @Query(value = "UPDATE guide_detail SET display_order = :newOrder " +
+ "WHERE id = :id AND guide_part = :guidePart", nativeQuery = true)
+ int updateDisplayOrder(@Param("id") Long id,
+ @Param("guidePart") Integer guidePart,
+ @Param("newOrder") Integer newOrder);
+}
diff --git a/src/main/java/com/application/domain/cocktail/repository/Impl/CocktailRepositoryImpl.java b/src/main/java/com/application/domain/cocktail/repository/Impl/CocktailRepositoryImpl.java
index 8b2f441..e5323cd 100644
--- a/src/main/java/com/application/domain/cocktail/repository/Impl/CocktailRepositoryImpl.java
+++ b/src/main/java/com/application/domain/cocktail/repository/Impl/CocktailRepositoryImpl.java
@@ -56,8 +56,11 @@ public Page getCocktails(CocktailSearchConditionDto condition, Pageabl
// --- WHERE 절 조립 ---
BooleanBuilder builder = new BooleanBuilder();
- builder.and(korNameContains(condition.korName()));
- builder.and(engNameContains(condition.engName()));
+ // 단일 검색창 패턴: korName 또는 engName 어느 쪽으로 들어와도 둘 다 OR 매칭 (case-insensitive).
+ // 앱이 항상 korName 만 채워서 보내기 때문에 영문 검색이 안 되던 버그 대응.
+ builder.and(nameContainsBilingual(
+ StringUtils.hasText(condition.korName()) ? condition.korName() : condition.engName()
+ ));
builder.and(abvBandEq(condition.abvBand()));
builder.and(styleEq(condition.style()));
// builder.and(baseEq(condition.base())); // base 다중선택 가능하도록 변경
@@ -346,6 +349,13 @@ private BooleanExpression engNameContains(String engName) {
return StringUtils.hasText(engName) ? cocktail.engName.contains(engName) : null;
}
+ /** 한국어/영어 컬럼 둘 다 case-insensitive contains 매칭 (OR). */
+ private BooleanExpression nameContainsBilingual(String term) {
+ if (!StringUtils.hasText(term)) return null;
+ return cocktail.korName.containsIgnoreCase(term)
+ .or(cocktail.engName.containsIgnoreCase(term));
+ }
+
// [추가] 도수 레벨 (예: "약함", "보통", "강함") 정확히 일치
private BooleanExpression abvBandEq(AbvLevel abvBand) {
return abvBand != null ? cocktail.abvBand.stringValue().eq(abvBand.getAbvLevel()) : null;
diff --git a/src/main/java/com/application/domain/cocktail/service/CocktailService.java b/src/main/java/com/application/domain/cocktail/service/CocktailService.java
index 7340da9..f0e7338 100644
--- a/src/main/java/com/application/domain/cocktail/service/CocktailService.java
+++ b/src/main/java/com/application/domain/cocktail/service/CocktailService.java
@@ -233,11 +233,15 @@ public CocktailResponseDto getRecommendation(CocktailRecommendationDto dto) {
}
public List getCocktailSuggestions(String searchText) {
- List projections =
- cocktailRepository.findTop5ByKorNameStartingWith(searchText);
+ if (searchText == null || searchText.isBlank()) return List.of();
+ String q = searchText.trim();
+ // 한글/영어 양쪽 contains 매칭 (case-insensitive). 매칭된 쪽 이름 반환.
+ List projections =
+ cocktailRepository.findTop5SuggestionsBilingual(q);
+ String lowerQ = q.toLowerCase();
return projections.stream()
- .map(CocktailRepository.CocktailNameProjection::getKorName)
+ .map(p -> p.getKorName().toLowerCase().contains(lowerQ) ? p.getKorName() : p.getEngName())
.toList();
}
diff --git a/src/main/java/com/application/domain/inquiry/controller/InquiryController.java b/src/main/java/com/application/domain/inquiry/controller/InquiryController.java
new file mode 100644
index 0000000..d56bd46
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/controller/InquiryController.java
@@ -0,0 +1,109 @@
+package com.application.domain.inquiry.controller;
+
+import com.application.common.auth.dto.oauth2Dto.CustomOAuth2User;
+import com.application.common.exception.custom.CustomApiException;
+import com.application.common.response.ResponseDto;
+import com.application.domain.inquiry.dto.request.InquiryCreateReq;
+import com.application.domain.inquiry.dto.response.InquiryCreateRes;
+import com.application.domain.inquiry.dto.response.InquiryPageRes;
+import com.application.domain.inquiry.dto.response.InquiryRes;
+import com.application.domain.inquiry.service.InquiryService;
+import com.application.domain.member.entity.Member;
+import com.application.domain.member.service.MemberService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/v2/inquiry")
+@RequiredArgsConstructor
+@Tag(name = "1:1 문의", description = "사용자 1:1 문의 등록 / 조회 API")
+public class InquiryController {
+
+ private final InquiryService inquiryService;
+ private final MemberService memberService;
+
+ @Operation(
+ summary = "1:1 문의 등록",
+ description = "로그인 사용자 / 비로그인 사용자 모두 사용 가능합니다. " +
+ "비로그인 시 device_number(body 또는 X-Device-Number 헤더) 필수."
+ )
+ @PostMapping
+ public ResponseEntity> create(
+ @Valid @RequestBody InquiryCreateReq req,
+ @RequestHeader(value = "X-Device-Number", required = false) String deviceHeader
+ ) {
+ Member member = resolveMemberOrNull();
+ InquiryCreateRes res = inquiryService.create(req, member, deviceHeader);
+ return ResponseEntity.ok(ResponseDto.onSuccess("문의가 접수되었습니다.", res));
+ }
+
+ @Operation(
+ summary = "내 1:1 문의 목록 조회",
+ description = "인증 필요. 본인이 등록한 문의만 페이지네이션으로 반환합니다."
+ )
+ @GetMapping("/mine")
+ public ResponseEntity> listMine(
+ @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+ Member member = resolveMemberOrThrow();
+ InquiryPageRes res = inquiryService.listMine(member.getId(), pageable);
+ return ResponseEntity.ok(ResponseDto.onSuccess(res));
+ }
+
+ @Operation(
+ summary = "1:1 문의 단건 조회",
+ description = "인증 필요. 본인 문의 또는 ADMIN만 조회 가능합니다."
+ )
+ @GetMapping("/{id}")
+ public ResponseEntity> getOne(@PathVariable Long id) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !(auth.getPrincipal() instanceof CustomOAuth2User principal)) {
+ throw new CustomApiException("인증이 필요합니다.");
+ }
+ String role = principal.getAuthorities().stream()
+ .findFirst().map(a -> a.getAuthority()).orElse("USER");
+ boolean isAdmin = "ADMIN".equalsIgnoreCase(role);
+
+ Member member = memberService.getMemberByCredentialId(principal.getCredentialId());
+ Long memberId = member != null ? member.getId() : null;
+
+ InquiryRes res = inquiryService.getOneForMemberOrAdmin(id, memberId, isAdmin);
+ return ResponseEntity.ok(ResponseDto.onSuccess(res));
+ }
+
+ // ------------------------------------------------------------------
+ // private helpers
+ // ------------------------------------------------------------------
+
+ private Member resolveMemberOrNull() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !(auth.getPrincipal() instanceof CustomOAuth2User principal)) {
+ return null;
+ }
+ try {
+ return memberService.getMemberByCredentialId(principal.getCredentialId());
+ } catch (Exception e) {
+ log.warn("[INQUIRY] resolveMemberOrNull failed: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private Member resolveMemberOrThrow() {
+ Member member = resolveMemberOrNull();
+ if (member == null) {
+ throw new CustomApiException("인증이 필요합니다.");
+ }
+ return member;
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/dto/request/InquiryCreateReq.java b/src/main/java/com/application/domain/inquiry/dto/request/InquiryCreateReq.java
new file mode 100644
index 0000000..c02e5e4
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/dto/request/InquiryCreateReq.java
@@ -0,0 +1,35 @@
+package com.application.domain.inquiry.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Schema(description = "1:1 문의 등록 요청 DTO")
+public class InquiryCreateReq {
+
+ @NotBlank(message = "title is required")
+ @Size(max = 200, message = "title max length is 200")
+ @JsonProperty("title")
+ @Schema(description = "문의 제목", example = "앱이 느려요", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String title;
+
+ @NotBlank(message = "content is required")
+ @JsonProperty("content")
+ @Schema(description = "문의 내용", example = "iPhone 15 Pro에서 앱 로딩이 너무 느립니다.", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String content;
+
+ @Size(max = 255)
+ @JsonProperty("contact")
+ @Schema(description = "답변 받을 연락처 / 이메일 (선택)", example = "user@example.com")
+ private String contact;
+
+ @Size(max = 255)
+ @JsonProperty("device_number")
+ @Schema(description = "기기 고유 번호 (비로그인 사용자는 필수)", example = "device_unique_identifier_12345")
+ private String deviceNumber;
+}
diff --git a/src/main/java/com/application/domain/inquiry/dto/response/InquiryCreateRes.java b/src/main/java/com/application/domain/inquiry/dto/response/InquiryCreateRes.java
new file mode 100644
index 0000000..df4c0a4
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/dto/response/InquiryCreateRes.java
@@ -0,0 +1,20 @@
+package com.application.domain.inquiry.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Schema(description = "1:1 문의 등록 응답 DTO")
+public class InquiryCreateRes {
+
+ @JsonProperty("id")
+ @Schema(description = "등록된 문의 ID", example = "1")
+ private final Long id;
+
+ @Builder
+ public InquiryCreateRes(Long id) {
+ this.id = id;
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/dto/response/InquiryPageRes.java b/src/main/java/com/application/domain/inquiry/dto/response/InquiryPageRes.java
new file mode 100644
index 0000000..7cd2810
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/dto/response/InquiryPageRes.java
@@ -0,0 +1,50 @@
+package com.application.domain.inquiry.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Getter
+@Schema(description = "1:1 문의 목록 응답 DTO")
+public class InquiryPageRes {
+
+ @JsonProperty("content")
+ private final List content;
+
+ @JsonProperty("page")
+ private final int page;
+
+ @JsonProperty("size")
+ private final int size;
+
+ @JsonProperty("total_elements")
+ private final long totalElements;
+
+ @JsonProperty("total_pages")
+ private final int totalPages;
+
+ @Builder
+ public InquiryPageRes(List content, int page, int size,
+ long totalElements, int totalPages) {
+ this.content = content;
+ this.page = page;
+ this.size = size;
+ this.totalElements = totalElements;
+ this.totalPages = totalPages;
+ }
+
+ public static InquiryPageRes fromPage(Page> page, List content) {
+ return InquiryPageRes.builder()
+ .content(content)
+ .page(page.getNumber())
+ .size(page.getSize())
+ .totalElements(page.getTotalElements())
+ .totalPages(page.getTotalPages())
+ .build();
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/dto/response/InquiryRes.java b/src/main/java/com/application/domain/inquiry/dto/response/InquiryRes.java
new file mode 100644
index 0000000..e6dffac
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/dto/response/InquiryRes.java
@@ -0,0 +1,76 @@
+package com.application.domain.inquiry.dto.response;
+
+import com.application.domain.inquiry.entity.Inquiry;
+import com.application.domain.inquiry.entity.InquiryStatus;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Schema(description = "1:1 문의 응답 DTO")
+public class InquiryRes {
+
+ @JsonProperty("id")
+ private final Long id;
+
+ @JsonProperty("member_id")
+ private final Long memberId;
+
+ @JsonProperty("device_number")
+ private final String deviceNumber;
+
+ @JsonProperty("title")
+ private final String title;
+
+ @JsonProperty("content")
+ private final String content;
+
+ @JsonProperty("contact")
+ private final String contact;
+
+ @JsonProperty("status")
+ private final InquiryStatus status;
+
+ @JsonProperty("admin_memo")
+ private final String adminMemo;
+
+ @JsonProperty("created_at")
+ private final LocalDateTime createdAt;
+
+ @JsonProperty("updated_at")
+ private final LocalDateTime updatedAt;
+
+ @Builder
+ public InquiryRes(Long id, Long memberId, String deviceNumber, String title,
+ String content, String contact, InquiryStatus status,
+ String adminMemo, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ this.id = id;
+ this.memberId = memberId;
+ this.deviceNumber = deviceNumber;
+ this.title = title;
+ this.content = content;
+ this.contact = contact;
+ this.status = status;
+ this.adminMemo = adminMemo;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ public static InquiryRes fromEntity(Inquiry inquiry) {
+ return InquiryRes.builder()
+ .id(inquiry.getId())
+ .memberId(inquiry.getMember() != null ? inquiry.getMember().getId() : null)
+ .deviceNumber(inquiry.getDeviceNumber())
+ .title(inquiry.getTitle())
+ .content(inquiry.getContent())
+ .contact(inquiry.getContact())
+ .status(inquiry.getStatus())
+ .adminMemo(inquiry.getAdminMemo())
+ .createdAt(inquiry.getCreatedAt())
+ .updatedAt(inquiry.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/entity/Inquiry.java b/src/main/java/com/application/domain/inquiry/entity/Inquiry.java
new file mode 100644
index 0000000..199240e
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/entity/Inquiry.java
@@ -0,0 +1,70 @@
+package com.application.domain.inquiry.entity;
+
+import com.application.common.time.BaseTimeEntity;
+import com.application.domain.member.entity.Member;
+import jakarta.persistence.*;
+import lombok.*;
+
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Getter
+@Setter
+@NoArgsConstructor
+@Entity(name = "inquiry")
+public class Inquiry extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @Column(name = "device_number")
+ private String deviceNumber;
+
+ @Column(name = "title", nullable = false, length = 200)
+ private String title;
+
+ @Column(name = "content", nullable = false, columnDefinition = "TEXT")
+ private String content;
+
+ @Column(name = "contact")
+ private String contact;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private InquiryStatus status = InquiryStatus.NEW;
+
+ @Column(name = "admin_memo", columnDefinition = "TEXT")
+ private String adminMemo;
+
+ @Builder
+ public Inquiry(Member member, String deviceNumber, String title, String content,
+ String contact, InquiryStatus status, String adminMemo) {
+ this.member = member;
+ this.deviceNumber = deviceNumber;
+ this.title = title;
+ this.content = content;
+ this.contact = contact;
+ this.status = status != null ? status : InquiryStatus.NEW;
+ this.adminMemo = adminMemo;
+ }
+
+ public void markRead() {
+ if (this.status == InquiryStatus.NEW) {
+ this.status = InquiryStatus.READ;
+ }
+ }
+
+ public void updateStatus(InquiryStatus status) {
+ if (status != null) {
+ this.status = status;
+ }
+ }
+
+ public void updateAdminMemo(String memo) {
+ this.adminMemo = memo;
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/entity/InquiryStatus.java b/src/main/java/com/application/domain/inquiry/entity/InquiryStatus.java
new file mode 100644
index 0000000..5ba8936
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/entity/InquiryStatus.java
@@ -0,0 +1,19 @@
+package com.application.domain.inquiry.entity;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+public enum InquiryStatus {
+ NEW,
+ READ,
+ REPLIED;
+
+ public static Optional fromString(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ return Arrays.stream(values())
+ .filter(s -> s.name().equalsIgnoreCase(value.trim()))
+ .findFirst();
+ }
+}
diff --git a/src/main/java/com/application/domain/inquiry/repository/InquiryRepository.java b/src/main/java/com/application/domain/inquiry/repository/InquiryRepository.java
new file mode 100644
index 0000000..7be476d
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/repository/InquiryRepository.java
@@ -0,0 +1,16 @@
+package com.application.domain.inquiry.repository;
+
+import com.application.domain.inquiry.entity.Inquiry;
+import com.application.domain.inquiry.entity.InquiryStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface InquiryRepository extends JpaRepository {
+
+ Page findByMemberId(Long memberId, Pageable pageable);
+
+ Page findAllByStatus(InquiryStatus status, Pageable pageable);
+}
diff --git a/src/main/java/com/application/domain/inquiry/service/InquiryService.java b/src/main/java/com/application/domain/inquiry/service/InquiryService.java
new file mode 100644
index 0000000..7aa75a3
--- /dev/null
+++ b/src/main/java/com/application/domain/inquiry/service/InquiryService.java
@@ -0,0 +1,116 @@
+package com.application.domain.inquiry.service;
+
+import com.application.common.exception.custom.CustomApiException;
+import com.application.domain.inquiry.dto.request.InquiryCreateReq;
+import com.application.domain.inquiry.dto.response.InquiryCreateRes;
+import com.application.domain.inquiry.dto.response.InquiryPageRes;
+import com.application.domain.inquiry.dto.response.InquiryRes;
+import com.application.domain.inquiry.entity.Inquiry;
+import com.application.domain.inquiry.entity.InquiryStatus;
+import com.application.domain.inquiry.repository.InquiryRepository;
+import com.application.domain.member.entity.Member;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class InquiryService {
+
+ private final InquiryRepository inquiryRepository;
+
+ @Transactional
+ public InquiryCreateRes create(InquiryCreateReq req, Member memberOrNull, String deviceHeader) {
+ String deviceNumber = (req.getDeviceNumber() != null && !req.getDeviceNumber().isBlank())
+ ? req.getDeviceNumber()
+ : deviceHeader;
+
+ if (memberOrNull == null && (deviceNumber == null || deviceNumber.isBlank())) {
+ throw new CustomApiException("비로그인 사용자는 device_number가 필요합니다.");
+ }
+
+ Inquiry inquiry = Inquiry.builder()
+ .member(memberOrNull)
+ .deviceNumber(deviceNumber)
+ .title(req.getTitle())
+ .content(req.getContent())
+ .contact(req.getContact())
+ .status(InquiryStatus.NEW)
+ .build();
+
+ Inquiry saved = inquiryRepository.save(inquiry);
+ log.info("[INQUIRY] Created id={}, memberId={}, device={}",
+ saved.getId(),
+ memberOrNull != null ? memberOrNull.getId() : null,
+ deviceNumber);
+
+ return InquiryCreateRes.builder().id(saved.getId()).build();
+ }
+
+ @Transactional(readOnly = true)
+ public InquiryPageRes listMine(Long memberId, Pageable pageable) {
+ Page page = inquiryRepository.findByMemberId(memberId, pageable);
+ List content = page.getContent().stream()
+ .map(InquiryRes::fromEntity)
+ .toList();
+ return InquiryPageRes.fromPage(page, content);
+ }
+
+ @Transactional
+ public InquiryRes getOneForMemberOrAdmin(Long inquiryId, Long requesterMemberId, boolean isAdmin) {
+ Inquiry inquiry = inquiryRepository.findById(inquiryId)
+ .orElseThrow(() -> new CustomApiException("문의를 찾을 수 없습니다."));
+
+ if (!isAdmin) {
+ Long ownerId = inquiry.getMember() != null ? inquiry.getMember().getId() : null;
+ if (ownerId == null || !ownerId.equals(requesterMemberId)) {
+ throw new CustomApiException("접근 권한이 없습니다.");
+ }
+ }
+
+ // Admin/owner 열람 시 NEW → READ 전환
+ if (isAdmin && inquiry.getStatus() == InquiryStatus.NEW) {
+ inquiry.markRead();
+ }
+ return InquiryRes.fromEntity(inquiry);
+ }
+
+ // ------- Admin helpers (Thymeleaf 페이지용) -------
+
+ @Transactional(readOnly = true)
+ public Page adminList(InquiryStatus status, Pageable pageable) {
+ if (status == null) {
+ return inquiryRepository.findAll(pageable);
+ }
+ return inquiryRepository.findAllByStatus(status, pageable);
+ }
+
+ @Transactional(readOnly = true)
+ public Inquiry adminGet(Long id) {
+ return inquiryRepository.findById(id)
+ .orElseThrow(() -> new CustomApiException("문의를 찾을 수 없습니다."));
+ }
+
+ @Transactional
+ public void adminUpdate(Long id, String statusStr, String adminMemo) {
+ Inquiry inquiry = inquiryRepository.findById(id)
+ .orElseThrow(() -> new CustomApiException("문의를 찾을 수 없습니다."));
+
+ if (statusStr != null && !statusStr.isBlank()) {
+ InquiryStatus status = InquiryStatus.fromString(statusStr)
+ .orElseThrow(() -> new CustomApiException("유효하지 않은 상태값입니다: " + statusStr));
+ inquiry.updateStatus(status);
+ }
+
+ inquiry.updateAdminMemo(adminMemo);
+ inquiryRepository.save(inquiry);
+ log.info("[INQUIRY][ADMIN] Updated id={}, status={}, memo_len={}",
+ id, inquiry.getStatus(), adminMemo == null ? 0 : adminMemo.length());
+ }
+}
diff --git a/src/main/java/com/application/tools/imagepipeline/ImagePipelineProperties.java b/src/main/java/com/application/tools/imagepipeline/ImagePipelineProperties.java
new file mode 100644
index 0000000..2a43fb8
--- /dev/null
+++ b/src/main/java/com/application/tools/imagepipeline/ImagePipelineProperties.java
@@ -0,0 +1,49 @@
+package com.application.tools.imagepipeline;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Bound to the {@code aws.s3.*} block in {@code application.yml}.
+ *
+ *
Used by the one-shot image pipeline runner. Note that this is intentionally
+ * separate from the legacy {@link com.application.common.config.S3Config}
+ * (Spring Cloud AWS v1) — the image pipeline talks to S3 directly via AWS SDK v2.
+ *
+ *
If either {@link #accessKey} or {@link #secretKey} is blank, the runner forces
+ * dry-run mode (download only, write variants to local disk).
+ */
+@Configuration
+@ConfigurationProperties(prefix = "aws.s3")
+public class ImagePipelineProperties {
+
+ /** Region that hosts the bucket. Defaults to ap-northeast-2 (Seoul) in yml. */
+ private String region;
+
+ /** Bucket name (default: onz-cocktail-images). */
+ private String bucket;
+
+ /** AWS access key. Blank → dry-run forced. */
+ private String accessKey;
+
+ /** AWS secret key. Blank → dry-run forced. */
+ private String secretKey;
+
+ public String getRegion() { return region; }
+ public void setRegion(String region) { this.region = region; }
+
+ public String getBucket() { return bucket; }
+ public void setBucket(String bucket) { this.bucket = bucket; }
+
+ public String getAccessKey() { return accessKey; }
+ public void setAccessKey(String accessKey) { this.accessKey = accessKey; }
+
+ public String getSecretKey() { return secretKey; }
+ public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
+
+ /** Returns true iff both access-key and secret-key are non-blank. */
+ public boolean hasCredentials() {
+ return accessKey != null && !accessKey.isBlank()
+ && secretKey != null && !secretKey.isBlank();
+ }
+}
diff --git a/src/main/java/com/application/tools/imagepipeline/ImagePipelineRunner.java b/src/main/java/com/application/tools/imagepipeline/ImagePipelineRunner.java
new file mode 100644
index 0000000..eb6bec5
--- /dev/null
+++ b/src/main/java/com/application/tools/imagepipeline/ImagePipelineRunner.java
@@ -0,0 +1,286 @@
+package com.application.tools.imagepipeline;
+
+import com.application.domain.cocktail.entity.Cocktail;
+import com.application.domain.cocktail.repository.CocktailRepository;
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * One-shot CLI runner that converts every {@link Cocktail#getImageUrl()} and
+ * {@link Cocktail#getGlassImageUrl()} PNG into 2 WebP variants (thumb / detail).
+ *
+ *
Activation
+ * Only runs when {@code --image-pipeline} is on the command line. Otherwise the
+ * Spring web server starts normally and this runner is a no-op.
+ *
+ *
Modes
+ *
+ *
dry-run — explicit {@code --dry-run} OR missing AWS creds.
+ * Saves variants to {@code /tmp/onz-image-out/{cocktails|glasses}/...} and
+ * skips the DB update.
+ *
full — uploads to S3 and updates DB columns
+ * (image_url_thumb / image_url_detail / glass_image_url_thumb /
+ * glass_image_url_detail).