Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: CD

# Triggers:
# - push to `dev` → automatic deploy after PR merge
# - workflow_dispatch → manual trigger (used for `main` until prod cutover wired)
#
# Image registry: GitHub Container Registry (GHCR)
# Repo: ghcr.io/mobileonz/cocktail-api
# Tags: :latest AND :<git-sha>
#
# EC2 pulls the image rather than rebuilding from source.
on:
push:
branches: [ dev ]
workflow_dispatch:
inputs:
ref:
description: 'Branch / tag / SHA to deploy (e.g. main)'
required: true
default: 'main'

permissions:
contents: read
packages: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: mobileonz/cocktail-api

jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew

# Tests already ran in CI; skip here for faster deploy.
- name: Build bootJar
run: ./gradlew clean bootJar -x test

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image tags
id: meta
run: |
echo "image_tag=${{ github.sha }}" >> "$GITHUB_OUTPUT"

- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@v1.0.3
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: GHCR_TOKEN,GHCR_USER
script: |
set -e
cd /root/cocktail-api

# Auth to GHCR (image is private by default)
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin

# Pick docker compose binary (v2 plugin preferred, fall back to v1)
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
else
DC="docker-compose"
fi

$DC pull api-server
$DC up -d api-server

# Reap dangling images/containers
docker system prune -f

docker logout ghcr.io || true
70 changes: 45 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,36 +1,56 @@
name: CI

# Runs on:
# - PRs targeting dev or main (gate before merge)
# - Pushes to feature branches (developer convenience while iterating)
# Does NOT run on push to dev/main directly — that's CD's job.
on:
push:
branches: [ dev, main ]
pull_request:
branches: [ dev, main ]
types: [ opened, synchronize, reopened ]
push:
branches-ignore:
- dev
- main

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build with Gradle
run: ./gradlew clean build -x test

- name: Upload build artifacts
if: success()
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: build/libs/*.jar
retention-days: 7
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew

# TODO: 기존 테스트들이 application.yml placeholder 미해결 (PostgreSQL/JWT/OAuth 환경변수)
# 로 인해 Spring 컨텍스트 로딩 실패함. application-test.yml + @ActiveProfiles("test") 셋업
# 후 -x test 제거 예정. 추적 이슈: 테스트 인프라 정비.
- name: Build with Gradle (tests skipped — see TODO)
run: ./gradlew clean build -x test

- name: Upload build artifacts
if: success()
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: build/libs/*.jar
retention-days: 7

- name: Upload test reports on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
build/reports/tests/
build/test-results/
retention-days: 7
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ application-local.properties
application-*.properties

### Editor ###
.claude
.claude
# 로컬 이미지 업로드 (S3 모드 아닐 때) — 절대 commit 금지
uploads/
!uploads/.gitkeep

6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
36 changes: 34 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
version: '3.8'

# ─────────────────────────────────────────────────────────────────────────────
# Image source
#
# Production (EC2)
# docker compose pull api-server # pulls ghcr.io/mobileonz/cocktail-api:latest
# docker compose up -d api-server # starts the pulled image
#
# Local dev (rebuild from source)
# docker compose up --build api-server
# └ `build: .` is honored only when --build is passed (or image is missing locally).
# `docker compose pull` ignores services with a `build:` block IF no `image:` is set,
# so we keep BOTH: `image:` is the registry coordinate, `build:` is the local Dockerfile.
#
# Tag override (deploy a specific commit)
# COCKTAIL_API_TAG=<sha> docker compose up -d api-server
# ─────────────────────────────────────────────────────────────────────────────

services:
# 1. API 서버 (Spring Boot) 서비스
api-server:
build: . # 현재 디렉토리의 Dockerfile을 사용해 이미지를 빌드
image: ghcr.io/mobileonz/cocktail-api:${COCKTAIL_API_TAG:-latest}
build: . # 로컬 개발 시 --build 플래그로만 사용됨
container_name: cocktail-api
ports:
- "80:8080" # 외부 80 -> 내부 8080
Expand All @@ -17,6 +35,16 @@ services:
# --- JWT 설정 ---
- JWT_SECRET=${JWT_SECRET}

# --- Admin 계정 (운영에선 .env 로 강한 값 덮어쓸 것) ---
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin1!}

# --- AWS S3 (이미지 저장소) ---
- AWS_S3_REGION=${AWS_S3_REGION:-ap-northeast-2}
- AWS_S3_ACCESS_KEY=${AWS_S3_ACCESS_KEY:-}
- AWS_S3_SECRET_KEY=${AWS_S3_SECRET_KEY:-}
- S3_BUCKET=${S3_BUCKET:-onz-cocktail-images}

# --- OAuth 설정 ---
- KAKAO_ID=${KAKAO_ID}
- KAKAO_SECRET=${KAKAO_SECRET}
Expand All @@ -37,6 +65,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' 서비스가 먼저 실행된 후에 실행됨

Expand All @@ -45,7 +77,7 @@ services:
image: postgres
container_name: postgres-cocktail
ports:
- "5432:5432"
- "5433:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.application.common.auth.JWTAccessTokenBlackListService;
import com.application.common.auth.jwt.JWTFilter;
import com.application.common.auth.jwt.JWTUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
Expand Down Expand Up @@ -87,6 +88,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()
Expand All @@ -104,9 +106,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
}


/**
* Admin 계정 자격증명을 환경변수에서 읽어옴.
* - ADMIN_USERNAME (default: "admin")
* - ADMIN_PASSWORD (default: "admin1!" — 운영에선 반드시 .env 로 덮어쓸 것)
* 운영 EC2 의 .env 에서 덮어쓰면 컨테이너 재시작 시 즉시 반영.
*/
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
var admin = User.withUsername("admin").password(passwordEncoder.encode("admin1!")).roles("ADMIN").build();
public UserDetailsService userDetailsService(
PasswordEncoder passwordEncoder,
@Value("${admin.username:admin}") String adminUsername,
@Value("${admin.password:admin1!}") String adminPassword
) {
var admin = User.withUsername(adminUsername)
.password(passwordEncoder.encode(adminPassword))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}

Expand Down
14 changes: 12 additions & 2 deletions src/main/java/com/application/common/auth/jwt/JWTFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 토큰이 있으면 검증하고, 없으면 익명 사용자로 통과)
Expand All @@ -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 예외 필터 적용 - 주석 처리]
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/com/application/common/storage/ImageStorage.java
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading