diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 0000000..4e0f584 --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,225 @@ +name: CD - Production Deploy + +on: + push: + branches: [production] + workflow_dispatch: + inputs: + confirm: + description: '운영 배포를 진행하시겠습니까?' + required: true + type: boolean + default: false + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: Java 17 설정 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + + - name: Gradle 실행 권한 부여 + run: chmod +x ./gradlew + + - name: 애플리케이션 JAR 빌드 + run: ./gradlew bootJar --no-daemon -x test + + - name: QEMU 설정 (ARM64 크로스 컴파일) + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: 이미지 이름 소문자 변환 + id: image_name + run: | + IMAGE_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "name=${IMAGE_NAME}" >> $GITHUB_OUTPUT + echo "소문자 변환된 이미지 이름: ${IMAGE_NAME}" + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: GitHub Container Registry 로그인 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker 메타데이터 추출 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.image_name.outputs.name }} + tags: | + type=raw,value=production + type=sha,prefix=prod- + + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=eod-prod-build + cache-to: type=gha,scope=eod-prod-build,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 + + - name: 이미지 다이제스트 출력 + run: echo ${{ steps.meta.outputs.digest }} + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: 서버에 SSH 접속 및 Docker Compose 배포 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_SSH_HOST }} + username: ${{ secrets.PROD_SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PROD_SSH_PORT || 22 }} + script: | + # 배포 디렉토리 생성 및 이동 + mkdir -p ~/eod/prod + cd ~/eod/prod + + # GitHub Repository Clone 또는 Pull + if [ -d ".git" ]; then + echo "기존 저장소 업데이트 중..." + git fetch origin production + git reset --hard origin/production + else + echo "저장소 복제 중..." + rm -rf * .* 2>/dev/null || true + git clone -b production https://github.com/${{ github.repository }}.git . + fi + + # .env 파일 생성 (GitHub Secrets 사용) + REPO_LOWERCASE=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + cat > .env << EOF + GITHUB_REPOSITORY=${REPO_LOWERCASE} + DOCKER_IMAGE_TAG=production + SPRING_DATASOURCE_URL=${{ secrets.PROD_DB_URL }} + SPRING_DATASOURCE_USERNAME=${{ secrets.PROD_DB_USERNAME }} + SPRING_DATASOURCE_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} + MYSQL_ROOT_PASSWORD=${{ secrets.PROD_MYSQL_ROOT_PASSWORD }} + MYSQL_DATABASE=${{ secrets.PROD_MYSQL_DATABASE }} + MYSQL_USER=${{ secrets.PROD_DB_USERNAME }} + MYSQL_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + BASE_URL=${{ secrets.PROD_BASE_URL }} + FRONTEND_BASE_URL=${{ secrets.PROD_FRONTEND_BASE_URL }} + LOG_DIR=${{ secrets.PROD_LOG_DIR }} + BSM_CLIENT_ID=${{ secrets.BSM_CLIENT_ID }} + BSM_CLIENT_SECRET=${{ secrets.BSM_CLIENT_SECRET }} + BSM_OAUTH_BASE_URL=${{ secrets.BSM_OAUTH_BASE_URL }} + BSM_REDIRECT_URI=${{ secrets.BSM_REDIRECT_URI }} + FILE_UPLOAD_BASE_URL=${{ secrets.FILE_UPLOAD_BASE_URL }} + EOF + + # GitHub Container Registry 로그인 + echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # 환경 변수 확인 (디버깅) + echo "GITHUB_REPOSITORY (소문자): ${REPO_LOWERCASE}" + + # MySQL 볼륨 존재 여부 확인 및 생성 + echo "=========================================" + echo "📦 MySQL 볼륨 확인 중..." + if ! docker volume inspect eod_mysql-data-prod >/dev/null 2>&1; then + echo "⚠️ 볼륨이 존재하지 않습니다. 새로 생성합니다..." + docker volume create eod_mysql-data-prod + echo "✅ 볼륨 생성 완료: eod_mysql-data-prod" + else + echo "✅ 볼륨이 이미 존재합니다: eod_mysql-data-prod" + fi + echo "=========================================" + + # Docker Compose로 배포 (production 태그 사용) + echo "=========================================" + echo "🚀 PRODUCTION Docker Compose 배포 시작" + echo "=========================================" + export DOCKER_IMAGE_TAG=production + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml down + docker compose -f docker-compose.prod.yml up -d --remove-orphans + + # 배포 직후 상태 확인 + echo "=========================================" + echo "📊 배포 직후 컨테이너 상태:" + docker compose -f docker-compose.prod.yml ps + echo "=========================================" + + # 컨테이너 시작 대기 + echo "컨테이너 시작 대기 중..." + sleep 10 + + # 사용하지 않는 이미지 정리 + docker image prune -af + + - name: 배포 상태 확인 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ~/eod/prod + echo "=========================================" + echo "🚀 PRODUCTION 환경 배포 완료" + echo "=========================================" + echo "📋 환경 변수 확인 (민감 정보 마스킹):" + cat .env | sed 's/PASSWORD=.*/PASSWORD=***/g' | sed 's/SECRET=.*/SECRET=***/g' + echo "=========================================" + echo "🐳 컨테이너 실행 상태:" + docker compose -f docker-compose.prod.yml ps + + # 컨테이너 실행 검증 (app 컨테이너가 반드시 떠있어야 함) + if ! docker compose -f docker-compose.prod.yml ps | grep -q "eod-app.*Up"; then + echo "❌ ERROR: app 컨테이너가 실행되지 않았습니다!" + docker compose -f docker-compose.prod.yml logs --tail=100 app + exit 1 + fi + echo "✅ app 컨테이너 정상 실행 확인" + + echo "=========================================" + echo "📊 Docker 이미지 확인:" + docker images | grep eod || true + echo "=========================================" + echo "📝 MySQL 로그 (최근 20줄):" + docker compose -f docker-compose.prod.yml logs --tail=20 mysql || true + echo "=========================================" + echo "📝 애플리케이션 로그 (최근 50줄):" + docker compose -f docker-compose.prod.yml logs --tail=50 app || true + echo "=========================================" + echo "🔍 포트 사용 확인:" + netstat -tuln | grep -E ':(8020|3307)' || true + + - name: 배포 완료 알림 + if: success() + run: | + IMAGE_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "✅ PRODUCTION Docker Compose 배포 완료!" + echo "서버: ${{ secrets.SSH_HOST }}" + echo "이미지: ${{ env.REGISTRY }}/${IMAGE_NAME}:production" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1eb27e4..d52a78c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -140,10 +140,31 @@ jobs: # 환경 변수 확인 (디버깅) echo "GITHUB_REPOSITORY (소문자): ${REPO_LOWERCASE}" + # MySQL 볼륨 존재 여부 확인 및 생성 + echo "=========================================" + echo "📦 MySQL 볼륨 확인 중..." + if ! docker volume inspect eod_mysql-data-dev >/dev/null 2>&1; then + echo "⚠️ 볼륨이 존재하지 않습니다. 새로 생성합니다..." + docker volume create eod_mysql-data-dev + echo "✅ 볼륨 생성 완료: eod_mysql-data-dev" + else + echo "✅ 볼륨이 이미 존재합니다: eod_mysql-data-dev" + fi + echo "=========================================" + # Docker Compose로 배포 - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml down - docker compose -f docker-compose.prod.yml up -d + echo "=========================================" + echo "🚀 Docker Compose 배포 시작" + echo "=========================================" + docker compose -f docker-compose.yml pull + docker compose -f docker-compose.yml down + docker compose -f docker-compose.yml up -d --remove-orphans + + # 배포 직후 상태 확인 + echo "=========================================" + echo "📊 배포 직후 컨테이너 상태:" + docker compose -f docker-compose.yml ps + echo "=========================================" # 컨테이너 시작 대기 echo "컨테이너 시작 대기 중..." @@ -163,22 +184,31 @@ jobs: cd ~/eod echo "=========================================" echo "📋 환경 변수 확인 (민감 정보 마스킹):" - cat .env | sed 's/PASSWORD=.*/PASSWORD=***/g' + cat .env | sed 's/PASSWORD=.*/PASSWORD=***/g' | sed 's/SECRET=.*/SECRET=***/g' echo "=========================================" echo "🐳 컨테이너 실행 상태:" - docker compose -f docker-compose.prod.yml ps + docker compose -f docker-compose.yml ps + + # 컨테이너 실행 검증 (app 컨테이너가 반드시 떠있어야 함) + if ! docker compose -f docker-compose.yml ps | grep -q "eod-app-dev.*Up"; then + echo "❌ ERROR: app 컨테이너가 실행되지 않았습니다!" + docker compose -f docker-compose.yml logs --tail=100 app + exit 1 + fi + echo "✅ app 컨테이너 정상 실행 확인" + echo "=========================================" echo "📊 Docker 이미지 확인:" - docker images | grep eod + docker images | grep eod || true echo "=========================================" echo "📝 MySQL 로그 (최근 20줄):" - docker compose -f docker-compose.prod.yml logs --tail=20 mysql + docker compose -f docker-compose.yml logs --tail=20 mysql || true echo "=========================================" echo "📝 애플리케이션 로그 (최근 50줄):" - docker compose -f docker-compose.prod.yml logs --tail=50 app + docker compose -f docker-compose.yml logs --tail=50 app || true echo "=========================================" echo "🔍 포트 사용 확인:" - netstat -tuln | grep -E ':(8000|3306)' + netstat -tuln | grep -E ':(8000|3306)' || true - name: 배포 완료 알림 if: success() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe3f8e..33a35ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main", "master", "develop" ] + branches: [ "main", "master", "develop", "production" ] pull_request: - branches: [ "main", "master", "develop" ] + branches: [ "main", "master", "develop", "production" ] jobs: build: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fea1ba3..fb5932a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,17 +3,15 @@ services: image: ghcr.io/${GITHUB_REPOSITORY}:latest container_name: eod-app volumes: - - /eod/logs:/logs + - /eod/prod/logs:/logs - /eod/uploads:/eod/uploads restart: always ports: - - "8000:8080" + - "8020:8080" environment: - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - JWT_SECRET=${JWT_SECRET} - BASE_URL=${BASE_URL} - FRONTEND_BASE_URL=${FRONTEND_BASE_URL} @@ -40,9 +38,9 @@ services: MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD} ports: - - "3306:3306" + - "3307:3306" volumes: - - mysql-data:/var/lib/mysql + - mysql-data-prod:/var/lib/mysql networks: - eod-network healthcheck: @@ -52,9 +50,9 @@ services: retries: 10 volumes: - mysql-data: + mysql-data-prod: external: true - name: eod_mysql-data + name: eod_mysql-data-prod networks: eod-network: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5cdba1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + app: + image: ghcr.io/${GITHUB_REPOSITORY}:latest + container_name: eod-app-dev + volumes: + - /eod/logs:/logs + - /eod/uploads:/eod/uploads + restart: always + ports: + - "8000:8080" + environment: + - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - JWT_SECRET=${JWT_SECRET} + - BASE_URL=${BASE_URL} + - FRONTEND_BASE_URL=${FRONTEND_BASE_URL} + - LOG_DIR=${LOG_DIR} + - BSM_CLIENT_ID=${BSM_CLIENT_ID} + - BSM_CLIENT_SECRET=${BSM_CLIENT_SECRET} + - BSM_OAUTH_BASE_URL=${BSM_OAUTH_BASE_URL} + - BSM_REDIRECT_URI=${BSM_REDIRECT_URI} + - FILE_UPLOAD_DIR=/eod/uploads + - FILE_UPLOAD_BASE_URL=${FILE_UPLOAD_BASE_URL} + depends_on: + mysql: + condition: service_healthy + networks: + - eod-network-dev + + mysql: + image: mysql:8.0 + container_name: eod-mysql-dev + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + ports: + - "3306:3306" + volumes: + - mysql-data-dev:/var/lib/mysql + networks: + - eod-network-dev + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + mysql-data-dev: + external: true + name: eod_mysql-data-dev + +networks: + eod-network-dev: + driver: bridge diff --git a/src/main/java/com/eod/eod/domain/item/application/ItemRegistrationService.java b/src/main/java/com/eod/eod/domain/item/application/ItemRegistrationService.java index 6f514a6..4ab5d5b 100644 --- a/src/main/java/com/eod/eod/domain/item/application/ItemRegistrationService.java +++ b/src/main/java/com/eod/eod/domain/item/application/ItemRegistrationService.java @@ -54,9 +54,23 @@ private LocalDateTime validateAndParseFoundAt(String foundAt, Long placeId) { } /** - * 학생 코드와 이름으로 학생 조회 + * 학생 코드와 이름으로 학생 조회 (선택적) + * + * @param reporterStudentCode 신고자 학생 코드 (null 가능) + * @param reporterName 신고자 이름 (null 가능) + * @return 학생 정보 또는 null (둘 다 null인 경우) */ private User findStudentByCodeAndName(Integer reporterStudentCode, String reporterName) { + // 둘 다 null이면 신고자 정보 없음 + if (reporterStudentCode == null && (reporterName == null || reporterName.isBlank())) { + return null; + } + + // 둘 중 하나만 있으면 에러 + if (reporterStudentCode == null || reporterName == null || reporterName.isBlank()) { + throw new IllegalArgumentException("신고자 학생 코드와 이름은 함께 입력해야 합니다."); + } + int grade = reporterStudentCode / 1000; int classNo = (reporterStudentCode / 100) % 10; int studentNo = reporterStudentCode % 100; diff --git a/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemRegistrationForm.java b/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemRegistrationForm.java index dbb6fbe..57bfbb0 100644 --- a/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemRegistrationForm.java +++ b/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemRegistrationForm.java @@ -6,8 +6,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.validator.constraints.Length; -import org.springframework.web.multipart.MultipartFile; @Getter @Setter @@ -19,15 +17,13 @@ public class ItemRegistrationForm { @Schema(description = "물품 이름", example = "아이패드", requiredMode = Schema.RequiredMode.REQUIRED) private String name; - @NotNull(message = "필수 항목이 누락되었습니다.") @Min(value = 1101, message = "신고자 학생 코드는 1101 이상이어야 합니다.") @Max(value = 3417, message = "신고자 학생 코드는 3417 이하여야 합니다.") - @Schema(description = "신고자 학생 코드", example = "2109", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "신고자 학생 코드 (선택)", example = "2109", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Integer reporterStudentCode; - @NotBlank(message = "필수 항목이 누락되었습니다.") - @Size(max = 50, message = "신고자 이름은 최대 50자까지 입력 가능합니다.") - @Schema(description = "신고자 이름", example = "홍길동", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 50, message = "신고자 이름은 최대 10자까지 입력 가능합니다.") + @Schema(description = "신고자 이름 (선택)", example = "이하은", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String reporterName; @NotBlank(message = "필수 항목이 누락되었습니다.") diff --git a/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemUpdateForm.java b/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemUpdateForm.java index e980140..071f585 100644 --- a/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemUpdateForm.java +++ b/src/main/java/com/eod/eod/domain/item/presentation/dto/request/ItemUpdateForm.java @@ -17,15 +17,13 @@ public class ItemUpdateForm { @Schema(description = "물품 이름", example = "아이패드", requiredMode = Schema.RequiredMode.REQUIRED) private String name; - @NotNull(message = "필수 항목이 누락되었습니다.") @Min(value = 1101, message = "신고자 학생 코드는 1101 이상이어야 합니다.") @Max(value = 3417, message = "신고자 학생 코드는 3417 이하여야 합니다.") - @Schema(description = "신고자 학생 코드", example = "2109", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "신고자 학생 코드 (선택)", example = "2109", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Integer reporterStudentCode; - @NotBlank(message = "필수 항목이 누락되었습니다.") @Size(max = 50, message = "신고자 이름은 최대 50자까지 입력 가능합니다.") - @Schema(description = "신고자 이름", example = "홍길동", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "신고자 이름 (선택)", example = "홍길동", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String reporterName; @NotBlank(message = "필수 항목이 누락되었습니다.")