diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ffa1f7..8b4d500f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,4 +57,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew test sonar --info --stacktrace + run: | + # 포크에서 온 PR: PR 연동 시 원본 레포에 PR이 없어 실패하므로 브랜치 분석만 수행 + # 같은 레포 PR: PR 데코레이션 사용 + if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then + ./gradlew test sonar --info --stacktrace \ + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} \ + -Dsonar.pullrequest.branch=${{ github.head_ref }} \ + -Dsonar.pullrequest.base=${{ github.base_ref }} + else + ./gradlew test sonar --info --stacktrace \ + -Dsonar.branch.name=pr-${{ github.event.pull_request.number }} + fi diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 60c18c19..83682fc8 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -33,6 +33,14 @@ jobs: - name: Grant gradlew permission run: chmod +x ./gradlew + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + - name: Log in to DockerHub uses: docker/login-action@v2 with: @@ -42,48 +50,56 @@ jobs: - name: Build & Push App Image run: | docker buildx build \ - --platform linux/amd64 \ + --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:dev-app \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2026:dev-app \ . - - name: Copy dev-docker-compose.yml + - name: Copy dev-compose uses: appleboy/scp-action@v0.1.3 with: username: ubuntu - host: ${{ secrets.DEV_EC2_HOST }} - key: ${{ secrets.DEV_EC2_SSH_KEY }} - source: ./clokey-api/dev-compose.yml + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + source: "clokey-api/dev-compose.yml" target: /home/ubuntu/ + - name: Set OCI private key env (multiline) + run: | + echo "OCI_PRIVATE_KEY<> $GITHUB_ENV + echo "${{ secrets.OCI_PRIVATE_KEY }}" >> $GITHUB_ENV + echo "ENDOFKEY" >> $GITHUB_ENV + + - name: Prepare OCI key base64 for Deploy script + run: echo "OCI_PRIVATE_KEY_B64=$(echo -n "$OCI_PRIVATE_KEY" | base64 -w 0)" >> $GITHUB_ENV + - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu - host: ${{ secrets.DEV_EC2_HOST }} - key: ${{ secrets.DEV_EC2_SSH_KEY }} + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + envs: DOCKERHUB_USERNAME,CODIVE_DEV_BASE_URL,CODIVE_PROD_BASE_URL,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | - export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app + + export CODIVE_DEV_BASE_URL=${{ secrets.CODIVE_DEV_BASE_URL }} + export CODIVE_PROD_BASE_URL=${{ secrets.CODIVE_PROD_BASE_URL }} - export DEV_MYSQL_HOST=${{ secrets.DEV_MYSQL_HOST }} - export MYSQL_PORT=${{ secrets.MYSQL_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} + export OCI_DEV_MYSQL_DB_NAME=${{ secrets.OCI_DEV_MYSQL_DB_NAME }} + export OCI_MYSQL_USERNAME=${{ secrets.OCI_MYSQL_USERNAME }} + export OCI_MYSQL_PASSWORD=${{ secrets.OCI_MYSQL_PASSWORD }} - export REDIS_HOST=${{ secrets.REDIS_HOST }} - export REDIS_PORT=${{ secrets.REDIS_PORT }} export REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} export KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} - export KAKAO_CLIENT_SECRET=${{ secrets.DEV_KAKAO_CLIENT_SECRET }} - export KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }} + export KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} export APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} export APPLE_CLIENT_SECRET=${{ secrets.APPLE_CLIENT_SECRET }} - export APPLE_REDIRECT_URI=${{ secrets.APPLE_REDIRECT_URI }} export JWT_ACCESS_TOKEN_SECRET=${{ secrets.JWT_ACCESS_TOKEN_SECRET }} export JWT_REFRESH_TOKEN_SECRET=${{ secrets.JWT_REFRESH_TOKEN_SECRET }} @@ -91,11 +107,13 @@ jobs: export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export DEV_AWS_ACCESS_KEY_ID=${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - export DEV_AWS_SECRET_ACCESS_KEY=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - export AWS_REGION=${{ secrets.AWS_REGION }} - export DEV_S3_BUCKET=${{ secrets.DEV_S3_BUCKET }} - export DEV_S3_ENDPOINT=${{ secrets.DEV_S3_ENDPOINT }} + export OCI_TENANCY_ID=${{ secrets.OCI_TENANCY_ID }} + export OCI_USER_ID=${{ secrets.OCI_USER_ID }} + export OCI_FINGERPRINT=${{ secrets.OCI_FINGERPRINT }} + export OCI_REGION=${{ secrets.OCI_REGION }} + export OCI_PASSPHRASE=${{ secrets.OCI_PASSPHRASE }} + export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.OCI_OBJECTSTORAGE_NAMESPACE }} + export OCI_DEV_OBJECTSTORAGE_BUCKET=${{ secrets.OCI_DEV_OBJECTSTORAGE_BUCKET }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} @@ -106,21 +124,24 @@ jobs: export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} - export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} export DEFAULT_PROFILE_IMAGE_URL=${{ secrets.DEFAULT_PROFILE_IMAGE_URL }} export TODAY_TEMPERATURE_IMAGE_URL=${{ secrets.TODAY_TEMPERATURE_IMAGE_URL }} - sudo mkdir -p /home/ubuntu/secrets - echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null - sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + chmod 600 /home/ubuntu/secrets/firebase-sa.json + + echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | tee /home/ubuntu/secrets/oci_key.pem > /dev/null + chmod 600 /home/ubuntu/secrets/oci_key.pem + export OCI_PRIVATE_KEY="$(cat /home/ubuntu/secrets/oci_key.pem)" export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/dev-compose.yml up -d - + docker compose -p clokey-dev -f clokey-api/dev-compose.yml up -d + echo "Cleaning up dangling Docker images..." - docker image prune -f - + docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 20b85f26..72a76f02 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -5,7 +5,7 @@ on: branches: [ main ] jobs: - dev-cd: + prod-cd: runs-on: ubuntu-latest permissions: contents: read @@ -33,6 +33,9 @@ jobs: - name: Grant gradlew permission run: chmod +x ./gradlew + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: @@ -52,60 +55,71 @@ jobs: - name: Build & Push App Image run: | docker buildx build \ - --platform linux/amd64 \ + --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:prod-app \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2026:prod-app \ . - - name: Copy prod-docker-compose.yml + - name: Copy prod-compose uses: appleboy/scp-action@v0.1.3 with: username: ubuntu - host: ${{ secrets.PROD_EC2_HOST }} - key: ${{ secrets.PROD_EC2_SSH_KEY }} - source: ./clokey-api/prod-compose.yml + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + source: "clokey-api/prod-compose.yml" target: /home/ubuntu/ + - name: Set OCI private key env (multiline) + run: | + echo "OCI_PRIVATE_KEY<> $GITHUB_ENV + echo "${{ secrets.OCI_PRIVATE_KEY }}" >> $GITHUB_ENV + echo "ENDOFKEY" >> $GITHUB_ENV + + - name: Prepare OCI key base64 for Deploy script + run: echo "OCI_PRIVATE_KEY_B64=$(echo -n "$OCI_PRIVATE_KEY" | base64 -w 0)" >> $GITHUB_ENV + - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu - host: ${{ secrets.PROD_EC2_HOST }} - key: ${{ secrets.PROD_EC2_SSH_KEY }} + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + envs: DOCKERHUB_USERNAME,CODIVE_DEV_BASE_URL,CODIVE_PROD_BASE_URL,OCI_MYSQL_HEATWAVE_HOST,OCI_PROD_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_PROD_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | - export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=prod-app - export PROD_MYSQL_HOST=${{ secrets.PROD_MYSQL_HOST }} - export MYSQL_PORT=${{ secrets.MYSQL_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export CODIVE_DEV_BASE_URL=${{ secrets.CODIVE_DEV_BASE_URL }} + export CODIVE_PROD_BASE_URL=${{ secrets.CODIVE_PROD_BASE_URL }} + + export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} + export OCI_PROD_MYSQL_DB_NAME=${{ secrets.OCI_PROD_MYSQL_DB_NAME }} + export OCI_MYSQL_USERNAME=${{ secrets.OCI_MYSQL_USERNAME }} + export OCI_MYSQL_PASSWORD=${{ secrets.OCI_MYSQL_PASSWORD }} - export REDIS_HOST=${{ secrets.REDIS_HOST }} - export REDIS_PORT=${{ secrets.REDIS_PORT }} export REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} export KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} export KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} - export KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }} export APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} export APPLE_CLIENT_SECRET=${{ secrets.APPLE_CLIENT_SECRET }} - export APPLE_REDIRECT_URI=${{ secrets.APPLE_REDIRECT_URI }} - + export JWT_ACCESS_TOKEN_SECRET=${{ secrets.JWT_ACCESS_TOKEN_SECRET }} export JWT_REFRESH_TOKEN_SECRET=${{ secrets.JWT_REFRESH_TOKEN_SECRET }} export JWT_ACCESS_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_ACCESS_TOKEN_EXPIRATION_TIME }} export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export PROD_AWS_ACCESS_KEY_ID=${{ secrets.PROD_AWS_ACCESS_KEY_ID }} - export PROD_AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} - export AWS_REGION=${{ secrets.AWS_REGION }} - export PROD_S3_BUCKET=${{ secrets.PROD_S3_BUCKET }} - export PROD_S3_ENDPOINT=${{ secrets.PROD_S3_ENDPOINT }} + export OCI_TENANCY_ID=${{ secrets.OCI_TENANCY_ID }} + export OCI_USER_ID=${{ secrets.OCI_USER_ID }} + export OCI_FINGERPRINT=${{ secrets.OCI_FINGERPRINT }} + export OCI_PRIVATE_KEY=${{ secrets.OCI_PRIVATE_KEY }} + export OCI_REGION=${{ secrets.OCI_REGION }} + export OCI_PASSPHRASE=${{ secrets.OCI_PASSPHRASE }} + export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.OCI_OBJECTSTORAGE_NAMESPACE }} + export OCI_PROD_OBJECTSTORAGE_BUCKET=${{ secrets.OCI_PROD_OBJECTSTORAGE_BUCKET }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} @@ -116,21 +130,24 @@ jobs: export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} - export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} export DEFAULT_PROFILE_IMAGE_URL=${{ secrets.DEFAULT_PROFILE_IMAGE_URL }} export TODAY_TEMPERATURE_IMAGE_URL=${{ secrets.TODAY_TEMPERATURE_IMAGE_URL }} - sudo mkdir -p /home/ubuntu/secrets - echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null - sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + chmod 600 /home/ubuntu/secrets/firebase-sa.json + + echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | tee /home/ubuntu/secrets/oci_key.pem > /dev/null + chmod 600 /home/ubuntu/secrets/oci_key.pem + export OCI_PRIVATE_KEY="$(cat /home/ubuntu/secrets/oci_key.pem)" export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/prod-compose.yml up -d + docker compose -p clokey-prod -f clokey-api/prod-compose.yml up -d echo "Cleaning up dangling Docker images..." - docker image prune -f - + docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-openapi.yml b/.github/workflows/prod-openapi.yml index 2ab646ee..d0938c28 100644 --- a/.github/workflows/prod-openapi.yml +++ b/.github/workflows/prod-openapi.yml @@ -20,6 +20,7 @@ jobs: env: CODIVEAPI_BRANCH: main + PROD_OPENAPI_URL: "${{ secrets.CODIVE_PROD_BASE_URL }}/v3/api-docs" TARGET_PATH: Sources/CodiveAPI/openapi.yaml SWAGGER_USERNAME: ${{ secrets.SWAGGER_USERNAME }} SWAGGER_PASSWORD: ${{ secrets.SWAGGER_PASSWORD }} @@ -31,8 +32,6 @@ jobs: fetch-depth: 1 - name: Wait for production server to be ready - env: - PROD_OPENAPI_URL: ${{ secrets.PROD_OPENAPI_URL }} run: | set -euo pipefail echo "Waiting for production OpenAPI to be ready..." @@ -53,8 +52,6 @@ jobs: - name: Fetch OpenAPI JSON from production - env: - PROD_OPENAPI_URL: ${{ secrets.PROD_OPENAPI_URL }} run: | set -euo pipefail echo "Fetching OpenAPI from: $PROD_OPENAPI_URL" diff --git a/build.gradle b/build.gradle index c3efbc96..0310c302 100644 --- a/build.gradle +++ b/build.gradle @@ -128,6 +128,7 @@ tasks.register('updateGitHooks', Copy) { } tasks.register('makeGitHooksExecutable', Exec) { + onlyIf { !System.getProperty('os.name').toLowerCase(java.util.Locale.ROOT).contains('windows') } commandLine 'chmod', '+x', './.git/hooks/pre-commit' dependsOn updateGitHooks } diff --git a/clokey-api/build.gradle b/clokey-api/build.gradle index 24343d8c..01739638 100644 --- a/clokey-api/build.gradle +++ b/clokey-api/build.gradle @@ -21,4 +21,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.vanslog:spring-data-meilisearch:0.7.3' + + testImplementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0' } diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 220f6bac..8572cd46 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -1,37 +1,57 @@ +name: clokey-dev + version: "3.8" services: + redis-dev: + image: redis:7-alpine + container_name: clokey-redis-dev + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] + ports: + - "6379:6379" + volumes: + - redis_dev_data:/data + networks: + - dev_app_network + app: - image: ${DOCKERHUB_NAME}/clokey-docker-2025:${DOCKER_TAG} - container_name: clokey-app + image: ${DOCKERHUB_USERNAME}/clokey-docker-2026:${DOCKER_TAG} + container_name: clokey-app-dev pull_policy: always + depends_on: + - redis-dev ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: dev TZ: Asia/Seoul - # Database - DEV_MYSQL_HOST: ${DEV_MYSQL_HOST} - MYSQL_PORT: ${MYSQL_PORT} - DB_NAME: ${DB_NAME} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + # Domain + CODIVE_DEV_BASE_URL: ${CODIVE_DEV_BASE_URL} + CODIVE_PROD_BASE_URL: ${CODIVE_PROD_BASE_URL} + + # Database (MySQL HeatWave) + OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} + MYSQL_PORT: 3306 + OCI_DEV_MYSQL_DB_NAME: ${OCI_DEV_MYSQL_DB_NAME} + OCI_MYSQL_USERNAME: ${OCI_MYSQL_USERNAME} + OCI_MYSQL_PASSWORD: ${OCI_MYSQL_PASSWORD} # Redis - REDIS_HOST: ${REDIS_HOST} - REDIS_PORT: ${REDIS_PORT} + REDIS_HOST: redis-dev + REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} # Kakao OIDC KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} - KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} # Apple OIDC APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} - APPLE_REDIRECT_URI: ${APPLE_REDIRECT_URI} # Firebase FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json @@ -45,33 +65,40 @@ services: JWT_REFRESH_TOKEN_EXPIRATION_TIME: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} JWT_ISSUER: ${JWT_ISSUER} - # AWS S3 - DEV_AWS_ACCESS_KEY_ID: ${DEV_AWS_ACCESS_KEY_ID} - DEV_AWS_SECRET_ACCESS_KEY: ${DEV_AWS_SECRET_ACCESS_KEY} - AWS_REGION: ${AWS_REGION} - DEV_S3_BUCKET: ${DEV_S3_BUCKET} - DEV_S3_ENDPOINT: ${DEV_S3_ENDPOINT} + # OCI Object Storage + OCI_TENANCY_ID: ${OCI_TENANCY_ID} + OCI_USER_ID: ${OCI_USER_ID} + OCI_FINGERPRINT: ${OCI_FINGERPRINT} + OCI_PRIVATE_KEY: ${OCI_PRIVATE_KEY} + OCI_REGION: ${OCI_REGION} + OCI_PASSPHRASE: ${OCI_PASSPHRASE} + OCI_OBJECTSTORAGE_NAMESPACE: ${OCI_OBJECTSTORAGE_NAMESPACE} + OCI_DEV_OBJECTSTORAGE_BUCKET: ${OCI_DEV_OBJECTSTORAGE_BUCKET} # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} + # MeiliSearch + MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} + MEILISEARCH_KEY: ${MEILISEARCH_KEY} + + # External API (AI) AI_SERVER_IP: ${AI_SERVER_IP} CLOTH_INFERENCE_PATH: ${CLOTH_INFERENCE_PATH} STYLE_INFERENCE_PATH: ${STYLE_INFERENCE_PATH} - CLOTH_DETECT_PATH: ${CLOTH_DETECT_PATH} - - # MeilieSearch - MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} - MEILISEARCH_KEY: ${MEILISEARCH_KEY} + HISTORY_CLOTH_DETECT_PATH: ${HISTORY_CLOTH_DETECT_PATH} volumes: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro networks: - - app_network + - dev_app_network networks: - app_network: - name: app_network + dev_app_network: + name: dev_app_network driver: bridge + +volumes: + redis_dev_data: diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index 9d397826..59340895 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -1,37 +1,57 @@ +name: clokey-prod + version: "3.8" services: + redis-prod: + image: redis:7-alpine + container_name: clokey-redis-prod + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] + ports: + - "6380:6379" + volumes: + - redis_prod_data:/data + networks: + - prod_app_network + app: - image: ${DOCKERHUB_NAME}/clokey-docker-2025:${DOCKER_TAG} - container_name: clokey-app + image: ${DOCKERHUB_USERNAME}/clokey-docker-2026:${DOCKER_TAG} + container_name: clokey-app-prod pull_policy: always + depends_on: + - redis-prod ports: - - "8080:8080" + - "8081:8080" environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Seoul - # Database - PROD_MYSQL_HOST: ${PROD_MYSQL_HOST} - MYSQL_PORT: ${MYSQL_PORT} - DB_NAME: ${DB_NAME} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + # Domain + CODIVE_DEV_BASE_URL: ${CODIVE_DEV_BASE_URL} + CODIVE_PROD_BASE_URL: ${CODIVE_PROD_BASE_URL} + + # Database (MySQL HeatWave) + OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} + MYSQL_PORT: 3306 + OCI_PROD_MYSQL_DB_NAME: ${OCI_PROD_MYSQL_DB_NAME} + OCI_MYSQL_USERNAME: ${OCI_MYSQL_USERNAME} + OCI_MYSQL_PASSWORD: ${OCI_MYSQL_PASSWORD} # Redis - REDIS_HOST: ${REDIS_HOST} - REDIS_PORT: ${REDIS_PORT} + REDIS_HOST: redis-prod + REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} # Kakao OIDC KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} - KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} # Apple OIDC APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} - APPLE_REDIRECT_URI: ${APPLE_REDIRECT_URI} # Firebase FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json @@ -45,33 +65,40 @@ services: JWT_REFRESH_TOKEN_EXPIRATION_TIME: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} JWT_ISSUER: ${JWT_ISSUER} - # AWS S3 - PROD_AWS_ACCESS_KEY_ID: ${PROD_AWS_ACCESS_KEY_ID} - PROD_AWS_SECRET_ACCESS_KEY: ${PROD_AWS_SECRET_ACCESS_KEY} - AWS_REGION: ${AWS_REGION} - PROD_S3_BUCKET: ${PROD_S3_BUCKET} - PROD_S3_ENDPOINT: ${PROD_S3_ENDPOINT} + # OCI Object Storage + OCI_TENANCY_ID: ${OCI_TENANCY_ID} + OCI_USER_ID: ${OCI_USER_ID} + OCI_FINGERPRINT: ${OCI_FINGERPRINT} + OCI_PRIVATE_KEY: ${OCI_PRIVATE_KEY} + OCI_REGION: ${OCI_REGION} + OCI_PASSPHRASE: ${OCI_PASSPHRASE} + OCI_OBJECTSTORAGE_NAMESPACE: ${OCI_OBJECTSTORAGE_NAMESPACE} + OCI_PROD_OBJECTSTORAGE_BUCKET: ${OCI_PROD_OBJECTSTORAGE_BUCKET} # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} + # MeiliSearch + MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} + MEILISEARCH_KEY: ${MEILISEARCH_KEY} + + # External API (AI) AI_SERVER_IP: ${AI_SERVER_IP} CLOTH_INFERENCE_PATH: ${CLOTH_INFERENCE_PATH} STYLE_INFERENCE_PATH: ${STYLE_INFERENCE_PATH} - CLOTH_DETECT_PATH: ${CLOTH_DETECT_PATH} - - # MeilieSearch - MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} - MEILISEARCH_KEY: ${MEILISEARCH_KEY} + HISTORY_CLOTH_DETECT_PATH: ${HISTORY_CLOTH_DETECT_PATH} volumes: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro networks: - - app_network + - prod_app_network networks: - app_network: - name: app_network + prod_app_network: + name: prod_app_network driver: bridge + +volumes: + redis_prod_data: diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java index 95ae7ad9..16cdf286 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java @@ -4,8 +4,9 @@ import java.util.List; public record ClothImagesPresignedUrlResponse( - @Schema(description = "생성된 presigned url 리스트") List urls) { - public static ClothImagesPresignedUrlResponse of(List urls) { - return new ClothImagesPresignedUrlResponse(urls); + @Schema(description = "업로드용 presigned url 리스트") List urls, + @Schema(description = "저장/조회용 공개 객체 url 리스트") List objectUrls) { + public static ClothImagesPresignedUrlResponse of(List urls, List objectUrls) { + return new ClothImagesPresignedUrlResponse(urls, objectUrls); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java index f8f52580..e6221aa3 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -36,7 +36,8 @@ import org.clokey.global.util.MemberUtil; import org.clokey.member.entity.Member; import org.clokey.properties.WebClientProperties; -import org.clokey.util.S3Util; +import org.clokey.util.PresignedUrlResult; +import org.clokey.util.StorageUtil; import org.clokey.util.WebClientUtil; import org.springframework.stereotype.Service; @@ -52,7 +53,7 @@ public class ClothAiServiceImpl implements ClothAiService { private final MemberUtil memberUtil; private final CategoryRepository categoryRepository; - private final S3Util s3Util; + private final StorageUtil storageUtil; private final WebClientUtil webClientUtil; private final WebClientProperties webClientProperties; @@ -61,19 +62,19 @@ public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( ClothImagesUploadRequest request) { final Member currentMember = memberUtil.getCurrentMember(); - // 중요 : md5 해시로 변조 확인을 하기 때문에 들어온 순서대로 반환해야함!! - List presignedUrls = + List presignedUrlResults = request.payloads().stream() .map( req -> - s3Util.createPresignedUrl( + storageUtil.createPresignedUrl( ImageType.CLOTH_IMAGE, currentMember.getId(), - req.fileExtension(), - req.md5Hashes())) + req.fileExtension())) .toList(); - return ClothImagesPresignedUrlResponse.of(presignedUrls); + return ClothImagesPresignedUrlResponse.of( + presignedUrlResults.stream().map(PresignedUrlResult::uploadUrl).toList(), + presignedUrlResults.stream().map(PresignedUrlResult::objectUrl).toList()); } @Override @@ -150,7 +151,7 @@ public ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request for (int i = 0; i < resultItems.size(); i++) { ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); - String clothImageUrl = resultItem.uploadedUrl(); + String clothImageUrl = storageUtil.toPublicObjectUrl(resultItem.uploadedUrl()); List categories = resultItem.categories(); @@ -346,7 +347,10 @@ public ClothDetectResponse detectClothes(ClothDetectRequest request) { phaseStartedAtNs = System.nanoTime(); List payloads = aiResponse.result().uploadedUrls().stream() - .map(ClothDetectResponse.Payload::new) + .map( + url -> + new ClothDetectResponse.Payload( + storageUtil.toPublicObjectUrl(url))) .toList(); postProcessMs = elapsedMillis(phaseStartedAtNs); @@ -370,13 +374,13 @@ public ClothDetectResponse detectClothes(ClothDetectRequest request) { } private void validateImageUrls(List imageUrls) { - if (!s3Util.doAllFilesExistByUrls(imageUrls)) { + if (!storageUtil.doAllFilesExistByUrls(imageUrls)) { throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); } } private void validateImageUrl(String imageUrl) { - if (!s3Util.doesFileExistByUrl(imageUrl)) { + if (!storageUtil.doesFileExistByUrl(imageUrl)) { throw new BaseCustomException(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND); } } @@ -403,8 +407,10 @@ private List createPresignedUrls(Long memberId, int count) { return java.util.stream.IntStream.range(0, count) .mapToObj( i -> - s3Util.createPresignedUrlWithoutMd5( - ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) + storageUtil + .createPresignedUrl( + ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG) + .uploadUrl()) .toList(); } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java index 7588931f..6fbcce3e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java @@ -29,7 +29,7 @@ import org.clokey.global.util.MemberUtil; import org.clokey.member.entity.Member; import org.clokey.response.SliceResponse; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; @@ -44,6 +44,7 @@ public class ClothServiceImpl implements ClothService { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final MemberUtil memberUtil; + private final StorageUtil storageUtil; private final ClothRepository clothRepository; private final CategoryRepository categoryRepository; @@ -52,7 +53,6 @@ public class ClothServiceImpl implements ClothService { private final ApplicationEventPublisher eventPublisher; private final CoordinateClothRepository coordinateClothRepository; - private final S3Util s3Util; @Override @Transactional @@ -73,7 +73,7 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { cr -> { Category category = categoryMap.get(cr.categoryId()); return Cloth.createCloth( - cr.clothImageUrl(), + storageUtil.toPublicObjectUrl(cr.clothImageUrl()), cr.clothUrl(), cr.name(), cr.brand(), @@ -88,7 +88,7 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { List clothImageUrls = request.content().stream().map(ClothCreateRequest::clothImageUrl).toList(); // 모든 선택된 url들을 확정하는 로직. - s3Util.updateTagsToCompleteByUrls(clothImageUrls); + storageUtil.updateTagsToCompleteByUrls(clothImageUrls); return ClothCreateResponse.from(clothes); } @@ -168,7 +168,8 @@ public void updateCloth(Long clothId, ClothUpdateRequest request) { validateChildCategory(category); // 사진이 바뀌는 경우 기존 imageUrl을 기반으로 S3에서 삭제합니다. - if (!cloth.getClothImageUrl().equals(request.clothImageUrl())) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(request.clothImageUrl()); + if (!storageUtil.toPublicObjectUrl(cloth.getClothImageUrl()).equals(normalizedImageUrl)) { eventPublisher.publishEvent(ImageDeleteEvent.of(cloth.getClothImageUrl())); } @@ -176,7 +177,7 @@ public void updateCloth(Long clothId, ClothUpdateRequest request) { boolean categoryChanged = !cloth.getCategory().getId().equals(category.getId()); cloth.updateCloth( - request.clothImageUrl(), + normalizedImageUrl, request.clothUrl(), request.name(), request.brand(), diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java index 7b829051..b2de8431 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java @@ -36,6 +36,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.Visibility; import org.clokey.response.SliceResponse; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.data.redis.core.RedisTemplate; @@ -61,6 +62,7 @@ public class CoordinateServiceImpl implements CoordinateService { private final RedisTemplate redisTemplate; private final ApplicationEventPublisher eventPublisher; + private final StorageUtil storageUtil; @Override @Transactional @@ -97,7 +99,8 @@ public CoordinateCreateResponse createDailyCoordinate(DailyCoordinateCreateReque validateDailyCoordinateExist(currentMember.getId(), LocalDate.now(KST)); Coordinate coordinate = - Coordinate.createDailyCoordinate(request.coordinateImageUrl(), currentMember); + Coordinate.createDailyCoordinate( + storageUtil.toPublicObjectUrl(request.coordinateImageUrl()), currentMember); coordinateRepository.save(coordinate); List coordinateClothes = @@ -160,7 +163,7 @@ public CoordinateCreateResponse createCoordinateManual(CoordinateManualCreateReq Coordinate.createCoordinateManual( request.name(), request.memo(), - request.coordinateImageUrl(), + storageUtil.toPublicObjectUrl(request.coordinateImageUrl()), currentMember, lookBook); coordinateRepository.save(coordinate); @@ -245,10 +248,11 @@ public void updateCoordinate(Long coordinateId, CoordinateUpdateRequest request) validateAllClothesOwnership(currentMember, clothes); /** Coordinate 업데이트 로직 */ - if (!Objects.equals(coordinate.getImageUrl(), request.coordinateImageUrl())) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(request.coordinateImageUrl()); + if (!storageUtil.toPublicObjectUrl(coordinate.getImageUrl()).equals(normalizedImageUrl)) { eventPublisher.publishEvent(ImageDeleteEvent.of(coordinate.getImageUrl())); } - coordinate.updateCoordinate(request.name(), request.memo(), request.coordinateImageUrl()); + coordinate.updateCoordinate(request.name(), request.memo(), normalizedImageUrl); /** CoordinateCloth 업데이트 로직 */ List coordinateCloths = coordinate.getCoordinateClothes(); diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java index 132fdab5..4d05382f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java @@ -4,8 +4,9 @@ import java.util.List; public record HistoryImagesPresignedUrlResponse( - @Schema(description = "생성된 presigned url 리스트") List urls) { - public static HistoryImagesPresignedUrlResponse of(List urls) { - return new HistoryImagesPresignedUrlResponse(urls); + @Schema(description = "업로드용 presigned url 리스트") List urls, + @Schema(description = "저장/조회용 공개 객체 url 리스트") List objectUrls) { + public static HistoryImagesPresignedUrlResponse of(List urls, List objectUrls) { + return new HistoryImagesPresignedUrlResponse(urls, objectUrls); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index 52dd42c5..237b0cd9 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -40,7 +40,8 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.Visibility; import org.clokey.report.enums.TargetType; -import org.clokey.util.S3Util; +import org.clokey.util.PresignedUrlResult; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -69,7 +70,7 @@ public class HistoryServiceImpl implements HistoryService { private final ReportRepository reportRepository; private final MemberRepository memberRepository; private final ApplicationEventPublisher eventPublisher; - private final S3Util s3Util; + private final StorageUtil storageUtil; @Override @Transactional @@ -105,7 +106,8 @@ public HistoryCreateResponse createHistory(HistoryCreateRequest request) { for (HistoryCreateRequest.Payload payload : request.payloads()) { final HistoryImage historyImage = - HistoryImage.createHistoryImage(payload.imageUrl(), history); + HistoryImage.createHistoryImage( + storageUtil.toPublicObjectUrl(payload.imageUrl()), history); images.add(historyImage); if (payload.clothTags() != null && !payload.clothTags().isEmpty()) { @@ -164,18 +166,21 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { existingImages.stream() .collect( Collectors.toMap( - HistoryImage::getImageUrl, + image -> storageUtil.toPublicObjectUrl(image.getImageUrl()), Function.identity(), (left, right) -> left)); Set requestedImageUrls = request.payloads().stream() - .map(HistoryUpdateRequest.Payload::imageUrl) + .map(payload -> storageUtil.toPublicObjectUrl(payload.imageUrl())) .collect(Collectors.toSet()); List imagesToDelete = existingImages.stream() - .filter(image -> !requestedImageUrls.contains(image.getImageUrl())) + .filter( + image -> + !requestedImageUrls.contains( + storageUtil.toPublicObjectUrl(image.getImageUrl()))) .toList(); if (!imagesToDelete.isEmpty()) { @@ -186,7 +191,10 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { Set keptImageIds = existingImages.stream() - .filter(image -> requestedImageUrls.contains(image.getImageUrl())) + .filter( + image -> + requestedImageUrls.contains( + storageUtil.toPublicObjectUrl(image.getImageUrl()))) .map(HistoryImage::getId) .collect(Collectors.toSet()); @@ -198,12 +206,13 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { List clothTags = new ArrayList<>(); for (HistoryUpdateRequest.Payload payload : request.payloads()) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(payload.imageUrl()); final HistoryImage historyImage = existingImageMap.getOrDefault( - payload.imageUrl(), - HistoryImage.createHistoryImage(payload.imageUrl(), history)); + normalizedImageUrl, + HistoryImage.createHistoryImage(normalizedImageUrl, history)); - if (!existingImageMap.containsKey(payload.imageUrl())) { + if (!existingImageMap.containsKey(normalizedImageUrl)) { newImages.add(historyImage); } @@ -402,18 +411,19 @@ public HistoryImagesPresignedUrlResponse getHistoryUploadPresignedUrls( HistoryImagesUploadRequest request) { final Member currentMember = memberUtil.getCurrentMember(); - List presignedUrls = + List presignedUrlResults = request.payloads().stream() .map( payload -> - s3Util.createPresignedUrl( + storageUtil.createPresignedUrl( ImageType.HISTORY_IMAGE, currentMember.getId(), - payload.fileExtension(), - payload.md5Hashes())) + payload.fileExtension())) .toList(); - return HistoryImagesPresignedUrlResponse.of(presignedUrls); + return HistoryImagesPresignedUrlResponse.of( + presignedUrlResults.stream().map(PresignedUrlResult::uploadUrl).toList(), + presignedUrlResults.stream().map(PresignedUrlResult::objectUrl).toList()); } private HistoryImage getHistoryImageById(Long historyImageId) { diff --git a/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java b/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java index ad83e781..d91f03b1 100644 --- a/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java +++ b/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java @@ -1,7 +1,7 @@ package org.clokey.domain.image.event; import lombok.RequiredArgsConstructor; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -11,17 +11,17 @@ @RequiredArgsConstructor public class ImageEventListener { - private final S3Util s3Util; + private final StorageUtil storageUtil; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleImagesDeleteEvent(ImagesDeleteEvent event) { - s3Util.deleteAllByUrls(event.imageUrls()); + storageUtil.deleteAllByUrls(event.imageUrls()); } @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleImageDeleteEvent(ImageDeleteEvent event) { - s3Util.deleteByUrl(event.imageUrl()); + storageUtil.deleteByUrl(event.imageUrl()); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java index 8b252815..6b9ebf1e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java @@ -26,6 +26,7 @@ import org.clokey.member.enums.MemberStatus; import org.clokey.member.enums.Visibility; import org.clokey.response.SliceResponse; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +45,7 @@ public class MemberServiceImpl implements MemberService { private final MemberLikeRepository memberLikeRepository; private final ApplicationEventPublisher eventPublisher; + private final StorageUtil storageUtil; @Override @Transactional @@ -55,8 +57,10 @@ public void updateProfile(ProfileUpdateRequest request) { // s3 삭제 로직 구현 이후에 반영 필요 -> 배경 및 프로필 이미지를 없애버리는 경우 + String normalizedProfileImageUrl = storageUtil.toPublicObjectUrl(request.profileImageUrl()); + currentMember.updateProfile( - request.nickname(), request.profileImageUrl(), request.bio(), request.visibility()); + request.nickname(), normalizedProfileImageUrl, request.bio(), request.visibility()); // Member 동기화 eventPublisher.publishEvent( diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index 135ecde5..8c97ff72 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -256,6 +256,7 @@ public void sendNewTemperatureNotification(TemperatureNotificationRequest reques if (isAbleToSendNotification(receiver)) { Notification notification = createPushNotification(content, temperatureImageUrl); + Message message = Message.builder() .setToken(receiver.getDeviceToken()) diff --git a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java index 6e07b2aa..b1dee313 100644 --- a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java +++ b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java @@ -10,6 +10,7 @@ import org.clokey.global.security.AppleAwareOAuth2AuthorizationRequestResolver; import org.clokey.global.security.JwtAuthenticationFilter; import org.clokey.global.security.SwaggerBasicAuthenticationFilter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -32,6 +33,18 @@ @RequiredArgsConstructor public class SecurityConfig { + @Value("${swagger.username:default}") + private String swaggerUsername; + + @Value("${swagger.password:default}") + private String swaggerPassword; + + @Value("${app.base-url.dev}") + private String devBaseUrl; + + @Value("${app.base-url.prod}") + private String prodBaseUrl; + private static final String[] SWAGGER_PATHS = { "/swagger-ui", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**" }; @@ -100,10 +113,7 @@ public SecurityFilterChain apiFilterChain( public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns( - List.of( - "http://localhost:3000", - "https://dev.clokey.store", - "https://prod.clokey.store")); + List.of("http://localhost:3000", devBaseUrl, prodBaseUrl)); configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 79df4c29..e287fecb 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -3,9 +3,9 @@ spring: time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DEV_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_DEV_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${OCI_MYSQL_USERNAME} + password: ${OCI_MYSQL_PASSWORD} hikari: minimum-idle: 10 maximum-pool-size: 20 @@ -43,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: ${KAKAO_REDIRECT_URI} + redirect-uri: "${app.base-url.dev}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -52,7 +52,7 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: ${APPLE_REDIRECT_URI} + redirect-uri: "${app.base-url.dev}/login/oauth2/code/apple" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -79,20 +79,23 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${DEV_AWS_ACCESS_KEY_ID} - secret-access-key: ${DEV_AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${DEV_S3_BUCKET} - endpoint: ${DEV_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_DEV_OBJECTSTORAGE_BUCKET} external: api: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} swagger: username: ${SWAGGER_USERNAME} @@ -113,3 +116,6 @@ firebase: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} \ No newline at end of file diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index 9e9a483e..be263a79 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -43,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: ${KAKAO_REDIRECT_URI} + redirect-uri: "${app.base-url.dev}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -52,7 +52,7 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: ${APPLE_REDIRECT_URI} + redirect-uri: "${app.base-url.dev}/login/oauth2/code/apple" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -79,20 +79,27 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${AWS_ACCESS_KEY_ID} - secret-access-key: ${AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${S3_BUCKET} - endpoint: ${S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_DEV_OBJECTSTORAGE_BUCKET} external: api: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} + +swagger: + username: ${SWAGGER_USERNAME} + password: ${SWAGGER_PASSWORD} springdoc: default-consumes-media-type: application/json @@ -115,3 +122,6 @@ firebase: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} \ No newline at end of file diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index e83a15e9..172650f8 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -3,9 +3,9 @@ spring: time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${PROD_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_PROD_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${OCI_MYSQL_USERNAME} + password: ${OCI_MYSQL_PASSWORD} hikari: minimum-idle: 10 maximum-pool-size: 20 @@ -43,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: ${KAKAO_REDIRECT_URI} + redirect-uri: "${app.base-url.prod}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -52,7 +52,7 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: ${APPLE_REDIRECT_URI} + redirect-uri: "${app.base-url.prod}/login/oauth2/code/apple" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -79,13 +79,16 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${PROD_AWS_ACCESS_KEY_ID} - secret-access-key: ${PROD_AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${PROD_S3_BUCKET} - endpoint: ${PROD_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_PROD_OBJECTSTORAGE_BUCKET} firebase: credentials-path: ${FIREBASE_CREDENTIALS_PATH} @@ -95,7 +98,7 @@ external: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} swagger: username: ${SWAGGER_USERNAME} @@ -113,4 +116,7 @@ springdoc: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} diff --git a/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java new file mode 100644 index 00000000..5ef4ecd7 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java @@ -0,0 +1,19 @@ +package org.clokey; + +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TestObjectStorageConfig { + + @Bean + @ConditionalOnMissingBean(ObjectStorageClient.class) + public ObjectStorageClient objectStorageClient() { + ObjectStorageClient client = org.mockito.Mockito.mock(ObjectStorageClient.class); + org.mockito.Mockito.when(client.getEndpoint()) + .thenReturn("https://objectstorage.test.oraclecloud.com"); + return client; + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java index f413d453..334bb08b 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java @@ -59,7 +59,8 @@ class 옷_업로드_presigned_url_발급_요청_시 { ClothImagesPresignedUrlResponse response = new ClothImagesPresignedUrlResponse( - List.of("testPresignedUrl1", "testPresignedUrl2")); + List.of("testPresignedUrl1", "testPresignedUrl2"), + List.of("testObjectUrl1", "testObjectUrl2")); given(clothAiService.getClothUploadPresignedUrls(request)).willReturn(response); diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java index fb3b3aeb..165cd93c 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java @@ -48,7 +48,8 @@ import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; import org.clokey.response.SliceResponse; -import org.clokey.util.S3Util; +import org.clokey.util.PresignedUrlResult; +import org.clokey.util.StorageUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -80,7 +81,7 @@ class ClothServiceTest extends IntegrationTest { @Autowired private SituationRepository situationRepository; @MockitoBean private MemberUtil memberUtil; - @MockitoBean private S3Util s3Util; + @MockitoBean private StorageUtil storageUtil; @Autowired private ApplicationEvents applicationEvents; @Nested @@ -109,18 +110,17 @@ void setUp() { FileExtension.JPEG, "testMd5Hash1"), new ClothImagesUploadRequest.Payload( FileExtension.JPEG, "testMd5Hash2"))); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) - .willReturn("testUrl1"); - - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) - .willReturn("testUrl2"); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")) + .willReturn(new PresignedUrlResult("testUploadUrl2", "testObjectUrl2")); // when ClothImagesPresignedUrlResponse response = clothAiService.getClothUploadPresignedUrls(request); // then - assertThat(response.urls()).containsExactly("testUrl1", "testUrl2"); + assertThat(response.urls()).containsExactly("testUploadUrl1", "testUploadUrl2"); + assertThat(response.objectUrls()).containsExactly("testObjectUrl1", "testObjectUrl2"); } } diff --git a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java index d9f3999f..155e4322 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java @@ -61,7 +61,9 @@ class 옷_업로드_presigned_url_발급_요청_시 { FileExtension.PNG, "testMd5Hash2"))); HistoryImagesPresignedUrlResponse response = - new HistoryImagesPresignedUrlResponse(List.of("testUrl1", "testUrl2")); + new HistoryImagesPresignedUrlResponse( + List.of("testUrl1", "testUrl2"), + List.of("testObjectUrl1", "testObjectUrl2")); given(historyService.getHistoryUploadPresignedUrls(request)).willReturn(response); diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index 6943e5e1..eb4e5d65 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import java.time.LocalDate; @@ -50,7 +49,8 @@ import org.clokey.member.entity.Member; import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; -import org.clokey.util.S3Util; +import org.clokey.util.PresignedUrlResult; +import org.clokey.util.StorageUtil; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -81,7 +81,7 @@ class HistoryServiceImplTest extends IntegrationTest { @Autowired private BlockRepository blockRepository; @MockitoBean private MemberUtil memberUtil; - @MockitoBean private S3Util s3Util; + @MockitoBean private StorageUtil storageUtil; @Autowired private ApplicationEvents applicationEvents; @Nested @@ -109,17 +109,17 @@ void setUp() { new HistoryImagesUploadRequest.Payload( FileExtension.PNG, "testMd5Hash2"))); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) - .willReturn("testUrl1"); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) - .willReturn("testUrl2"); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")) + .willReturn(new PresignedUrlResult("testUploadUrl2", "testObjectUrl2")); // when HistoryImagesPresignedUrlResponse response = historyService.getHistoryUploadPresignedUrls(request); // then - assertThat(response.urls()).containsExactly("testUrl1", "testUrl2"); + assertThat(response.urls()).containsExactly("testUploadUrl1", "testUploadUrl2"); + assertThat(response.objectUrls()).containsExactly("testObjectUrl1", "testObjectUrl2"); } } diff --git a/clokey-api/src/test/resources/application-test.yml b/clokey-api/src/test/resources/application-test.yml index f2143b62..1e184c2a 100644 --- a/clokey-api/src/test/resources/application-test.yml +++ b/clokey-api/src/test/resources/application-test.yml @@ -7,18 +7,15 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD} + password: ${REDIS_PASSWORD:} meilisearch: url: ${MEILISEARCH_ENDPOINT:http://localhost:7700} api-key: ${MEILISEARCH_KEY:dummy} -aws: - access-key-id: dummy - secret-access-key: dummy - region: dummy - s3: - bucket: dummy - endpoint: dummy +oci: + objectstorage: + namespace: test-namespace + bucket: test-bucket firebase: type: dummy diff --git a/clokey-infrastructure/build.gradle b/clokey-infrastructure/build.gradle index 0b072959..baf7ab20 100644 --- a/clokey-infrastructure/build.gradle +++ b/clokey-infrastructure/build.gradle @@ -14,7 +14,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' + implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0' + implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.47.0' api 'com.google.firebase:firebase-admin:9.7.0' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'io.vanslog:spring-data-meilisearch:0.7.3' diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java new file mode 100644 index 00000000..7a248904 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java @@ -0,0 +1,49 @@ +package org.clokey.config; + +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.clokey.properties.OciProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class ObjectStorageConfig { + + private final OciProperties ociProperties; + + /** 환경 변수에 PEM 키가 "\\n" 문자열로 들어온 경우 실제 줄바꿈으로 복원 */ + private static String normalizePemPrivateKey(String privateKey) { + if (privateKey == null || privateKey.isBlank()) { + return privateKey; + } + return privateKey.replace("\\n", "\n"); + } + + @Bean + @ConditionalOnProperty(name = "oci.region") + public ObjectStorageClient objectStorageClient() { + String rawKey = ociProperties.privateKey(); + String privateKey = normalizePemPrivateKey(rawKey); + String passphrase = ociProperties.passphrase(); + + SimpleAuthenticationDetailsProvider provider = + SimpleAuthenticationDetailsProvider.builder() + .tenantId(ociProperties.tenancyId()) + .userId(ociProperties.userId()) + .fingerprint(ociProperties.fingerprint()) + .privateKeySupplier( + () -> + new ByteArrayInputStream( + privateKey.getBytes(StandardCharsets.UTF_8))) + .passPhrase(passphrase != null && !passphrase.isEmpty() ? passphrase : null) + .region(com.oracle.bmc.Region.fromRegionId(ociProperties.region())) + .build(); + + return new ObjectStorageClient(provider); + } +} diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java b/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java deleted file mode 100644 index 6ec82256..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.clokey.config; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import lombok.RequiredArgsConstructor; -import org.clokey.properties.AwsProperties; -import org.clokey.properties.S3Properties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@RequiredArgsConstructor -public class S3Config { - - private final AwsProperties awsProperties; - private final S3Properties s3Properties; - - @Bean - public AmazonS3 s3Client() { - BasicAWSCredentials credentials = - new BasicAWSCredentials( - awsProperties.accessKeyId(), awsProperties.secretAccessKey()); - AwsClientBuilder.EndpointConfiguration endpointConfiguration = - new AwsClientBuilder.EndpointConfiguration( - s3Properties.endpoint(), awsProperties.region()); - - return AmazonS3ClientBuilder.standard() - .withEndpointConfiguration(endpointConfiguration) - .withCredentials(new AWSStaticCredentialsProvider(credentials)) - .build(); - } -} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java deleted file mode 100644 index d96917bd..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.clokey.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties("aws") -public record AwsProperties(String accessKeyId, String secretAccessKey, String region) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java new file mode 100644 index 00000000..bc7fe247 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java @@ -0,0 +1,12 @@ +package org.clokey.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("oci") +public record OciProperties( + String tenancyId, + String userId, + String fingerprint, + String privateKey, + String region, + String passphrase) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java index 52ef9a03..fe30c904 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java @@ -7,8 +7,8 @@ @EnableConfigurationProperties({ RedisProperties.class, JwtProperties.class, - S3Properties.class, - AwsProperties.class, + StorageProperties.class, + OciProperties.class, WebClientProperties.class, FirebaseProperties.class, MeilisearchProperties.class diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java deleted file mode 100644 index ff021da2..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.clokey.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties("aws.s3") -public record S3Properties(String bucket, String endpoint) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java new file mode 100644 index 00000000..62b83554 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java @@ -0,0 +1,6 @@ +package org.clokey.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("oci.objectstorage") +public record StorageProperties(String namespace, String bucket) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java b/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java new file mode 100644 index 00000000..d825e584 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java @@ -0,0 +1,3 @@ +package org.clokey.util; + +public record PresignedUrlResult(String uploadUrl, String objectUrl) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java b/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java deleted file mode 100644 index 76146573..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java +++ /dev/null @@ -1,173 +0,0 @@ -package org.clokey.util; - -import com.amazonaws.HttpMethod; -import com.amazonaws.SdkClientException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.*; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.clokey.enums.FileExtension; -import org.clokey.enums.ImageType; -import org.clokey.properties.S3Properties; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class S3Util { - - private final AmazonS3 amazonS3; - private final S3Properties s3Properties; - - public String createPresignedUrl( - ImageType imageType, Long memberId, FileExtension fileExtension, String md5Hash) { - String imageKey = UUID.randomUUID().toString(); - String fileName = createFileName(imageType, memberId, imageKey, fileExtension); - String bucket = s3Properties.bucket(); - - GeneratePresignedUrlRequest generatePresignedUrlRequest = - generatePresignedUrlRequest( - bucket, fileName, fileExtension.getExtension(), md5Hash); - - return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); - } - - public String createPresignedUrlWithoutMd5( - ImageType imageType, Long memberId, FileExtension fileExtension) { - String imageKey = UUID.randomUUID().toString(); - String fileName = createFileName(imageType, memberId, imageKey, fileExtension); - String bucket = s3Properties.bucket(); - - GeneratePresignedUrlRequest generatePresignedUrlRequest = - generatePresignedUrlRequestWithoutMd5( - bucket, fileName, fileExtension.getExtension()); - - return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); - } - - private String createFileName( - ImageType imageType, Long memberId, String imageKey, FileExtension fileExtension) { - return memberId - + "/" - + imageType.getType() - + "/" - + imageKey - + "." - + fileExtension.getExtension(); - } - - private GeneratePresignedUrlRequest generatePresignedUrlRequest( - String bucket, String fileName, String imageFileExtension, String md5Hash) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) - .withKey(fileName) - .withContentType("image/" + imageFileExtension) - .withExpiration(getPresignedUrlExpiration()); - - generatePresignedUrlRequest.addRequestParameter("x-amz-tagging", "status=pending"); - - generatePresignedUrlRequest.setContentMd5(md5Hash); - - return generatePresignedUrlRequest; - } - - private GeneratePresignedUrlRequest generatePresignedUrlRequestWithoutMd5( - String bucket, String fileName, String imageFileExtension) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) - .withKey(fileName) - .withContentType("image/" + imageFileExtension) - .withExpiration(getPresignedUrlExpiration()); - - generatePresignedUrlRequest.addRequestParameter("x-amz-tagging", "status=pending"); - - return generatePresignedUrlRequest; - } - - public void deleteAllByUrls(List urls) { - if (urls == null || urls.isEmpty()) { - log.info("deleteAllByUrls skipped: received null or empty urls"); - return; - } - String bucket = s3Properties.bucket(); - - List keys = - urls.stream() - .map(this::extractObjectKey) - .map(DeleteObjectsRequest.KeyVersion::new) - .toList(); - - DeleteObjectsRequest request = new DeleteObjectsRequest(bucket).withKeys(keys); - amazonS3.deleteObjects(request); - } - - public void deleteByUrl(String url) { - String bucket = s3Properties.bucket(); - String objectKey = extractObjectKey(url); - amazonS3.deleteObject(bucket, objectKey); - } - - private String extractObjectKey(String url) { - int comIndex = url.indexOf(".com/"); - return url.substring(comIndex + 5); - } - - private Date getPresignedUrlExpiration() { - Date expiration = new Date(); - long expTimeMillis = expiration.getTime(); - expTimeMillis += TimeUnit.HOURS.toMillis(1); - expiration.setTime(expTimeMillis); - - return expiration; - } - - public void updateTagToCompleteByUrl(String url) { - String bucket = s3Properties.bucket(); - String key = extractObjectKey(url); - - List tags = List.of(new Tag("status", "complete")); - ObjectTagging tagging = new ObjectTagging(tags); - - SetObjectTaggingRequest request = new SetObjectTaggingRequest(bucket, key, tagging); - amazonS3.setObjectTagging(request); - } - - public void updateTagsToCompleteByUrls(List urls) { - for (String url : urls) { - updateTagToCompleteByUrl(url); - } - } - - public boolean doesFileExistByUrl(String url) { - String bucket = s3Properties.bucket(); - String key = extractObjectKey(url); - try { - return amazonS3.doesObjectExist(bucket, key); - } catch (AmazonS3Exception e) { - if (e.getStatusCode() == 403) { - log.warn("Access denied for key={}, treating as non-existent", key); - return false; - } - log.error("S3 error while checking existence: {}", e.getErrorMessage(), e); - return false; - } catch (SdkClientException e) { - log.error("Network error while connecting to S3: {}", e.getMessage()); - return false; - } - } - - public boolean doAllFilesExistByUrls(List urls) { - for (String url : urls) { - if (!doesFileExistByUrl(url)) { - log.warn("File not found or inaccessible: {}", url); - return false; - } - } - - return true; - } -} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java new file mode 100644 index 00000000..44940b61 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java @@ -0,0 +1,274 @@ +package org.clokey.util; + +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails; +import com.oracle.bmc.objectstorage.requests.*; +import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse; +import com.oracle.bmc.objectstorage.responses.HeadObjectResponse; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.clokey.enums.FileExtension; +import org.clokey.enums.ImageType; +import org.clokey.properties.StorageProperties; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StorageUtil { + + private final ObjectStorageClient objectStorageClient; + private final StorageProperties storageProperties; + + public PresignedUrlResult createPresignedUrl( + ImageType imageType, Long memberId, FileExtension fileExtension) { + String imageKey = UUID.randomUUID().toString(); + String fileName = createFileName(imageType, memberId, imageKey, fileExtension); + return createPresignedUrlForObject(fileName, imageKey); + } + + private PresignedUrlResult createPresignedUrlForObject(String fileName, String imageKey) { + CreatePreauthenticatedRequestDetails details = + CreatePreauthenticatedRequestDetails.builder() + .name("presigned-url-" + imageKey) + .objectName(fileName) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + .timeExpires(getPresignedUrlExpiration()) + .build(); + + CreatePreauthenticatedRequestRequest request = + CreatePreauthenticatedRequestRequest.builder() + .namespaceName(storageProperties.namespace()) + .bucketName(storageProperties.bucket()) + .createPreauthenticatedRequestDetails(details) + .build(); + + CreatePreauthenticatedRequestResponse response = + objectStorageClient.createPreauthenticatedRequest(request); + + String uploadUrl = + objectStorageClient.getEndpoint() + + response.getPreauthenticatedRequest().getAccessUri(); + String objectUrl = buildPublicObjectUrl(fileName); + + return new PresignedUrlResult(uploadUrl, objectUrl); + } + + public String buildPublicObjectUrl(String objectKey) { + return objectStorageClient.getEndpoint() + + "/n/" + + storageProperties.namespace() + + "/b/" + + storageProperties.bucket() + + "/o/" + + encodeObjectKey(objectKey); + } + + public String toPublicObjectUrl(String url) { + if (url == null || url.isBlank()) { + return url; + } + + try { + return buildPublicObjectUrl(extractObjectKey(url)); + } catch (IllegalArgumentException e) { + log.warn("Failed to normalize object URL, returning original url: {}", url, e); + return url; + } + } + + private String createFileName( + ImageType imageType, Long memberId, String imageKey, FileExtension fileExtension) { + return memberId + + "/" + + imageType.getType() + + "/" + + imageKey + + "." + + fileExtension.getExtension(); + } + + public void deleteAllByUrls(List urls) { + if (urls == null || urls.isEmpty()) { + log.info("deleteAllByUrls skipped: received null or empty urls"); + return; + } + + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + + for (String url : urls) { + try { + String objectKey = extractObjectKey(url); + DeleteObjectRequest request = + DeleteObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + objectStorageClient.deleteObject(request); + } catch (Exception e) { + log.error("Failed to delete object: {}", url, e); + } + } + } + + public void deleteByUrl(String url) { + if (url == null || url.isBlank()) { + log.info("deleteByUrl skipped: url is null or blank"); + return; + } + try { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + DeleteObjectRequest request = + DeleteObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + objectStorageClient.deleteObject(request); + } catch (Exception e) { + log.error("Failed to delete object: {}", url, e); + } + } + + private String extractObjectKey(String url) { + String path = url; + int queryIndex = path.indexOf('?'); + if (queryIndex != -1) { + path = path.substring(0, queryIndex); + } + + int oIndex = path.indexOf("/o/"); + if (oIndex == -1) { + throw new IllegalArgumentException("Invalid URL format: " + url); + } + + String encodedKey = path.substring(oIndex + 3); + return URLDecoder.decode(encodedKey, StandardCharsets.UTF_8); + } + + private String encodeObjectKey(String objectKey) { + return URLEncoder.encode(objectKey, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private Date getPresignedUrlExpiration() { + return Date.from(Instant.now().plusSeconds(3600)); // 1시간 후 + } + + public void updateTagToCompleteByUrl(String url) { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + try { + HeadObjectRequest headRequest = + HeadObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + HeadObjectResponse headResponse = objectStorageClient.headObject(headRequest); + + Map metadata = new HashMap<>(); + if (headResponse.getOpcMeta() != null) { + metadata.putAll(headResponse.getOpcMeta()); + } + + metadata.put("status", "complete"); + + com.oracle.bmc.objectstorage.model.CopyObjectDetails copyDetails = + com.oracle.bmc.objectstorage.model.CopyObjectDetails.builder() + .sourceObjectName(objectKey) + .destinationBucket(bucket) + .destinationObjectName(objectKey) + .build(); + + CopyObjectRequest copyRequest = + CopyObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .copyObjectDetails(copyDetails) + .build(); + + objectStorageClient.copyObject(copyRequest); + + log.info( + "Object copied for tag update (metadata update may require manual intervention): {}", + objectKey); + } catch (com.oracle.bmc.model.BmcException e) { + log.error( + "Failed to update tag for object: {}, status: {}, message: {}", + objectKey, + e.getStatusCode(), + e.getMessage(), + e); + log.warn("Tag update failed but continuing: {}", objectKey); + } catch (Exception e) { + log.error("Failed to update tag for object: {}", objectKey, e); + log.warn("Tag update failed but continuing: {}", objectKey); + } + } + + public void updateTagsToCompleteByUrls(List urls) { + for (String url : urls) { + updateTagToCompleteByUrl(url); + } + } + + public boolean doesFileExistByUrl(String url) { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + try { + HeadObjectRequest request = + HeadObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + HeadObjectResponse response = objectStorageClient.headObject(request); + return response.get__httpStatusCode__() == 200; + } catch (com.oracle.bmc.model.BmcException e) { + if (e.getStatusCode() == 404) { + return false; + } + if (e.getStatusCode() == 403) { + log.warn("Access denied for key={}, treating as non-existent", objectKey); + return false; + } + log.error("OCI error while checking existence: {}", e.getMessage(), e); + return false; + } catch (Exception e) { + log.error("Network error while connecting to OCI: {}", e.getMessage()); + return false; + } + } + + public boolean doAllFilesExistByUrls(List urls) { + for (String url : urls) { + if (!doesFileExistByUrl(url)) { + log.warn("File not found or inaccessible: {}", url); + return false; + } + } + + return true; + } +}