diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml new file mode 100644 index 0000000..b939104 --- /dev/null +++ b/.github/workflows/deploy-ec2-docker.yml @@ -0,0 +1,187 @@ +name: Deploy Docker Apps To EC2 + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Docker image tag to deploy (default: commit SHA)" + required: false + type: string + pull_request: + types: + - closed + +env: + AWS_REGION: ap-northeast-2 + +jobs: + build-and-push: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: api-user + ecr_repo: oplust-api-user + - service: api-admin + ecr_repo: oplust-api-admin + - service: transcoder + ecr_repo: oplust-transcoder + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Ensure ECR repository exists + run: | + aws ecr describe-repositories --repository-names "${{ matrix.ecr_repo }}" >/dev/null 2>&1 || \ + aws ecr create-repository --repository-name "${{ matrix.ecr_repo }}" >/dev/null + + - name: Build and push image + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + run: | + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + IMAGE_URI="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:${IMAGE_TAG}" + IMAGE_URI_LATEST="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:latest" + + docker build \ + -f "apps/${{ matrix.service }}/Dockerfile" \ + -t "${IMAGE_URI}" \ + -t "${IMAGE_URI_LATEST}" \ + . + + docker push "${IMAGE_URI}" + docker push "${IMAGE_URI_LATEST}" + + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to EC2 instances via SSM + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + PROJECT_NAME: oplust + DB_NAME: oplust + SSM_RDS_ENDPOINT_PARAM: /oplust/common/rds-endpoint + SSM_DB_USERNAME_PARAM: /oplust/common/db-username + SSM_DB_PASSWORD_PARAM: /oplust/common/db-password + run: | + set -euo pipefail + + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + + deploy_service() { + local target_tag="$1" + local image_uri="$2" + local container_name="$3" + local env_file="$4" + local port="$5" + local service_env_param="$6" + + local instance_id + instance_id=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${target_tag}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$instance_id" ] || [ "$instance_id" = "None" ]; then + echo "No running instance found for tag: ${target_tag}" >&2 + exit 1 + fi + + local run_cmd + if [ -n "$port" ]; then + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped -p ${port}:${port} --env-file ${env_file} ${image_uri}" + else + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped --env-file ${env_file} ${image_uri}" + fi + + local cmd_id + cmd_id=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$instance_id" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy ${container_name}:${IMAGE_TAG}" \ + --parameters commands="[ + \"set -e\", + \"sudo mkdir -p /etc/oplust\", + \"DB_HOST=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"DB_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"DB_PASS=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_PASSWORD_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"SERVICE_ENV=\$(aws ssm get-parameter --region $AWS_REGION --name '${service_env_param}' --with-decryption --query 'Parameter.Value' --output text)\", + \"echo \\\"SPRING_DATASOURCE_URL=jdbc:mysql://\$DB_HOST:3306/${DB_NAME}\\\" | sudo tee ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_USERNAME=\$DB_USER\\\" | sudo tee -a ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_PASSWORD=\$DB_PASS\\\" | sudo tee -a ${env_file} >/dev/null\", + \"printf '%s\\n' \\\"\$SERVICE_ENV\\\" | sudo tee -a ${env_file} >/dev/null\", + \"sudo chmod 600 ${env_file}\", + \"aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY\", + \"sudo docker pull ${image_uri}\", + \"sudo docker rm -f ${container_name} || true\", + \"${run_cmd}\" + ]" \ + --query 'Command.CommandId' \ + --output text) + + echo "[$container_name] command id: $cmd_id (instance: $instance_id)" + + local status + for _ in $(seq 1 120); do + status=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$cmd_id" \ + --instance-id "$instance_id" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$status" in + Success) + echo "[$container_name] deployment success" + return 0 + ;; + Failed|Cancelled|TimedOut) + echo "[$container_name] deployment failed with status: $status" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + echo "[$container_name] unexpected status: $status" >&2 + sleep 5 + ;; + esac + done + + echo "[$container_name] deployment timed out waiting for SSM command completion" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + } + + deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "/oplust/api-user/env" + deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "/oplust/api-admin/env" + deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "/oplust/transcoder/env" diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml new file mode 100644 index 0000000..8201ef7 --- /dev/null +++ b/.github/workflows/deploy-monitoring.yml @@ -0,0 +1,212 @@ +name: Deploy Monitoring Stack + +on: + workflow_dispatch: + inputs: + monitoring_instance_tag: + description: "EC2 Name tag for monitoring server" + required: true + default: "oplust-monitoring-ec2" + type: string + user_api_target_ssm_param: + description: "SSM parameter name for user-api target (host:port)" + required: true + default: "/oplust/monitoring/targets/user-api" + type: string + admin_api_target_ssm_param: + description: "SSM parameter name for admin-api target (host:port)" + required: true + default: "/oplust/monitoring/targets/admin-api" + type: string + transcoder_target_ssm_param: + description: "SSM parameter name for transcoder target (host:port)" + required: true + default: "/oplust/monitoring/targets/transcoder" + type: string + grafana_password_ssm_param: + description: "SSM SecureString parameter name for Grafana admin password" + required: true + default: "/oplust/monitoring/grafana-admin-password" + type: string + grafana_admin_password: + description: "Optional override password (leave blank to use SSM)" + required: false + type: string + +env: + AWS_REGION: ap-northeast-2 + MONITORING_ROOT: /opt/oplust-monitoring + +jobs: + deploy-monitoring: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Resolve scrape targets from SSM + env: + USER_API_TARGET_SSM_PARAM: ${{ github.event.inputs.user_api_target_ssm_param }} + ADMIN_API_TARGET_SSM_PARAM: ${{ github.event.inputs.admin_api_target_ssm_param }} + TRANSCODER_TARGET_SSM_PARAM: ${{ github.event.inputs.transcoder_target_ssm_param }} + run: | + set -euo pipefail + + USER_API_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$USER_API_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + ADMIN_API_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$ADMIN_API_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + TRANSCODER_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$TRANSCODER_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + if [ -z "$USER_API_TARGET" ] || [ "$USER_API_TARGET" = "None" ] || \ + [ -z "$ADMIN_API_TARGET" ] || [ "$ADMIN_API_TARGET" = "None" ] || \ + [ -z "$TRANSCODER_TARGET" ] || [ "$TRANSCODER_TARGET" = "None" ]; then + echo "One or more scrape targets are empty. Check SSM parameter values." >&2 + exit 1 + fi + + echo "USER_API_TARGET=$USER_API_TARGET" >> "$GITHUB_ENV" + echo "ADMIN_API_TARGET=$ADMIN_API_TARGET" >> "$GITHUB_ENV" + echo "TRANSCODER_TARGET=$TRANSCODER_TARGET" >> "$GITHUB_ENV" + + - name: Render prod Prometheus config + run: | + set -euo pipefail + + sed \ + -e "s|__USER_API_TARGET__|${USER_API_TARGET}|g" \ + -e "s|__ADMIN_API_TARGET__|${ADMIN_API_TARGET}|g" \ + -e "s|__TRANSCODER_TARGET__|${TRANSCODER_TARGET}|g" \ + apps/monitoring/prometheus/prometheus.prod.yml.tpl > apps/monitoring/prometheus/prometheus.prod.yml + + - name: Resolve Grafana admin password + env: + GRAFANA_ADMIN_PASSWORD_INPUT: ${{ github.event.inputs.grafana_admin_password }} + GRAFANA_PASSWORD_SSM_PARAM: ${{ github.event.inputs.grafana_password_ssm_param }} + run: | + set -euo pipefail + + if [ -n "$GRAFANA_ADMIN_PASSWORD_INPUT" ]; then + GRAFANA_PASSWORD="$GRAFANA_ADMIN_PASSWORD_INPUT" + else + GRAFANA_PASSWORD=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$GRAFANA_PASSWORD_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + fi + + if [ -z "$GRAFANA_PASSWORD" ] || [ "$GRAFANA_PASSWORD" = "None" ]; then + echo "Grafana admin password is empty. Check input or SSM parameter." >&2 + exit 1 + fi + + echo "GRAFANA_PASSWORD=$GRAFANA_PASSWORD" >> "$GITHUB_ENV" + + - name: Deploy monitoring stack via SSM + env: + INSTANCE_TAG: ${{ github.event.inputs.monitoring_instance_tag }} + run: | + set -euo pipefail + + INSTANCE_ID=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${INSTANCE_TAG}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "No running monitoring instance found for tag: $INSTANCE_TAG" >&2 + exit 1 + fi + + COMPOSE_B64=$(base64 -w 0 apps/monitoring/docker-compose.yml) + COMPOSE_PROD_B64=$(base64 -w 0 apps/monitoring/docker-compose.prod.yml) + PROM_PROD_B64=$(base64 -w 0 apps/monitoring/prometheus/prometheus.prod.yml) + DATASOURCE_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/datasources/prometheus.yml) + DASH_PROVIDER_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/dashboards/dashboards.yml) + DASHBOARDS_JSON_TGZ_B64=$(tar -C apps/monitoring/grafana/provisioning/dashboards -czf - json | base64 -w 0) + + PARAMS=$(jq -nc \ + --arg c1 "set -euo pipefail" \ + --arg c2 "sudo mkdir -p ${MONITORING_ROOT}/prometheus ${MONITORING_ROOT}/grafana/provisioning/datasources ${MONITORING_ROOT}/grafana/provisioning/dashboards/json ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ + --arg c3 "echo '$COMPOSE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/docker-compose.yml >/dev/null" \ + --arg c4 "echo '$COMPOSE_PROD_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/docker-compose.prod.yml >/dev/null" \ + --arg c5 "echo '$PROM_PROD_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/prometheus/prometheus.prod.yml >/dev/null" \ + --arg c6 "echo '$DATASOURCE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/datasources/prometheus.yml >/dev/null" \ + --arg c7 "echo '$DASH_PROVIDER_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/dashboards/dashboards.yml >/dev/null" \ + --arg c8 "echo '$DASHBOARDS_JSON_TGZ_B64' | base64 -d | sudo tar -xzf - -C ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ + --arg c9 "printf '%s\n' 'GF_SECURITY_ADMIN_USER=admin' 'GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}' | sudo tee ${MONITORING_ROOT}/.env >/dev/null" \ + --arg c10 "if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else if command -v dnf >/dev/null 2>&1; then sudo dnf install -y docker-compose-plugin || true; fi; if command -v yum >/dev/null 2>&1; then sudo yum install -y docker-compose-plugin || true; fi; if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else sudo mkdir -p /usr/local/lib/docker/cli-plugins; sudo curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose; sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose; if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; else echo 'Docker Compose not found' >&2; exit 1; fi; fi; fi" \ + --arg c11 "cd ${MONITORING_ROOT} && \$COMPOSE_CMD -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10,$c11]}') + + CMD_ID=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy monitoring stack" \ + --parameters "$PARAMS" \ + --query 'Command.CommandId' \ + --output text) + + echo "command_id=$CMD_ID instance_id=$INSTANCE_ID" + + for _ in $(seq 1 120); do + STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$STATUS" in + Success) + echo "Monitoring deployment success" + exit 0 + ;; + Failed|Cancelled|TimedOut) + echo "Monitoring deployment failed: $STATUS" >&2 + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' \ + --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + sleep 5 + ;; + esac + done + + echo "Monitoring deployment timed out" >&2 + exit 1 \ No newline at end of file diff --git a/.github/workflows/deploy-rabbitmq.yml b/.github/workflows/deploy-rabbitmq.yml new file mode 100644 index 0000000..f7bf7c4 --- /dev/null +++ b/.github/workflows/deploy-rabbitmq.yml @@ -0,0 +1,131 @@ +name: Deploy RabbitMQ + +on: + workflow_dispatch: + inputs: + rabbitmq_instance_tag: + description: "EC2 Name tag for RabbitMQ server" + required: true + default: "oplust-rabbitmq-ec2" + type: string + rabbitmq_image_tag: + description: "RabbitMQ image tag" + required: true + default: "3.13-management" + type: string + rabbitmq_user_ssm_param: + description: "SSM SecureString parameter for RabbitMQ default user" + required: true + default: "/oplust/rabbitmq/default-user" + type: string + rabbitmq_password_ssm_param: + description: "SSM SecureString parameter for RabbitMQ default password" + required: true + default: "/oplust/rabbitmq/default-password" + type: string + rabbitmq_vhost_ssm_param: + description: "SSM parameter for RabbitMQ default vhost" + required: true + default: "/oplust/rabbitmq/default-vhost" + type: string + +env: + AWS_REGION: ap-northeast-2 + RABBITMQ_ROOT: /opt/oplust-rabbitmq + RABBITMQ_CONTAINER_NAME: oplust-rabbitmq + RABBITMQ_DATA_VOLUME: oplust-rabbitmq-data + +jobs: + deploy-rabbitmq: + runs-on: ubuntu-latest + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy RabbitMQ via SSM + env: + INSTANCE_TAG: ${{ github.event.inputs.rabbitmq_instance_tag }} + RABBITMQ_IMAGE_TAG: ${{ github.event.inputs.rabbitmq_image_tag }} + RABBITMQ_USER_SSM_PARAM: ${{ github.event.inputs.rabbitmq_user_ssm_param }} + RABBITMQ_PASSWORD_SSM_PARAM: ${{ github.event.inputs.rabbitmq_password_ssm_param }} + RABBITMQ_VHOST_SSM_PARAM: ${{ github.event.inputs.rabbitmq_vhost_ssm_param }} + run: | + set -euo pipefail + + INSTANCE_ID=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${INSTANCE_TAG}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "No running RabbitMQ instance found for tag: $INSTANCE_TAG" >&2 + exit 1 + fi + + PARAMS=$(jq -nc \ + --arg c1 "set -euo pipefail" \ + --arg c2 "sudo mkdir -p ${RABBITMQ_ROOT}" \ + --arg c3 "RABBITMQ_DEFAULT_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_USER_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c4 "RABBITMQ_DEFAULT_PASS=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_PASSWORD_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c5 "RABBITMQ_DEFAULT_VHOST=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_VHOST_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c6 "if [ -z \\\"\$RABBITMQ_DEFAULT_USER\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_USER\\\" = \\\"None\\\" ] || [ -z \\\"\$RABBITMQ_DEFAULT_PASS\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_PASS\\\" = \\\"None\\\" ] || [ -z \\\"\$RABBITMQ_DEFAULT_VHOST\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_VHOST\\\" = \\\"None\\\" ]; then echo 'RabbitMQ env values are empty from SSM' >&2; exit 1; fi" \ + --arg c7 "printf '%s\n' \\\"RABBITMQ_DEFAULT_USER=\$RABBITMQ_DEFAULT_USER\\\" \\\"RABBITMQ_DEFAULT_PASS=\$RABBITMQ_DEFAULT_PASS\\\" \\\"RABBITMQ_DEFAULT_VHOST=\$RABBITMQ_DEFAULT_VHOST\\\" | sudo tee ${RABBITMQ_ROOT}/.env >/dev/null" \ + --arg c8 "sudo chmod 600 ${RABBITMQ_ROOT}/.env" \ + --arg c9 "sudo docker pull rabbitmq:${RABBITMQ_IMAGE_TAG}" \ + --arg c10 "sudo docker rm -f ${RABBITMQ_CONTAINER_NAME} || true" \ + --arg c11 "sudo docker volume create ${RABBITMQ_DATA_VOLUME} >/dev/null" \ + --arg c12 "sudo docker run -d --name ${RABBITMQ_CONTAINER_NAME} --restart unless-stopped -p 5672:5672 -p 15672:15672 --env-file ${RABBITMQ_ROOT}/.env -v ${RABBITMQ_DATA_VOLUME}:/var/lib/rabbitmq rabbitmq:${RABBITMQ_IMAGE_TAG}" \ + --arg c13 "for i in \$(seq 1 30); do if sudo docker exec ${RABBITMQ_CONTAINER_NAME} rabbitmq-diagnostics -q ping >/dev/null 2>&1; then echo 'RabbitMQ is healthy'; exit 0; fi; sleep 2; done; echo 'RabbitMQ health check failed' >&2; exit 1" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10,$c11,$c12,$c13]}') + + CMD_ID=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy RabbitMQ" \ + --parameters "$PARAMS" \ + --query 'Command.CommandId' \ + --output text) + + echo "command_id=$CMD_ID instance_id=$INSTANCE_ID" + + for _ in $(seq 1 120); do + STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$STATUS" in + Success) + echo "RabbitMQ deployment success" + exit 0 + ;; + Failed|Cancelled|TimedOut) + echo "RabbitMQ deployment failed: $STATUS" >&2 + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' \ + --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + sleep 5 + ;; + esac + done + + echo "RabbitMQ deployment timed out" >&2 + exit 1 diff --git a/.gitignore b/.gitignore index fe8f8e8..3ef2135 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,12 @@ out/ ### Node ### node_modules/ package-lock.json -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml + +modules/infra/src/main/resources/db/seed/ + +private_docs/ +AGENTS.md +CLAUDE.md + +**/application-local.yml \ No newline at end of file diff --git a/README.md b/README.md index 1650d7c..6a7c400 100644 --- a/README.md +++ b/README.md @@ -1 +1,440 @@ -# backend \ No newline at end of file +## ๐Ÿ“Œ 1. Project Overview +**O+T(์˜ค์ ํ‹ฐ)** ๋Š” ๋‹จ์ˆœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ถ”์ฒœ์˜ ํ•œ๊ณ„๋ฅผ ๋ณด์™„ํ•˜๊ณ  ์‚ฌ์šฉ์ž์˜ ์ฝ˜ํ…์ธ  ํƒ์ƒ‰ ํ”ผ๋กœ๋„๋ฅผ ๋‚ฎ์ถ”๊ธฐ ์œ„ํ•ด ๊ธฐํš๋œ ์ˆํผ/๋กฑํผ ์—ฐ๊ณ„ OTT ํ”Œ๋žซํผ์ž…๋‹ˆ๋‹ค. +๋ณธ ๋ ˆํฌ์ง€ํ† ๋ฆฌ๋Š” ์„œ๋น„์Šค์˜ ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„ ๋ฐ ๋น„๋™๊ธฐ ์˜์ƒ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์‹œ์Šคํ…œ์„ ํฌํ•จํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ **์—๋””ํ„ฐ/๊ด€๋ฆฌ์ž ๊ธฐ๋ฐ˜์˜ ์ˆํผ ์—…๋กœ๋“œ**์™€ **์ˆํผ์—์„œ ๋ณธํŽธ(๋กฑํผ)์œผ๋กœ์˜ ์ฆ‰๊ฐ์ ์ธ ์ „ํ™˜(CTA)** ์„ ์ง€์›ํ•˜๋Š”๋ฐ ๋งž์ถฐ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค. +๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ๋Œ€์šฉ๋Ÿ‰ ์˜์ƒ ์ฒ˜๋ฆฌ๋กœ ์ธํ•œ API ์„œ๋ฒ„ ๋ถ€ํ•˜๋ฅผ ๋ฐฉ์ง€ํ•˜๊ณ  HLS ๊ธฐ๋ฐ˜์˜ ์ ์‘ํ˜• ์ŠคํŠธ๋ฆฌ๋ฐ(ABR) ์„ ์•ˆ์ •์ ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” ์ธํ”„๋ผ ๋ฐ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„์— ์ง‘์ค‘ํ–ˆ์Šต๋‹ˆ๋‹ค. + +
+ +## ๐Ÿ› ๏ธ 2. ๊ธฐ์ˆ  ์Šคํƒ (Tech Stack) +1. **์–ธ์–ด ๋ฐ ํ”„๋ ˆ์ž„์›Œํฌ:** Java, Spring Boot, Spring Data JPA, QueryDSL + +2. **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค:** MySQL 8.0, Flyway + +3. **๋กœ๊น… ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง:** Prometheus, Grafana, Loki + +4. **์ธํ”„๋ผ:** AWS (EC2, RDS, S3, Lambda, ALB, VPC Endpoint) + +5. **๋ฉ”์‹œ์ง€ ํ:** AWS SQS (or RabbitMQ) + +6. **CI/CD ๋ฐ ๊ธฐํƒ€:** GitHub Actions, Docker, FFmpeg (Media Processing) + +
+ +## 3. ์‹œ์Šคํ…œ ๋ฐ ์ธํ”„๋ผ ์•„ํ‚คํ…์ฒ˜ +### 3.1 ๐Ÿ—๏ธ ์ „์ฒด ์ธํ”„๋ผ ์•„ํ‚คํ…์ฒ˜ (System Architecture) + +```mermaid +flowchart LR + %% ================= Clients ================= + user["์ผ๋ฐ˜ ์‚ฌ์šฉ์ž
(Client)"] + admin["๊ด€๋ฆฌ์ž
(Client)"] + + %% ================= VPC ================= + subgraph vpc["VPC 10.0.0.0/20
Region: ap-northeast-2"] + direction LR + + %% Public + subgraph public["Public Subnets x2
(ALB ์ „์šฉ)"] + alb["ALB :80
- default โ†’ user-api (8080)
- /admin/* โ†’ admin-api (8081)"] + end + + %% Private App + subgraph private_app["Private App Subnet x1
(EC2 3๋Œ€)"] + user_api["EC2 user-api
:8080
์ผ๋ฐ˜ ๊ธฐ๋Šฅ / ์กฐํšŒ API"] + admin_api["EC2 admin-api
:8081
Presigned URL ๋ฐœ๊ธ‰
(์—…๋กœ๋“œ ์ „์šฉ ๊ด€๋ฆฌ)"] + worker["EC2 worker
SQS Consumer
Transcoding Server"] + end + + %% Private DB + subgraph private_db["Private DB Subnets x2
(RDS Subnet Group)"] + rds["RDS MySQL 8.0
db.t3.micro
Private / No Public Access"] + end + + %% VPC Endpoints + subgraph endpoints["VPC Endpoints (No NAT)"] + s3_ep["Gateway Endpoint
S3"] + sqs_ep["Interface Endpoint
SQS"] + ssm_ep["Interface Endpoint
SSM"] + ec2msg_ep["Interface Endpoint
EC2Messages"] + ssmm_ep["Interface Endpoint
SSMMessages"] + end + end + + %% ================= AWS Managed Services ================= + subgraph aws["AWS Managed Services
(Outside VPC)"] + s3_content["S3 Content Bucket
${project}-content-${random}
์›๋ณธ & ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์ €์žฅ"] + s3_deploy["S3 Deploy Bucket
${project}-deploy-${random}
๋ฐฐํฌ ์•„ํ‹ฐํŒฉํŠธ"] + lambda["Lambda (python3.12)
s3_to_sqs
ObjectCreated Trigger"] + sqs["SQS transcode_queue
(Standard Queue)"] + dlq["SQS transcode_dlq
maxReceiveCount = 5"] + end + + %% ================= API Routing ================= + user -->|"์ผ๋ฐ˜ API ์š”์ฒญ"| alb + admin -->|"๊ด€๋ฆฌ์ž API ์š”์ฒญ"| alb + + alb -->|"default"| user_api + alb -->|"/admin/*"| admin_api + + %% ================= Database ================= + user_api -->|"์กฐํšŒ/๋ฉ”ํƒ€ ๋ฐ์ดํ„ฐ"| rds + admin_api -->|"์—…๋กœ๋“œ ๋ฉ”ํƒ€ ๊ด€๋ฆฌ"| rds + worker -->|"์ƒํƒœ ์—…๋ฐ์ดํŠธ"| rds + + %% ================= Presigned Upload (ํ•ต์‹ฌ ๊ตฌ์กฐ) ================= + admin_api -. "Presigned PUT URL ๋ฐœ๊ธ‰
(S3 ์—…๋กœ๋“œ์šฉ)" .-> admin + admin -. "์ง์ ‘ ์—…๋กœ๋“œ (PUT)
contents/{id}/origin/{file}.mp4" .-> s3_content + + %% ================= Event Driven Pipeline ================= + s3_content -->|"ObjectCreated (.mp4)"| lambda + lambda -->|"SendMessage
{bucket, key, videoId}"| sqs + sqs --> dlq + + %% ================= Worker Data Flow ================= + worker -->|"Poll ๋ฉ”์‹œ์ง€"| sqs_ep + sqs_ep --> sqs + + worker -->|"์›๋ณธ ๋‹ค์šด๋กœ๋“œ / ๊ฒฐ๊ณผ ์—…๋กœ๋“œ"| s3_ep + s3_ep --> s3_content +``` + +์œ„ ๋‹ค์ด์–ด๊ทธ๋žจ์€ O+T ์„œ๋น„์Šค์˜ ํ•ต์‹ฌ ์ธํ”„๋ผ ๊ตฌ์„ฑ๋„๋กœ, ๋„คํŠธ์›Œํฌ ๋ณด์•ˆ ๊ฐ•ํ™”, ๋น„์šฉ ์ตœ์ ํ™”, ๋ฏธ๋””์–ด ์ฒ˜๋ฆฌ์˜ ๋น„๋™๊ธฐํ™”์— ์ดˆ์ ์„ ๋งž์ถ”์–ด ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +#### 1. ๋„คํŠธ์›Œํฌ ๊ฒฉ๋ฆฌ ๋ฐ ๋ณด์•ˆ(VPC & Subnet) +- ์™ธ๋ถ€์˜ ๋ชจ๋“  ํด๋ผ์ด์–ธํŠธ ํŠธ๋ž˜ํ”ฝ์€ Public Subnet์— ์œ„์น˜ํ•œ ALB(Application Load Balancer) 1๊ณณ์„ ํ†ตํ•ด์„œ๋งŒ ์ธ์ž…๋ฉ๋‹ˆ๋‹ค. + +- ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ์‹คํ–‰๋˜๋Š” 3๋Œ€์˜ EC2(User API, Admin API, Transcoder Worker)์™€ ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜๋Š” RDS MySQL์€ ๋ชจ๋‘ Private Subnet์— ์™„๋ฒฝํžˆ ๊ฒฉ๋ฆฌํ•˜์—ฌ ์™ธ๋ถ€ ์ธํ„ฐ๋„ท์œผ๋กœ๋ถ€ํ„ฐ์˜ ์ง์ ‘์ ์ธ ์ ‘๊ทผ์„ ์›์ฒœ ์ฐจ๋‹จํ–ˆ์Šต๋‹ˆ๋‹ค. + +#### 2. ๋„๋ฉ”์ธ๋ณ„ ํŠธ๋ž˜ํ”ฝ ๋ผ์šฐํŒ… ๋ถ„๋ฆฌ +- ALB์˜ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ…(Path-based Routing) ๊ทœ์น™์„ ์ ์šฉํ•˜์—ฌ ๋ฌผ๋ฆฌ์ ์ธ ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +- ์—๋””ํ„ฐ ์ „์šฉ ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ์ž ์š”์ฒญ(/admin/*)์€ Admin API ์ธ์Šคํ„ด์Šค(8081 ํฌํŠธ)๋กœ, ๊ฒ€์ƒ‰/ํ”ผ๋“œ ์กฐํšŒ/์ŠคํŠธ๋ฆฌ๋ฐ ๋“ฑ ํŠธ๋ž˜ํ”ฝ์ด ์ง‘์ค‘๋˜๋Š” ์ผ๋ฐ˜ ๋Œ€๊ณ ๊ฐ ์š”์ฒญ์€ User API ์ธ์Šคํ„ด์Šค(8080 ํฌํŠธ)๋กœ ์ „๋‹ฌํ•˜์—ฌ ๋„๋ฉ”์ธ ๊ฐ„ ๊ฐ„์„ญ์„ ์ตœ์†Œํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. + +#### 3. No-NAT ๊ธฐ๋ฐ˜ ํ”„๋ผ์ด๋น— ํ†ต์‹ (VPC Endpoints) +- Private Subnet ๋‚ด๋ถ€์˜ ์„œ๋ฒ„๊ฐ€ ์™ธ๋ถ€ AWS Managed Service(S3, SQS ๋“ฑ)์™€ ํ†ต์‹ ํ•˜๊ธฐ ์œ„ํ•ด ํ•„์ˆ˜์ ์ธ NAT Gateway๋ฅผ ๊ณผ๊ฐํžˆ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. (์›” ๊ณ ์ • ๋น„์šฉ ์ ˆ๊ฐ) + +- ๋Œ€์‹  AWS ๋‚ด๋ถ€๋ง ์ „์šฉ์„ ์ธ VPC Endpoints๋ฅผ ๊ตฌ์ถ•ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋Œ€์šฉ๋Ÿ‰ ์˜์ƒ์˜ ๋‹ค์šด๋กœ๋“œ/์—…๋กœ๋“œ๋Š” ๋ฌด๋ฃŒ์ธ S3 Gateway Endpoint๋ฅผ ๊ฑฐ์น˜๋ฉฐ, ์ž‘์—… ๋Œ€๊ธฐ์—ด ํ™•์ธ์€ SQS Interface Endpoint๋ฅผ ํ†ตํ•ด ํผ๋ธ”๋ฆญ ์ธํ„ฐ๋„ท๋ง ๋…ธ์ถœ ์—†์ด ์•ˆ์ „ํ•˜๊ณ  ๋น ๋ฅด๊ฒŒ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค. + +#### 4. ์„œ๋ฒ„๋ฆฌ์Šค ์ด๋ฒคํŠธ ๋ธŒ๋ฆฟ์ง€(Event-Driven Pipeline) +- Admin API๊ฐ€ S3 Presigned URL์„ ๋ฐœ๊ธ‰ํ•˜๋ฉด, ํด๋ผ์ด์–ธํŠธ๋Š” ์„œ๋ฒ„๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š๊ณ  S3 ๋ฒ„ํ‚ท์œผ๋กœ ์›๋ณธ ์˜์ƒ์„ ์งํ–‰์‹œํ‚ต๋‹ˆ๋‹ค. + +- ์˜์ƒ์ด S3์— ๋„์ฐฉํ•˜๋ฉด ๋ฐœ์ƒํ•˜๋Š” ObjectCreated ์ด๋ฒคํŠธ๋ฅผ AWS Lambda๊ฐ€ ์ฆ‰์‹œ ๋‚š์•„์ฑ„์–ด, ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ํ•จ๊ป˜ **SQS(Standard Queue)**๋กœ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์ž‘์—… ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ€์–ด ๋„ฃ์Šต๋‹ˆ๋‹ค. + + +#### 5. ๋ณด์•ˆ ์ ‘์† ๋ฐ CI/CD ๋ฐฐํฌ ์ž๋™ํ™”(AWS SSM) +- ๋ณด์•ˆ ์œ„ํ˜‘์ด ๋  ์ˆ˜ ์žˆ๋Š” ์™ธ๋ถ€ SSH ํฌํŠธ(22) ๊ฐœ๋ฐฉ์ด๋‚˜ ๋ณ„๋„์˜ Bastion Host(์ ํ”„ ์„œ๋ฒ„) ๊ตฌ์ถ•์„ ๋ฐฐ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + +- SSM Interface Endpoint๋ฅผ ํ†ตํ•ด AWS Systems Manager(Session Manager, Run Command)๋กœ Private EC2์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘์†ํ•˜๋ฉฐ, GitHub Actions์™€ ์—ฐ๋™ํ•˜์—ฌ ๋ฌด์ค‘๋‹จ ์ž๋™ ๋ฐฐํฌ ํŒŒ์ดํ”„๋ผ์ธ์„ ๊ตฌ๋™ํ•ฉ๋‹ˆ๋‹ค. + + + +### 3.2 ๐Ÿ“ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ (Multi-Module Monorepo) +์˜์ƒ ํŠธ๋žœ์Šค์ฝ”๋”ฉ(FFmpeg)์€ CPU ์ž์›์„ ๊ทน๋„๋กœ ์†Œ๋ชจํ•˜๋Š” ์ž‘์—…์ž…๋‹ˆ๋‹ค. ๋‹จ์ผ ๋ชจ๋†€๋ฆฌ์‹ ๊ตฌ์กฐ์—์„œ API ์š”์ฒญ ์ฒ˜๋ฆฌ์™€ ์ธ์ฝ”๋”ฉ ์ž‘์—…์„ ๋ณ‘ํ–‰ํ•  ๊ฒฝ์šฐ, ์ธ์ฝ”๋”ฉ ๋ถ€ํ•˜๊ฐ€ ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž API์˜ ์‘๋‹ต ์ง€์—ฐ ๋ฐ ์žฅ์• ๋กœ ์ „ํŒŒ๋  ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค. +์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ณ  ๊ฐœ๋ฐœ ํšจ์œจ์„ฑ์„ ๋†’์ด๊ธฐ ์œ„ํ•ด ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ๋ชจ๋…ธ๋ ˆํฌ ๋ฐ ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์ฑ„ํƒํ–ˆ์Šต๋‹ˆ๋‹ค. + +- **๋ฐฐํฌ ๋‹จ์œ„ ๋ถ„๋ฆฌ (apps/):** + - api-user: ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž์˜ ์ฝ˜ํ…์ธ  ๊ฒ€์ƒ‰, ์žฌ์ƒ, ํ†ต๊ณ„ ์กฐํšŒ๋ฅผ ์ „๋‹ดํ•˜๋Š” API ์„œ๋ฒ„. + + - api-admin: ๊ด€๋ฆฌ์ž ๋ฐ ์—๋””ํ„ฐ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ, ์˜์ƒ ์—…๋กœ๋“œ(Presigned URL ๋ฐœ๊ธ‰)๋ฅผ ์ „๋‹ดํ•˜๋Š” ๋ฐฑ์˜คํ”ผ์Šค ์„œ๋ฒ„. + + - transcoder: ์™ธ๋ถ€ ์š”์ฒญ์„ ์ง์ ‘ ๋ฐ›์ง€ ์•Š๊ณ , SQS ๋ฉ”์‹œ์ง€๋ฅผ ํด๋งํ•˜์—ฌ ๋น„๋™๊ธฐ๋กœ ์˜์ƒ์„ ๋ณ€ํ™˜ํ•˜๋Š” ์›Œ์ปค(Worker) ์„œ๋ฒ„. + +- **๊ณตํ†ต ๋ชจ๋“ˆ ๋ถ„๋ฆฌ (modules/):** + - ๊ฐ ์„œ๋ฒ„์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๋„๋ฉ”์ธ(Entity, Repository), ์ธํ”„๋ผ ์—ฐ๋™(S3, SQS ์„ค์ •), ์›น ๊ณตํ†ต(์˜ˆ์™ธ ์ฒ˜๋ฆฌ, ์‘๋‹ต DTO), ๋ณด์•ˆ(JWT, OAuth) ๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜์—ฌ ์ฝ”๋“œ ์ค‘๋ณต์„ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +``` +repo-root/ +โ”œโ”€โ”€ apps/ โ† ์‹ค์ œ ๋ฐฐํฌ ๋‹จ์œ„ (๊ฐ๊ฐ ๋…๋ฆฝ JAR) +โ”‚ โ”œโ”€โ”€ api-admin/ โ† ๊ด€๋ฆฌ์ž/์—๋””ํ„ฐ API ์„œ๋ฒ„ +โ”‚ โ”œโ”€โ”€ api-user/ โ† ์‚ฌ์šฉ์ž API ์„œ๋ฒ„ +โ”‚ โ””โ”€โ”€ transcoder/ โ† ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์›Œ์ปค +โ”‚ +โ”œโ”€โ”€ modules/ โ† ๊ณต์œ  ๋ชจ๋“ˆ (๋‹จ๋… ์‹คํ–‰ ๋ถˆ๊ฐ€, ์•ฑ์—์„œ ์˜์กด) +โ”‚ โ”œโ”€โ”€ domain/ โ† ์ „์ฒด Entity + Repository (JPA) +โ”‚ โ”œโ”€โ”€ infra/ โ† JPA ์„ค์ • + S3 ์„ค์ • +โ”‚ โ”œโ”€โ”€ common-web/ โ† ์˜ˆ์™ธ์ฒ˜๋ฆฌ, ์‘๋‹ต ํฌ๋งท +โ”‚ โ””โ”€โ”€ common-security/ โ† JWT, OAuth +โ”‚ +โ”œโ”€โ”€ settings.gradle +โ””โ”€โ”€ docker-compose.yml + + +---------------------------------------- + + +repo-root/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ api-admin/ # ๋ฐฑ์˜คํ”ผ์Šค ์„œ๋ฒ„ (JAR) +โ”‚ โ”‚ โ””โ”€โ”€ src/main/java/com/ott/admin/ +โ”‚ โ”‚ โ”œโ”€โ”€ content/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ dto/ +โ”‚ โ”œโ”€โ”€ api-user/ # ์‚ฌ์šฉ์ž API ์„œ๋ฒ„ (JAR) +โ”‚ โ”‚ โ””โ”€โ”€ src/main/java/com/ott/user/ +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ dto/ +โ”‚ โ”‚ โ”œโ”€โ”€ content/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ dto/ +โ”‚ โ”‚ โ””โ”€โ”€ config/ +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ transcoder/ # ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์›Œ์ปค (JAR) +โ”‚ โ””โ”€โ”€ src/main/java/com/ott/transcode/ +โ”‚ โ”œโ”€โ”€ worker/ +โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ””โ”€โ”€ config/ +โ”‚ +โ”œโ”€โ”€ modules/ +โ”‚ โ”œโ”€โ”€ domain/ # ์ „์ฒด ๋„๋ฉ”์ธ (Entity + Repository) +โ”‚ โ”‚ โ””โ”€โ”€ src/main/java/com/ott/domain/ +โ”‚ โ”‚ โ”œโ”€โ”€ content/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ entity/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ repository/ +โ”‚ โ”‚ โ””โ”€โ”€ series/ +โ”‚ โ”‚ โ”œโ”€โ”€ entity/ +โ”‚ โ”‚ โ””โ”€โ”€ repository/ +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ infra/ # DB + S3 ์„ค์ • +โ”‚ โ”‚ โ””โ”€โ”€ src/main/java/com/ott/infra/ +โ”‚ โ”‚ โ”œโ”€โ”€ db/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ””โ”€โ”€ s3/ +โ”‚ โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ””โ”€โ”€ S3FileService.java +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ common-web/ # ์›น ๊ณตํ†ต +โ”‚ โ”‚ โ””โ”€โ”€ src/main/java/com/ott/common/web/ +โ”‚ โ”‚ โ”œโ”€โ”€ exception/ +โ”‚ โ”‚ โ””โ”€โ”€ response/ +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ common-security/ # ์ธ์ฆ/์ธ๊ฐ€ ๊ณตํ†ต +โ”‚ โ””โ”€โ”€ src/main/java/com/ott/common/security/ +โ”‚ โ”œโ”€โ”€ jwt/ +โ”‚ โ””โ”€โ”€ oauth/ +โ”‚ +โ”œโ”€โ”€ docker-compose.yml +โ”œโ”€โ”€ settings.gradle +โ””โ”€โ”€ build.gradle +``` + +
+ +## 4. ํ•ต์‹ฌ ๊ธฐ์ˆ  ๋ฐ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +### 4.1 ์—…๋กœ๋“œ ๋ฐ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ํ”„๋กœ์„ธ์Šค (Event-Driven Ingest) +๋Œ€์šฉ๋Ÿ‰ ์˜์ƒ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ API ์„œ๋ฒ„์˜ I/O ๋ณ‘๋ชฉ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์ด๋ ‰ํŠธ ์—…๋กœ๋“œ ๋ฐ ๋น„๋™๊ธฐ ํ์ž‰ ๋ฐฉ์‹์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. + +image + + +1. ์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์š”์ฒญ: ์—๋””ํ„ฐ/๊ด€๋ฆฌ์ž๊ฐ€ API ์„œ๋ฒ„(api-admin)์— ์—…๋กœ๋“œ์šฉ Pre-signed URL์„ ์š”์ฒญํ•ฉ๋‹ˆ๋‹ค. + +2. Pre-signed URL ๋ฐœ๊ธ‰: API ์„œ๋ฒ„๊ฐ€ S3์šฉ Pre-signed URL์„ ์ƒ์„ฑ ํ›„ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + +3. ์›๋ณธ ์˜์ƒ ์—…๋กœ๋“œ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ฐœ๊ธ‰๋ฐ›์€ Pre-signed URL์„ ์‚ฌ์šฉํ•˜์—ฌ S3์— ์›๋ณธ ์˜์ƒ์„ ์ง์ ‘ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + +4. ์—…๋กœ๋“œ ์™„๋ฃŒ ์ด๋ฒคํŠธ ๋ฐœํ–‰: S3 ObjectCreated ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด EventBridge/Lambda๋ฅผ ๊ฑฐ์ณ SQS ํ์— ์—…๋กœ๋“œ ์™„๋ฃŒ ์ด๋ฒคํŠธ(์ž‘์—… ๋ฉ”์‹œ์ง€)๊ฐ€ ์ ์žฌ๋ฉ๋‹ˆ๋‹ค. + +5. ํŠธ๋žœ์Šค์ฝ”๋” ์ด๋ฒคํŠธ ์†Œ๋น„: ๊ฒฉ๋ฆฌ๋œ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์„œ๋ฒ„(Worker)๊ฐ€ SQS ํ ๋ฉ”์‹œ์ง€๋ฅผ ์ˆ˜์‹ (ํด๋ง)ํ•ฉ๋‹ˆ๋‹ค. + +6. ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์ž‘์—… ์ˆ˜ํ–‰: FFmpeg๋ฅผ ๊ตฌ๋™ํ•˜์—ฌ ์›๋ณธ ์˜์ƒ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•ด์ƒ๋„ ๋ฐ ๋น„ํŠธ๋ ˆ์ดํŠธ๋ณ„(360p, 720p, 1080p) ์ธ์ฝ”๋”ฉ์„ ๋™์‹œ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +7. HLS ํŒจํ‚ค์ง•: ์ŠคํŠธ๋ฆฌ๋ฐ์ด ๊ฐ€๋Šฅํ•œ HLS ํ˜•์‹์œผ๋กœ ํŒจํ‚ค์ง•ํ•˜์—ฌ .m3u8(Playlist) ๋ฐ .ts(Segment) ํŒŒ์ผ๋“ค์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + +8. ๊ฒฐ๊ณผ๋ฌผ ์—…๋กœ๋“œ: ํŒจํ‚ค์ง•์ด ์™„๋ฃŒ๋œ ์ตœ์ข… HLS ๊ฒฐ๊ณผ๋ฌผ์„ S3์— ์—…๋กœ๋“œํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + + +### 4.1 ์ŠคํŠธ๋ฆฌ๋ฐ(์˜์ƒ ์žฌ์ƒ) ํŒŒ์ดํ”„๋ผ์ธ (HLS & ABR) +์‚ฌ์šฉ์ž์˜ ๋””๋ฐ”์ด์Šค ๋ฐ ์‹ค์‹œ๊ฐ„ ๋„คํŠธ์›Œํฌ ํ™˜๊ฒฝ์— ๋งž์ถฐ ์ตœ์ ์˜ ํ™”์งˆ์„ ๋Š๊น€ ์—†์ด ์ œ๊ณตํ•˜๋Š” ABR(Adaptive Bitrate) ์žฌ์ƒ ํ”„๋กœ์„ธ์Šค์ž…๋‹ˆ๋‹ค. + +```mermaid +sequenceDiagram + autonumber + title HLS ์ŠคํŠธ๋ฆฌ๋ฐ ์žฌ์ƒ ํ๋ฆ„ + + actor User as ์‚ฌ์šฉ์ž + participant Player as ๋น„๋””์˜ค ํ”Œ๋ ˆ์ด์–ด
(hls.js) + participant ABR as ABR ์—”์ง„ + participant Buffer as ๋ฒ„ํผ ๊ด€๋ฆฌ์ž + participant CDN as CDN
(CloudFront) + participant S3 as Origin
(S3) + + %% 1. ์ดˆ๊ธฐํ™” ๋ฐ Master Playlist ์š”์ฒญ + rect rgb(232, 245, 233) + Note over User, S3: 1. ์ดˆ๊ธฐํ™” ๋ฐ Master Playlist ์š”์ฒญ + User->>Player: ์˜์ƒ ์žฌ์ƒ ํด๋ฆญ + activate Player + Player->>CDN: GET /video/{id}/master.m3u8 + activate CDN + + alt ์บ์‹œ ํžˆํŠธ + CDN-->>Player: master.m3u8 ๋ฐ˜ํ™˜ + else ์บ์‹œ ๋ฏธ์Šค + CDN->>S3: master.m3u8 ์š”์ฒญ + activate S3 + S3-->>CDN: master.m3u8 + deactivate S3 + CDN->>CDN: ์บ์‹œ ์ €์žฅ + CDN-->>Player: master.m3u8 ๋ฐ˜ํ™˜ + end + deactivate CDN + + Player->>Player: ํ™”์งˆ ๋ชฉ๋ก ํŒŒ์‹ฑ
(360p, 720p, 1080p) + end + + %% 2. ์ดˆ๊ธฐ ํ™”์งˆ ์„ ํƒ + rect rgb(227, 242, 253) + Note over User, S3: 2. ์ดˆ๊ธฐ ํ™”์งˆ ์„ ํƒ + Player->>ABR: ์ดˆ๊ธฐ ํ™”์งˆ ๊ฒฐ์ • ์š”์ฒญ + activate ABR + ABR->>ABR: ๋„คํŠธ์›Œํฌ ๋Œ€์—ญํญ ์ธก์ •
(3 Mbps) + ABR->>ABR: ์•ˆ์ „ ๋งˆ์ง„ ์ ์šฉ
(3 ร— 0.8 = 2.4 Mbps) + ABR-->>Player: 720p ์„ ํƒ
(BANDWIDTH=2500000) + deactivate ABR + end + + %% 3. Media Playlist ์š”์ฒญ + rect rgb(255, 243, 224) + Note over User, S3: 3. Media Playlist ์š”์ฒญ + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + activate CDN + CDN-->>Player: 720p playlist.m3u8 + deactivate CDN + Player->>Player: ์„ธ๊ทธ๋จผํŠธ ๋ชฉ๋ก ํŒŒ์‹ฑ
(segment_000.ts ~ segment_00N.ts) + end + + %% 4. ์„ธ๊ทธ๋จผํŠธ ์ˆœ์ฐจ ์š”์ฒญ ๋ฐ ์žฌ์ƒ + rect rgb(232, 245, 233) + Note over User, S3: 4. ์„ธ๊ทธ๋จผํŠธ ์ˆœ์ฐจ ์š”์ฒญ ๋ฐ ์žฌ์ƒ + loop ์„ธ๊ทธ๋จผํŠธ ๋‹ค์šด๋กœ๋“œ (์ •์ƒ ์ƒํƒœ) + Player->>CDN: GET /video/{id}/720p/segment_000.ts + activate CDN + CDN-->>Player: segment_000.ts (10์ดˆ ๋ถ„๋Ÿ‰) + deactivate CDN + + Player->>Buffer: ์„ธ๊ทธ๋จผํŠธ ์ถ”๊ฐ€ + activate Buffer + Buffer->>Buffer: ๋””์ฝ”๋”ฉ & ๋ฒ„ํผ๋ง + Buffer-->>Player: ๋ฒ„ํผ ์ƒํƒœ (25์ดˆ) + deactivate Buffer + + Player->>ABR: ๋‹ค์šด๋กœ๋“œ ํ†ต๊ณ„ ์ „๋‹ฌ
(์†๋„, ์‹œ๊ฐ„) + ABR->>ABR: ๋Œ€์—ญํญ ์—…๋ฐ์ดํŠธ + end + Player->>User: โ–ถ๏ธ ์žฌ์ƒ ์‹œ์ž‘ + end + + %% 5. ๋„คํŠธ์›Œํฌ ์ƒํƒœ ๋ณ€ํ™” ๊ฐ์ง€ + rect rgb(255, 235, 238) + Note over User, S3: 5. ๋„คํŠธ์›Œํฌ ์ƒํƒœ ๋ณ€ํ™” ๊ฐ์ง€ + Note over CDN: ๋„คํŠธ์›Œํฌ ๋Œ€์—ญํญ ์ €ํ•˜
3 Mbps โ†’ 1 Mbps + + Player->>CDN: GET /video/{id}/720p/segment_003.ts + activate CDN + CDN-->>Player: segment_003.ts
(๋‹ค์šด๋กœ๋“œ ์ง€์—ฐ ๋ฐœ์ƒ) + deactivate CDN + + Player->>ABR: ๋‹ค์šด๋กœ๋“œ ํ†ต๊ณ„ ์ „๋‹ฌ
(์†๋„ ์ €ํ•˜ ๊ฐ์ง€) + activate ABR + ABR->>ABR: ๋Œ€์—ญํญ ์žฌ์ธก์ •
(1 Mbps) + ABR->>Buffer: ๋ฒ„ํผ ์ƒํƒœ ํ™•์ธ + Buffer-->>ABR: ํ˜„์žฌ ๋ฒ„ํผ: 15์ดˆ + ABR->>ABR: ํ™”์งˆ ์ „ํ™˜ ๊ฒฐ์ •
(1 ร— 0.8 = 0.8 Mbps) + ABR-->>Player: 360p๋กœ ์ „ํ™˜ ์ง€์‹œ
(BANDWIDTH=800000) + deactivate ABR + end + + %% 6. ํ™”์งˆ ์ „ํ™˜ (ABR) + rect rgb(252, 228, 236) + Note over User, S3: 6. ํ™”์งˆ ์ „ํ™˜ (ABR) + Player->>CDN: GET /video/{id}/360p/playlist.m3u8 + activate CDN + CDN-->>Player: 360p playlist.m3u8 + deactivate CDN + + Player->>Player: ํ˜„์žฌ ์žฌ์ƒ ์œ„์น˜ ํ™•์ธ
(segment_004๋ถ€ํ„ฐ ํ•„์š”) + + Player->>CDN: GET /video/{id}/360p/segment_004.ts + activate CDN + CDN-->>Player: segment_004.ts (360p) + deactivate CDN + + Player->>Buffer: 360p ์„ธ๊ทธ๋จผํŠธ ์ถ”๊ฐ€ + Buffer->>Buffer: ๋Š๊น€ ์—†์ด ์ด์–ด์„œ ์žฌ์ƒ
(Seamless Switching) + + Note over Player, Buffer: 720p segment_003 โ†’ 360p segment_004
ํ™”์งˆ์€ ๋‚ฎ์•„์ง€์ง€๋งŒ ๋ฒ„ํผ๋ง ์—†์Œ + end + + %% 7. ๋„คํŠธ์›Œํฌ ๋ณต๊ตฌ ์‹œ + rect rgb(232, 245, 233) + Note over User, S3: 7. ๋„คํŠธ์›Œํฌ ๋ณต๊ตฌ ์‹œ + Note over CDN: ๋„คํŠธ์›Œํฌ ๋Œ€์—ญํญ ๋ณต๊ตฌ
1 Mbps โ†’ 4 Mbps + + loop ์„ธ๊ทธ๋จผํŠธ ๋‹ค์šด๋กœ๋“œ (๋ณต๊ตฌ ํ›„) + Player->>CDN: GET /video/{id}/360p/segment_005.ts + CDN-->>Player: segment_005.ts (๋น ๋ฅธ ๋‹ค์šด๋กœ๋“œ) + Player->>ABR: ๋‹ค์šด๋กœ๋“œ ํ†ต๊ณ„ ์ „๋‹ฌ + ABR->>ABR: ๋Œ€์—ญํญ ์žฌ์ธก์ • (4 Mbps) + ABR->>Buffer: ๋ฒ„ํผ ์ƒํƒœ ํ™•์ธ + Buffer-->>ABR: ํ˜„์žฌ ๋ฒ„ํผ: 30์ดˆ + end + + ABR->>ABR: ํ™”์งˆ ์ƒํ–ฅ ๊ฒฐ์ •
(๋ฒ„ํผ ์ถฉ๋ถ„ + ๋Œ€์—ญํญ ์—ฌ์œ ) + ABR-->>Player: 720p๋กœ ๋ณต๊ท€ ์ง€์‹œ + + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + CDN-->>Player: 720p playlist.m3u8 + + Player->>CDN: GET /video/{id}/720p/segment_006.ts + CDN-->>Player: segment_006.ts (720p) + + Note over Player: ๋‹ค์‹œ 720p๋กœ ํ™”์งˆ ๋ณต๊ท€ + deactivate Player + end + +``` + + +### ๐ŸŽฅ ํŒจํ‚ค์ง• ๊ฒฐ๊ณผ๋ฌผ (๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ) +FFmpeg๋ฅผ ํ†ตํ•ด ์ธ์ฝ”๋”ฉ ๋ฐ HLS ํŒจํ‚ค์ง•์ด ์™„๋ฃŒ๋œ ์˜์ƒ ๋ฐ์ดํ„ฐ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ S3 ๋ฒ„ํ‚ท์— ์ ์žฌ๋ฉ๋‹ˆ๋‹ค. +``` +์ž…๋ ฅ (์›๋ณธ) ์ถœ๋ ฅ (HLS) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interview.mp4 โ†’ transcoded/{videoId}/ +โ”œโ”€โ”€ H.264 ๋˜๋Š” ๊ธฐํƒ€ ์ฝ”๋ฑ โ”œโ”€โ”€ master.m3u8 +โ”œโ”€โ”€ 1080p โ”œโ”€โ”€ 360p/ +โ”œโ”€โ”€ 10 Mbps โ”‚ โ”œโ”€โ”€ playlist.m3u8 +โ””โ”€โ”€ 5๋ถ„ ๋‹จ์ผ ํŒŒ์ผ โ”‚ โ”œโ”€โ”€ segment_000.ts (1MB) + โ”‚ โ”œโ”€โ”€ segment_001.ts + โ”‚ โ””โ”€โ”€ ... + โ”œโ”€โ”€ 720p/ + โ”‚ โ”œโ”€โ”€ playlist.m3u8 + โ”‚ โ”œโ”€โ”€ segment_000.ts (3MB) + โ”‚ โ””โ”€โ”€ ... + โ””โ”€โ”€ 1080p/ + โ”œโ”€โ”€ playlist.m3u8 + โ”œโ”€โ”€ segment_000.ts (6MB) + โ””โ”€โ”€ ... +``` + + +
+ +## [Next Step / ํ–ฅํ›„ ๊ณ„ํš] +1์ฐจ MVP ๊ตฌํ˜„ ์ดํ›„, ์šด์˜ ์•ˆ์ •์„ฑ์„ ๊ทน๋Œ€ํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ณ ๋„ํ™”๋ฅผ ๊ณ„ํšํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +- **๋ชจ๋‹ˆํ„ฐ๋ง ๊ฐ•ํ™”:** Prometheus์™€ Grafana๋ฅผ ์—ฐ๋™ํ•˜์—ฌ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์›Œ์ปค์˜ CPU ์ž„๊ณ„์น˜ ์ดˆ๊ณผ ๋ฐ ABR ๋Œ€์—ญํญ ์ „ํ™˜ ํ†ต๊ณ„๋ฅผ ์‹œ๊ฐํ™”. + +- **DR (์žฌํ•ด ๋ณต๊ตฌ):** S3 Cross-Region Replication(๊ต์ฐจ ๋ฆฌ์ „ ๋ณต์ œ)์„ ํ™œ์šฉํ•œ ์ตœ์†Œํ•œ์˜ ์˜์ƒ ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์ƒ. + +- **Redis ๋„์ž… (์บ์‹ฑ ๋ฐ DB ์“ฐ๊ธฐ ๋ถ€ํ•˜ ๋ถ„์‚ฐ):** 10์ดˆ ๋‹จ์œ„์˜ ์ด์–ด๋ณด๊ธฐ ์œ„์น˜ ๊ฐฑ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ์ธ๋ฉ”๋ชจ๋ฆฌ๋กœ ์ฒ˜๋ฆฌ ํ›„ DB์— ์ผ๊ด„ ์ €์žฅ(Write-Behind)ํ•˜์—ฌ ์“ฐ๊ธฐ ๋ถ€ํ•˜๋ฅผ ๋ฐฉ์ง€ํ•˜๊ณ , ์‹ค์‹œ๊ฐ„ ์ธ๊ธฐ ์ฐจํŠธ ๋“ฑ ์กฐํšŒ ๋นˆ๋„๊ฐ€ ๋†’์€ ํ”ผ๋“œ๋ฅผ ์บ์‹ฑํ•˜์—ฌ ์‘๋‹ต ์†๋„๋ฅผ ๊ทน๋Œ€ํ™”ํ•  ๊ณ„ํš + +- **Kafka ๋„์ž… :** ๊ธฐ์กด SQS ๊ธฐ๋ฐ˜์˜ ๋‹จ์ˆœ ๋Œ€๊ธฐ์—ด์„ ๋„˜์–ด, ์˜์ƒ ์—…๋กœ๋“œ ์‹œ ํŠธ๋žœ์Šค์ฝ”๋”ฉ, ์˜์ƒ ๋ถ„์„, ์ธ๋„ค์ผ ์ถ”์ถœ ๋“ฑ ๋‹ค์ˆ˜์˜ ๋…๋ฆฝ์ ์ธ ์›Œ์ปค(Worker)๋“ค์ด ์ด๋ฒคํŠธ๋ฅผ ๋™์‹œ์— ์†Œ๋น„(Pub/Sub)ํ•˜๊ณ  ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ํ™•์žฅ์„ฑ ๋†’์€ ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆฌ๋ฐ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ตฌ์ถ•ํ•  ์˜ˆ์ • + + diff --git a/apps/api-admin/build.gradle b/apps/api-admin/build.gradle index faf3af7..3a531d7 100644 --- a/apps/api-admin/build.gradle +++ b/apps/api-admin/build.gradle @@ -2,7 +2,8 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') + implementation project(':modules:infra-s3') implementation project(':modules:common-web') implementation project(':modules:common-security') diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java new file mode 100644 index 0000000..38d14fe --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -0,0 +1,98 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Admin Auth API", description = "๊ด€๋ฆฌ์ž ์ธ์ฆ/์ธ๊ฐ€ API") +public interface AdminAuthApi { + + @Operation( + summary = "๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ", + description = """ + ์ด๋ฉ”์ผ/๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + + - ADMIN or EDITOR ๊ถŒํ•œ์„ ๊ฐ€์ง„ ๊ณ„์ •๋งŒ ๋กœ๊ทธ์ธ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + - ์‘๋‹ต Body์—๋Š” memberId์™€ role๋งŒ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค. + """ + + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "๋กœ๊ทธ์ธ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))), + + @ApiResponse( + responseCode = "400", + description = "์š”์ฒญ ๊ฐ’ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์‹คํŒจ (์ด๋ฉ”์ผ ํ˜•์‹ ์˜ค๋ฅ˜, ํ•„๋“œ ๋ˆ„๋ฝ ๋“ฑ)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "๊ด€๋ฆฌ์ž ๊ถŒํ•œ ์—†์Œ", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response); + + @Operation( + summary = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰", + description = """ + ์ฟ ํ‚ค์˜ refreshToken์„ ๊ฒ€์ฆํ•˜์—ฌ Access Token๊ณผ Refresh Token์„ ์žฌ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. + + - ์š”์ฒญ ์‹œ refreshToken ์ฟ ํ‚ค๊ฐ€ ๋ฐ˜๋“œ์‹œ ํฌํ•จ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + - ๋ณด์•ˆ์„ ์œ„ํ•ด Access Token๊ณผ Refresh Token์„ ๋ชจ๋‘ ์žฌ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. (Refresh Token Rotation) + - ์žฌ๋ฐœ๊ธ‰๋œ ํ† ํฐ์€ ๊ธฐ์กด ์ฟ ํ‚ค๋ฅผ ๋ฎ์–ด์”๋‹ˆ๋‹ค. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "์žฌ๋ฐœ๊ธ‰ ์„ฑ๊ณต"), + @ApiResponse( + responseCode = "401", + description = "refreshToken์ด ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ/์œ ํšจํ•˜์ง€ ์•Š์Œ", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity reissue( + HttpServletRequest request, HttpServletResponse response); + + @Operation( + summary = "๋กœ๊ทธ์•„์›ƒ", + description = """ + ๋กœ๊ทธ์ธ๋œ ๊ด€๋ฆฌ์ž๋ฅผ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + + - DB์— ์ €์žฅ๋œ refreshToken์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + - accessToken, refreshToken ์ฟ ํ‚ค๋ฅผ ์ฆ‰์‹œ ๋งŒ๋ฃŒ์‹œํ‚ต๋‹ˆ๋‹ค. + - ์ดํ›„ ํ•ด๋‹น ํ† ํฐ์œผ๋กœ๋Š” ์ธ์ฆ์ด ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต"), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java new file mode 100644 index 0000000..2207929 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -0,0 +1,98 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.common.security.util.CookieUtil; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.SuccessResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/back-office") +@RequiredArgsConstructor +public class AdminAuthController implements AdminAuthApi { + + private final AdminAuthService adminAuthService; + private final CookieUtil cookie; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + @Override + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response) { + + AdminLoginResponse loginResponse = adminAuthService.login(request); + + // ๋‘˜ ๋‹ค ์ฟ ํ‚ค๋กœ + cookie.addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + + // Body์—๋Š” memberId, role๋งŒ (ํ† ํฐ์€ @JsonIgnore) + return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); + } + + @Override + @PostMapping("/reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); + + cookie.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + @Override + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + adminAuthService.logout(memberId); + + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java new file mode 100644 index 0000000..a68bd06 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ ์š”์ฒญ") +public class AdminLoginRequest { + + @Email + @NotBlank + @Schema(type= "String",description = "๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ", example = "admin@ott.com") + private String email; + + @NotBlank + @Schema(type="String", description = "๋น„๋ฐ€๋ฒˆํ˜ธ", example = "password123") + private String password; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java new file mode 100644 index 0000000..413bb6e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ ์‘๋‹ต") +public class AdminLoginResponse { + + @JsonIgnore // ์ฟ ํ‚ค๋กœ ์ „๋‹ฌ โ€” JSON ์‘๋‹ต์—์„œ ์ œ์™ธ + private String accessToken; + + @JsonIgnore // ์ฟ ํ‚ค๋กœ ์ „๋‹ฌ โ€” JSON ์‘๋‹ต์—์„œ ์ œ์™ธ + private String refreshToken; + + @Schema(type = "Long", description = "ํšŒ์› ID", example = "1") + private Long memberId; + + @Schema(type= "String", description = "ํšŒ์› ์—ญํ• ", example = "ADMIN") + private String role; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java new file mode 100644 index 0000000..dda0cf4 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * reissue ์‹œ Service โ†’ Controller ํ† ํฐ ์ „๋‹ฌ์šฉ + */ +@Getter +@AllArgsConstructor +public class AdminTokenResponse { + + @Schema(type = "String", description = "accessToken", example = "122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") + private String accessToken; + + @Schema(type = "String", description = "refreshToken", example = "eym122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") + private String refreshToken; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java new file mode 100644 index 0000000..028d96a --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java @@ -0,0 +1,105 @@ +package com.ott.api_admin.auth.service; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.domain.Role; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + /** + * ๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ + * ํ† ํฐ์€ Controller์—์„œ ์ฟ ํ‚ค๋กœ ์„ธํŒ… + */ + public AdminLoginResponse login(AdminLoginRequest request) { + // 1. ์ด๋ฉ”์ผ + LOCAL provider๋กœ ํšŒ์› ์กฐํšŒ + Member member = memberRepository.findByEmailAndProvider(request.getEmail(), Provider.LOCAL) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 2. ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ +// String encodedPassword = member.getPassword(); +// if (encodedPassword == null || !passwordEncoder.matches(request.getPassword(), encodedPassword)) { +// throw new BusinessException(ErrorCode.UNAUTHORIZED, "์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); +// } + + + // 3. ๊ถŒํ•œ ํ™•์ธ (ADMIN, EDITOR๋งŒ ํ—ˆ์šฉ) + if (member.getRole() != Role.ADMIN && member.getRole() != Role.EDITOR) { + throw new BusinessException(ErrorCode.FORBIDDEN, "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + // 4. JWT ์ƒ์„ฑ + List authorities = List.of(member.getRole().getKey()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), authorities); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), authorities); + + // 5. refresh token DB ์ €์žฅ + member.updateRefreshToken(refreshToken); + + return AdminLoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .role(member.getRole().name()) + .build(); + } + + /** + * Access ๋ฐœ๊ธ‰ ์‹œ + Refresh Token ์žฌ๋ฐœ๊ธ‰ + */ + public AdminTokenResponse reissue(String refreshToken) { + // 1. refresh token ๊ฒ€์ฆ + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + if (errorCode != null) { + throw new BusinessException(errorCode); + } + + // 2. DB ํ† ํฐ ์ผ์น˜ ํ™•์ธ + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 3. ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰ + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // 4. refresh token ๊ฐฑ์‹  + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new AdminTokenResponse(newAccessToken, newRefreshToken); + } + + /** + * ๋กœ๊ทธ์•„์›ƒ โ€” DB refresh token ์‚ญ์ œ + */ + public void logout(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.clearRefreshToken(); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java new file mode 100644 index 0000000..fd37782 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java @@ -0,0 +1,32 @@ +package com.ott.api_admin.category.controller; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "BackOffice Category API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ API") +public interface BackOfficeCategoryApi { + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ", description = "์ „์ฒด ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = CategoryListResponse.class)))} + ), + @ApiResponse( + responseCode = "400", description = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getCategoryList(); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java new file mode 100644 index 0000000..035c27b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java @@ -0,0 +1,28 @@ +package com.ott.api_admin.category.controller; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.api_admin.category.service.BackOfficeCategoryService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/back-office/admin/categories") +@RequiredArgsConstructor +public class BackOfficeCategoryController implements BackOfficeCategoryApi { + + private final BackOfficeCategoryService backOfficeCategoryService; + + @Override + @GetMapping + public ResponseEntity>> getCategoryList() { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeCategoryService.getCategoryList()) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java new file mode 100644 index 0000000..364c0dd --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.category.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record CategoryListResponse( + + @Schema(type = "Long", description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") + Long categoryId, + + @Schema(type = "String", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ช…", example = "๋“œ๋ผ๋งˆ") + String categoryName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java new file mode 100644 index 0000000..87d9625 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java @@ -0,0 +1,16 @@ +package com.ott.api_admin.category.mapper; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.domain.category.domain.Category; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeCategoryMapper { + + public CategoryListResponse toCategoryListResponse(Category category) { + return new CategoryListResponse( + category.getId(), + category.getName() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java new file mode 100644 index 0000000..7fd68d5 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.category.service; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.api_admin.category.mapper.BackOfficeCategoryMapper; +import com.ott.domain.category.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackOfficeCategoryService { + + private final BackOfficeCategoryMapper backOfficeCategoryMapper; + + private final CategoryRepository categoryRepository; + + @Transactional(readOnly = true) + public List getCategoryList() { + return categoryRepository.findAll().stream() + .map(backOfficeCategoryMapper::toCategoryListResponse) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java new file mode 100644 index 0000000..c04f6c4 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -0,0 +1,96 @@ +package com.ott.api_admin.config; + +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + + .authorizeHttpRequests(auth -> auth + // ์ธ์ฆ ๋ถˆํ•„์š” + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", + "/back-office/login", + "/back-office/reissue", + "/back-office/swagger-ui.html", + "/back-office/swagger-ui/**", + "/back-office/v3/api-docs", + "/back-office/v3/api-docs/**", + "/back-office/swagger-resources/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/back-office/swagger-ui/**", + "/back-office/v3/api-docs/**", + "/back-office/swagger-resources/**" + + + ).permitAll() + .requestMatchers("/back-office/admin/**").hasRole("ADMIN") + .anyRequest().hasAnyRole("ADMIN", "EDITOR") + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java new file mode 100644 index 0000000..cd7aaf0 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -0,0 +1,118 @@ +package com.ott.api_admin.content.controller; + +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +@Tag(name = "BackOffice Contents API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ API") +public interface BackOfficeContentsApi { + + @Operation(summary = "์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ", description = "์ฝ˜ํ…์ธ  ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentsListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity>> getContents( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, + @Parameter(description = "๊ณต๊ฐœ ์—ฌ๋ถ€. ๊ณต๊ฐœ/๋น„๊ณต๊ฐœ๋กœ ๋‚˜๋‰ฉ๋‹ˆ๋‹ค.", required = false, example = "PUBLIC") @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus + ); + + @Operation(summary = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ", description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsDetailResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> getContentsDetail( + @Parameter(description = "์กฐํšŒํ•  ์ฝ˜ํ…์ธ ์˜ ๋ฏธ๋””์–ด ID", required = true) @PathVariable("mediaId") Long mediaId + ); + + @Operation(summary = "์ฝ˜ํ…์ธ  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ", description = "์ฝ˜ํ…์ธ  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  S3 ์—…๋กœ๋“œ์šฉ Presigned URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ฝ˜ํ…์ธ  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ฝ˜ํ…์ธ  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createContentsUpload( + @Parameter(description = "ContentsUploadRequest๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @Valid @RequestBody ContentsUploadRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์ฝ˜ํ…์ธ  ์ˆ˜์ •", description = "์ฝ˜ํ…์ธ  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  ํ•„์š” ์‹œ ํŒŒ์ผ ๊ต์ฒด์šฉ Presigned URL์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ฝ˜ํ…์ธ  ์ˆ˜์ • ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsUpdateResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ฝ˜ํ…์ธ  ์ˆ˜์ • ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> updateContentsUpload( + @Parameter(description = "์ˆ˜์ • ๋Œ€์ƒ ์ฝ˜ํ…์ธ ์˜ ID", required = true, example = "1") + @PathVariable("contentsId") Long contentsId, + + @Parameter(description = "ContentsUpdateRequest๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @Valid @RequestBody ContentsUpdateRequest request + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java new file mode 100644 index 0000000..69cb05f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -0,0 +1,73 @@ +package com.ott.api_admin.content.controller; + +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; +import com.ott.api_admin.content.service.BackOfficeContentsService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.common.PublicStatus; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/back-office/admin/contents") +@RequiredArgsConstructor +public class BackOfficeContentsController implements BackOfficeContentsApi { + + private final BackOfficeContentsService backOfficeContentsService; + + @Override + @GetMapping + public ResponseEntity>> getContents( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeContentsService.getContents(page, size, searchWord, publicStatus)) + ); + } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getContentsDetail( + @PathVariable("mediaId") Long mediaId + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) + ); + } + + @Override + @PostMapping("/upload") + public ResponseEntity> createContentsUpload( + @Valid @RequestBody ContentsUploadRequest request, + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request, memberId))); + } + + @Override + @PatchMapping("/{contentsId}/upload") + public ResponseEntity> updateContentsUpload( + @PathVariable("contentsId") Long contentsId, + @Valid @RequestBody ContentsUpdateRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.updateContentsUpload(contentsId, request))); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java new file mode 100644 index 0000000..4b7ab07 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -0,0 +1,59 @@ +package com.ott.api_admin.content.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +import java.util.List; + +@Schema(description = "์ฝ˜ํ…์ธ  ์ˆ˜์ • ์š”์ฒญ") +public record ContentsUpdateRequest( + @Schema(type = "Long", description = "์—ฐ๊ฒฐํ•  ์‹œ๋ฆฌ์ฆˆ ID(์„ ํƒ)", example = "1") + @Positive + Long seriesId, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "์‘๋‹ตํ•˜๋ผ 1988 1ํ™”") + @NotBlank + String title, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์„ค๋ช…", example = "๊ฐ€์กฑ๊ณผ ์ด์›ƒ์˜ ๋”ฐ๋œปํ•œ ์ด์•ผ๊ธฐ") + @NotBlank + String description, + + @Schema(type = "String", description = "์ถœ์—ฐ์ง„", example = "์„ฑ๋™์ผ, ์ด์ผํ™”") + @NotBlank + String actors, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "ํƒœ๊ทธ ID ๋ชฉ๋ก", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "3600") + Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "512000") + Integer videoSize, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์›๋ณธ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์›๋ณธ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "thumb-new.jpg") + String thumbnailFileName, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "origin-new.mp4") + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java new file mode 100644 index 0000000..2dee9c1 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -0,0 +1,63 @@ +package com.ott.api_admin.content.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +import java.util.List; + +@Schema(description = "์ฝ˜ํ…์ธ  ์—…๋กœ๋“œ ์š”์ฒญ") +public record ContentsUploadRequest( + @Schema(type = "Long", description = "์—ฐ๊ฒฐํ•  ์‹œ๋ฆฌ์ฆˆ ID(์„ ํƒ)", example = "1") + Long seriesId, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "์‘๋‹ตํ•˜๋ผ 1988 1ํ™”") + @NotBlank + String title, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์„ค๋ช…", example = "๊ฐ€์กฑ๊ณผ ์ด์›ƒ์˜ ๋”ฐ๋œปํ•œ ์ด์•ผ๊ธฐ") + @NotBlank + String description, + + @Schema(type = "String", description = "์ถœ์—ฐ์ง„", example = "์„ฑ๋™์ผ, ์ด์ผํ™”") + @NotBlank + String actors, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "ํƒœ๊ทธ ID ๋ชฉ๋ก", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "3600") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "512000") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ํŒŒ์ผ๋ช…", example = "origin.mp4") + @NotBlank + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java new file mode 100644 index 0000000..461294e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -0,0 +1,42 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +import com.ott.domain.common.PublicStatus; + +@Schema(description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ ์‘๋‹ต") +public record ContentsDetailResponse( + + @Schema(type = "Long", description = "์ฝ˜ํ…์ธ  ID", example = "1") Long contentsId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ URL", example = "https://cdn.example.com/poster.jpg") String posterUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ URL", example = "https://cdn.example.com/thumb.jpg") String thumbnailUrl, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "๊ธฐ์ƒ์ถฉ") String title, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์„ค๋ช…", example = "๋ด‰์ค€ํ˜ธ ๊ฐ๋…์˜ ๋ธ”๋ž™์ฝ”๋ฏธ๋”” ์Šค๋ฆด๋Ÿฌ") String description, + + @Schema(type = "String", description = "์ถœ์—ฐ์ง„", example = "์†ก๊ฐ•ํ˜ธ, ์ด์„ ๊ท ") String actors, + + @Schema(type = "String", description = "์†Œ์† ์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ (์—†์œผ๋ฉด null)", example = "๋น„๋ฐ€์˜ ์ˆฒ") String seriesTitle, + + @Schema(type = "String", description = "์—…๋กœ๋” ๋‹‰๋„ค์ž„", example = "๊ด€๋ฆฌ์ž") String uploaderNickname, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "7200") Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "1048576") Integer videoSize, + + @Schema(type = "String", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ช…", example = "๋“œ๋ผ๋งˆ") String categoryName, + + @Schema(type = "List", description = "ํƒœ๊ทธ ์ด๋ฆ„ ๋ชฉ๋ก", example = "[\"์Šค๋ฆด๋Ÿฌ\", \"์ถ”๋ฆฌ\"]") List tagNameList, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") PublicStatus publicStatus, + + @Schema(type = "Long", description = "๋ถ๋งˆํฌ ์ˆ˜", example = "150") Long bookmarkCount, + + @Schema(type = "LocalDate", description = "์—…๋กœ๋“œ์ผ", example = "2026-01-15") LocalDate uploadedDate) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java new file mode 100644 index 0000000..4a02179 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +import com.ott.domain.common.PublicStatus; + +@Schema(description = "์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record ContentsListResponse( + + @Schema(type = "Long", description = "๋ฏธ๋””์–ด ID", example = "1") Long mediaId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ(์„ธ๋กœ, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") String posterUrl, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "๊ธฐ์ƒ์ถฉ") String title, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") PublicStatus publicStatus, + + @Schema(type = "LocalDate", description = "์—…๋กœ๋“œ์ผ", example = "2026-01-15") LocalDate uploadedDate) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java new file mode 100644 index 0000000..f5e625e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์ฝ˜ํ…์ธ  ์ˆ˜์ • ์‘๋‹ต") +public record ContentsUpdateResponse( + @Schema(type = "Long", description = "์ฝ˜ํ…์ธ  ID", example = "10") + Long contentsId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "contents/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "contents/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "contents/10/origin/origin-new.mp4") + String originObjectKey, + + @Schema(type = "String", description = "๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ Object Key", example = "contents/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/contents/10/poster/poster-new.jpg?X-Amz-...") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/contents/10/thumbnail/thumb-new.jpg?X-Amz-...") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/contents/10/origin/origin-new.mp4?X-Amz-...") + String originUploadUrl +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java new file mode 100644 index 0000000..27b04be --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์ฝ˜ํ…์ธ  ์—…๋กœ๋“œ ์‘๋‹ต") +public record ContentsUploadResponse( + @Schema(type = "Long", description = "์ƒ์„ฑ๋œ ์ฝ˜ํ…์ธ  ID", example = "10") + Long contentsId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ S3 object key", example = "contents/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ S3 object key", example = "contents/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ S3 object key", example = "contents/10/origin/origin.mp4") + String originObjectKey, + + @Schema(type = "String", description = "ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ object key", example = "contents/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java new file mode 100644 index 0000000..fdb3fc1 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -0,0 +1,106 @@ +package com.ott.api_admin.content.mapper; + +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class BackOfficeContentsMapper { + + public ContentsListResponse toContentsListResponse(Media media) { + return new ContentsListResponse( + media.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getPublicStatus(), + media.getCreatedDate().toLocalDate() + ); + } + + public ContentsDetailResponse toContentsDetailResponse(Contents contents, Media media, String uploaderNickname, String seriesTitle, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new ContentsDetailResponse( + contents.getId(), + media.getPosterUrl(), + media.getThumbnailUrl(), + media.getTitle(), + media.getDescription(), + contents.getActors(), + seriesTitle, + uploaderNickname, + contents.getDuration(), + contents.getVideoSize(), + categoryName, + tagNameList, + media.getPublicStatus(), + media.getBookmarkCount(), + media.getCreatedDate().toLocalDate() + ); + } + + public ContentsUploadResponse toContentsUploadResponse( + Long contentsId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ContentsUploadResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + + public ContentsUpdateResponse toContentsUpdateResponse( + Long contentsId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ContentsUpdateResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java new file mode 100644 index 0000000..1ccb9bb --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -0,0 +1,216 @@ +package com.ott.api_admin.content.service; + +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; +import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +import com.ott.api_admin.upload.support.MediaTagLinker; +import com.ott.api_admin.upload.support.UploadHelper; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class BackOfficeContentsService { + + private final BackOfficeContentsMapper backOfficeContentsMapper; + + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final ContentsRepository contentsRepository; + private final SeriesRepository seriesRepository; + private final UploadHelper uploadHelper; + private final MediaTagLinker mediaTagLinker; + + @Transactional(readOnly = true) + public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { + Pageable pageable = PageRequest.of(page, size); + + // ๋ฏธ๋””์–ด ์ค‘ ์ฝ˜ํ…์ธ  ๋Œ€์ƒ ํŽ˜์ด์ง• + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus( + pageable, + MediaType.CONTENTS, + searchWord, + publicStatus + ); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeContentsMapper::toContentsListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public ContentsDetailResponse getContentsDetail(Long mediaId) { + Contents contents = contentsRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Media media = contents.getMedia(); + String uploaderNickname = media.getUploader().getNickname(); + + // 2. ์†Œ์† ์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ ๋ฐ ํƒœ๊ทธ ์ถ”์ถœ + Long originMediaId = mediaId; + String seriesTitle = null; + if (contents.getSeries() != null) { + Media originMedia = contents.getSeries().getMedia(); + originMediaId = originMedia.getId(); + seriesTitle = originMedia.getTitle(); + } + + // 3. ํƒœ๊ทธ ์กฐํšŒ + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); + + return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); + } + + @Transactional + // ์ฝ˜ํ…์ธ /๋ฏธ๋””์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  S3 ์—…๋กœ๋“œ์šฉ Presigned URL์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. + public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request, Long memberId) { + Member uploader = uploadHelper.resolveUploader(memberId); + Series series = resolveSeries(request.seriesId()); + + // S3 object key ์•ˆ์ •์„ฑ์„ ์œ„ํ•ด ํŒŒ์ผ๋ช…์„ ์ •๊ทœํ™”ํ•ฉ๋‹ˆ๋‹ค. + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // ์ฝ˜ํ…์ธ  ID ์ƒ์„ฑ ์ „์ด๋ผ ์ตœ์ข… URL์„ ๋งŒ๋“ค ์ˆ˜ ์—†์–ด ์ž„์‹œ๊ฐ’์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + .posterUrl("PENDING") + // ์ฝ˜ํ…์ธ  ID ๊ธฐ๋ฐ˜ object key ํ™•์ • ํ›„ ์‹ค์ œ S3 URL๋กœ ์ฆ‰์‹œ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.CONTENTS) + .publicStatus(request.publicStatus()) + .build() + ); + + Contents contents = contentsRepository.save( + Contents.builder() + .media(media) + .series(series) + .actors(request.actors()) + .duration(request.duration()) + .videoSize(request.videoSize()) + // ์ฝ˜ํ…์ธ  ID ์ƒ์„ฑ ์ „์ด๋ผ ์›๋ณธ URL์„ ํ™•์ •ํ•  ์ˆ˜ ์—†์–ด ์ž„์‹œ๊ฐ’์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + .originUrl("PENDING") + // ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๊ฒฐ๊ณผ URL๋„ ID ๊ธฐ๋ฐ˜ ๊ฒฝ๋กœ ๊ณ„์‚ฐ ํ›„ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. + .masterPlaylistUrl("PENDING") + .build() + ); + + Long contentsId = contents.getId(); + UploadHelper.MediaCreateUploadResult mediaCreateUploadResult = uploadHelper.prepareMediaCreate( + "contents", contentsId, request.posterFileName(), request.thumbnailFileName(), request.originFileName() + ); + + media.updateImageKeys( + mediaCreateUploadResult.posterObjectUrl(), + mediaCreateUploadResult.thumbnailObjectUrl() + ); + contents.updateStorageKeys( + mediaCreateUploadResult.originObjectUrl(), + mediaCreateUploadResult.masterPlaylistObjectUrl() + ); + + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeContentsMapper.toContentsUploadResponse( + contentsId, + mediaCreateUploadResult.posterObjectKey(), + mediaCreateUploadResult.thumbnailObjectKey(), + mediaCreateUploadResult.originObjectKey(), + mediaCreateUploadResult.masterPlaylistObjectKey(), + mediaCreateUploadResult.posterUploadUrl(), + mediaCreateUploadResult.thumbnailUploadUrl(), + mediaCreateUploadResult.originUploadUrl() + ); + } + + @Transactional + public ContentsUpdateResponse updateContentsUpload(Long contentsId, ContentsUpdateRequest request) { + Contents contents = contentsRepository.findWithMediaById(contentsId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Media media = contents.getMedia(); + Series series = resolveSeries(request.seriesId()); + + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + contents.updateMetadata(series, request.actors(), request.duration(), request.videoSize()); + + UploadHelper.MediaUpdateUploadResult mediaUpdateUploadResult = uploadHelper.prepareMediaUpdate( + "contents", + contentsId, + request.posterFileName(), + request.thumbnailFileName(), + request.originFileName(), + media.getPosterUrl(), + media.getThumbnailUrl(), + contents.getOriginUrl(), + contents.getMasterPlaylistUrl() + ); + + media.updateImageKeys( + mediaUpdateUploadResult.nextPosterUrl(), + mediaUpdateUploadResult.nextThumbnailUrl() + ); + contents.updateStorageKeys( + mediaUpdateUploadResult.nextOriginUrl(), + mediaUpdateUploadResult.nextMasterPlaylistUrl() + ); + + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeContentsMapper.toContentsUpdateResponse( + contentsId, + mediaUpdateUploadResult.posterObjectKey(), + mediaUpdateUploadResult.thumbnailObjectKey(), + mediaUpdateUploadResult.originObjectKey(), + mediaUpdateUploadResult.masterPlaylistObjectKey(), + mediaUpdateUploadResult.posterUploadUrl(), + mediaUpdateUploadResult.thumbnailUploadUrl(), + mediaUpdateUploadResult.originUploadUrl() + ); + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + // ์š”์ฒญ์œผ๋กœ ์ „๋‹ฌ๋œ seriesId์˜ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java new file mode 100644 index 0000000..8d5b8de --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java @@ -0,0 +1,43 @@ +package com.ott.api_admin.ingest_job.controller; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BackOffice IngestJob API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์—…๋กœ๋“œ ์ž‘์—… ๊ด€๋ฆฌ API") +public interface BackOfficeIngestJobApi { + + @Operation(summary = "์—…๋กœ๋“œ ์ž‘์—… ๋ชฉ๋ก ์กฐํšŒ", description = "์—…๋กœ๋“œ ์ž‘์—… ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ADMIN์€ ์ „์ฒด, EDITOR๋Š” ๋ณธ์ธ ์—…๋กœ๋“œ๋งŒ ์กฐํšŒ๋ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = IngestJobListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์—…๋กœ๋“œ ์ž‘์—… ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์—…๋กœ๋“œ ์ž‘์—… ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getIngestJobList( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, + Authentication authentication + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java new file mode 100644 index 0000000..c31882f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java @@ -0,0 +1,34 @@ +package com.ott.api_admin.ingest_job.controller; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.api_admin.ingest_job.service.BackOfficeIngestJobService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/back-office/ingest-jobs") +@RequiredArgsConstructor +public class BackOfficeIngestJobController implements BackOfficeIngestJobApi { + + private final BackOfficeIngestJobService backOfficeIngestJobService; + + @Override + @GetMapping + public ResponseEntity>> getIngestJobList( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeIngestJobService.getIngestJobList(page, size, searchWord, authentication)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java new file mode 100644 index 0000000..ffef747 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_admin.ingest_job.dto.response; + +import com.ott.domain.ingest_job.domain.IngestStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์—…๋กœ๋“œ ์ž‘์—… ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record IngestJobListResponse( + + @Schema(type = "Long", description = "์ž‘์—… ID", example = "1") + Long ingestJobId, + + @Schema(type = "String", description = "๋ฏธ๋””์–ด(์‹œ๋ฆฌ์ฆˆ/์ฝ˜ํ…์ธ /์ˆํผ) ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ 1ํ™”") + String title, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "1048576") + Integer videoSize, + + @Schema(type = "String", description = "์—…๋กœ๋” ๋‹‰๋„ค์ž„", example = "ํ™๊ธธ๋™") + String uploaderName, + + @Schema(type = "String", description = "์ž‘์—… ์ƒํƒœ", example = "TRANSCODING") + IngestStatus ingestStatus, + + @Schema(type = "Integer", description = "์ง„ํ–‰๋ฅ  (%)", example = "0") + Integer progress +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java new file mode 100644 index 0000000..e87f15c --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java @@ -0,0 +1,25 @@ +package com.ott.api_admin.ingest_job.mapper; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.domain.ingest_job.domain.IngestJob; +import com.ott.domain.media.domain.Media; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class BackOfficeIngestJobMapper { + + public IngestJobListResponse toIngestJobListResponse(IngestJob ingestJob, Map videoSizeByMediaId) { + Media media = ingestJob.getMedia(); + + return new IngestJobListResponse( + ingestJob.getId(), + media.getTitle(), + videoSizeByMediaId.getOrDefault(media.getId(), null), + media.getUploader().getNickname(), + ingestJob.getIngestStatus(), + 0 + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java new file mode 100644 index 0000000..34483ef --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java @@ -0,0 +1,100 @@ +package com.ott.api_admin.ingest_job.service; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.api_admin.ingest_job.mapper.BackOfficeIngestJobMapper; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.ingest_job.domain.IngestJob; +import com.ott.domain.ingest_job.repository.IngestJobRepository; +import com.ott.domain.member.domain.Role; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BackOfficeIngestJobService { + + private final BackOfficeIngestJobMapper backOfficeIngestJobMapper; + + private final IngestJobRepository ingestJobRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + + @Transactional(readOnly = true) + public PageResponse getIngestJobList( + Integer page, Integer size, String searchWord, Authentication authentication + ) { + Pageable pageable = PageRequest.of(page, size); + + // 1. ๊ด€๋ฆฌ์ž/์—๋””ํ„ฐ ์—ฌ๋ถ€ ํ™•์ธ + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + // 2. ์—๋””ํ„ฐ์ธ ๊ฒฝ์šฐ ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ์ž‘์—…๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅ + if (isEditor) + uploaderId = memberId; + + // 2. IngestJob ํŽ˜์ด์ง• ์กฐํšŒ (Media + Uploader fetchJoin) + Page ingestJobPage = ingestJobRepository.findIngestJobListWithMediaBySearchWordAndUploaderId( + pageable, searchWord, uploaderId + ); + + List ingestJobList = ingestJobPage.getContent(); + + // 3. ํƒ€์ž…๋ณ„๋กœ mediaId ๋ถ„๋ฆฌ + List contentsMediaIdList = ingestJobList.stream() + .filter(j -> j.getMedia().getMediaType() == MediaType.CONTENTS) + .map(j -> j.getMedia().getId()) + .toList(); + + List shortFormMediaIdList = ingestJobList.stream() + .filter(j -> j.getMedia().getMediaType() == MediaType.SHORT_FORM) + .map(j -> j.getMedia().getId()) + .toList(); + + // 4. ์ผ๊ด„ ์กฐํšŒ: mediaId โ†’ videoSize ๋งคํ•‘ + Map videoSizeByMediaId = new HashMap<>(); + + // 5. ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฉด ์กฐํšŒ x + if (!contentsMediaIdList.isEmpty()) { + contentsRepository.findAllByMediaIdIn(contentsMediaIdList).forEach( + c -> videoSizeByMediaId.put(c.getMedia().getId(), c.getVideoSize()) + ); + } + + if (!shortFormMediaIdList.isEmpty()) { + shortFormRepository.findAllByMediaIdIn(shortFormMediaIdList).forEach( + s -> videoSizeByMediaId.put(s.getMedia().getId(), s.getVideoSize()) + ); + } + + List responseList = ingestJobList.stream() + .map(j -> backOfficeIngestJobMapper.toIngestJobListResponse(j, videoSizeByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + ingestJobPage.getNumber(), + ingestJobPage.getTotalPages(), + ingestJobPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java new file mode 100644 index 0000000..c767c87 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java @@ -0,0 +1,74 @@ +package com.ott.api_admin.member.controller; + +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.member.domain.Role; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BackOffice Member API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API") +public interface BackOfficeMemberApi { + + @Operation(summary = "์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ", description = "์‚ฌ์šฉ์ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = MemberListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity>> getMemberList( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "๋‹‰๋„ค์ž„ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, + @Parameter(description = "์—ญํ•  ํ•„ํ„ฐ. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ์—ญํ• ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false, example = "MEMBER") @RequestParam(value = "role", required = false) Role role + ); + + @Operation(summary = "์‚ฌ์šฉ์ž ์—ญํ•  ๋ณ€๊ฒฝ", description = "์‚ฌ์šฉ์ž์˜ ์—ญํ• ์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. EDITOR โ†” SUSPENDED ์ „ํ™˜๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "์—ญํ•  ๋ณ€๊ฒฝ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Void.class))} + ), + @ApiResponse( + responseCode = "400", description = "ํ—ˆ์šฉ๋˜์ง€ ์•Š๋Š” ์—ญํ•  ๋ณ€๊ฒฝ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ), + @ApiResponse( + responseCode = "404", description = "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity changeRole( + @Parameter(description = "์‚ฌ์šฉ์ž ID", required = true, example = "1") @PathVariable Long memberId, + @RequestBody ChangeRoleRequest changeRoleRequest + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java new file mode 100644 index 0000000..c455d48 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -0,0 +1,43 @@ +package com.ott.api_admin.member.controller; + +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.api_admin.member.service.BackOfficeMemberService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.member.domain.Role; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/back-office/admin/members") +@RequiredArgsConstructor +public class BackOfficeMemberController implements BackOfficeMemberApi { + + private final BackOfficeMemberService backOfficeMemberService; + + @Override + @GetMapping + public ResponseEntity>> getMemberList( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + @RequestParam(value = "role", required = false) Role role + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeMemberService.getMemberList(page, size, searchWord, role)) + ); + } + + @Override + @PatchMapping("/{memberId}/role") + public ResponseEntity changeRole( + @PathVariable("memberId") Long memberId, + @Valid @RequestBody ChangeRoleRequest changeRoleRequest + ) { + backOfficeMemberService.changeRole(memberId, changeRoleRequest); + return ResponseEntity.noContent().build(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java new file mode 100644 index 0000000..51188d6 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.member.dto.request; + +import com.ott.domain.member.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "์‚ฌ์šฉ์ž ์—ญํ•  ๋ณ€๊ฒฝ ์š”์ฒญ") +public record ChangeRoleRequest( + + @NotNull(message = "๋ณ€๊ฒฝํ•  ์—ญํ• ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Schema(description = "๋ณ€๊ฒฝํ•  ์—ญํ•  (EDITOR ๋˜๋Š” SUSPENDED)", example = "SUSPENDED") + Role role +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java new file mode 100644 index 0000000..907d72e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.member.dto.response; + +import com.ott.domain.member.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record MemberListResponse( + + @Schema(type = "Long", description = "์‚ฌ์šฉ์ž ID", example = "1") + Long memberId, + + @Schema(type = "String", description = "๋‹‰๋„ค์ž„", example = "ํ™๊ธธ๋™") + String nickname, + + @Schema(type = "String", description = "์ด๋ฉ”์ผ", example = "user@example.com") + String email, + + @Schema(type = "String", description = "์—ญํ• ", example = "MEMBER") + Role role, + + @Schema(type = "String", description = "๊ฐ€์ž…์ผ", example = "2026-01-15") + LocalDate createdDate +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java new file mode 100644 index 0000000..5423c31 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.member.mapper; + +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.domain.member.domain.Member; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeMemberMapper { + + public MemberListResponse toMemberListResponse(Member member) { + return new MemberListResponse( + member.getId(), + member.getNickname(), + member.getEmail(), + member.getRole(), + member.getCreatedDate().toLocalDate() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java new file mode 100644 index 0000000..7e6bfb3 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java @@ -0,0 +1,55 @@ +package com.ott.api_admin.member.service; + +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.api_admin.member.mapper.BackOfficeMemberMapper; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class BackOfficeMemberService { + + private final BackOfficeMemberMapper backOfficeMemberMapper; + + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public PageResponse getMemberList(int page, int size, String searchWord, Role role) { + Pageable pageable = PageRequest.of(page, size); + + Page memberPage = memberRepository.findMemberList(pageable, searchWord, role); + + List responseList = memberPage.getContent().stream() + .map(backOfficeMemberMapper::toMemberListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + memberPage.getNumber(), + memberPage.getTotalPages(), + memberPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional + public void changeRole(Long memberId, ChangeRoleRequest changeRoleRequest) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + member.changeRole(changeRoleRequest.role()); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 1c4b0e5..b6edb9b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -1,7 +1,12 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -13,8 +18,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Series API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์‹œ๋ฆฌ์ฆˆ ๊ด€๋ฆฌ API") @@ -45,6 +53,31 @@ ResponseEntity>> getSeries( @Parameter(description = "์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord ); + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ (์ฝ˜ํ…์ธ  ์—…๋กœ๋“œ ํŽ˜์ด์ง€)", description = "์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesTitleListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity>> getSeriesTitle( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord + ); + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ์กฐํšŒ", description = "์‹œ๋ฆฌ์ฆˆ์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - ADMIN ๊ถŒํ•œ ํ•„์š”.") @ApiResponses(value = { @ApiResponse( @@ -61,6 +94,50 @@ ResponseEntity>> getSeries( ) }) ResponseEntity> getSeriesDetail( - @Parameter(description = "์‹œ๋ฆฌ์ฆˆ ID", required = true, example = "1") @PathVariable Long seriesId + @Parameter(description = "๋ฏธ๋””์–ด ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId + ); + + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ", description = "์‹œ๋ฆฌ์ฆˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  S3 ์—…๋กœ๋“œ์šฉ Presigned URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์‹œ๋ฆฌ์ฆˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = SeriesUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์‹œ๋ฆฌ์ฆˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createSeriesUpload( + @Parameter(description = "SeriesUploadRequest๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @RequestBody SeriesUploadRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ์ˆ˜์ •", description = "์‹œ๋ฆฌ์ฆˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  ํ•„์š” ์‹œ ํฌ์Šคํ„ฐ/์ธ๋„ค์ผ ๊ต์ฒด์šฉ Presigned URL์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์‹œ๋ฆฌ์ฆˆ ์ˆ˜์ • ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = SeriesUpdateResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์‹œ๋ฆฌ์ฆˆ ์ˆ˜์ • ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> updateSeriesUpload( + @Parameter(description = "์ˆ˜์ • ๋Œ€์ƒ ์‹œ๋ฆฌ์ฆˆ ID", required = true, example = "1") + @PathVariable("seriesId") Long seriesId, + + @Parameter(description = "SeriesUpdateRequest๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @Valid @RequestBody SeriesUpdateRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 30fccf5..9944d4c 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,23 +1,37 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/back-office") +@RequestMapping("/back-office/admin/series") @RequiredArgsConstructor public class BackOfficeSeriesController implements BackOfficeSeriesApi { private final BackOfficeSeriesService backOfficeSeriesService; @Override - @GetMapping("/admin/series") + @GetMapping public ResponseEntity>> getSeries( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -29,10 +43,40 @@ public ResponseEntity>> getSeri } @Override - @GetMapping("/admin/series/{seriesId}") - public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { + @GetMapping("/titles") + public ResponseEntity>> getSeriesTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeSeriesService.getSeriesTitle(page, size, searchWord)) + ); + } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getSeriesDetail(@PathVariable("mediaId") Long mediaId) { return ResponseEntity.ok( - SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) + SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) ); } + + @Override + @PostMapping("/upload") + public ResponseEntity> createSeriesUpload( + @Valid @RequestBody SeriesUploadRequest request, + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request, memberId))); + } + + @Override + @PatchMapping("/{seriesId}/upload") + public ResponseEntity> updateSeriesUpload( + @PathVariable("seriesId") Long seriesId, + @Valid @RequestBody SeriesUpdateRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.updateSeriesUpload(seriesId, request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java new file mode 100644 index 0000000..0bf714b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java @@ -0,0 +1,45 @@ +package com.ott.api_admin.series.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ˆ˜์ • ์š”์ฒญ") +public record SeriesUpdateRequest( + @Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "์ˆ˜์ •๋œ ์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ") + @NotBlank + String title, + + @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์„ค๋ช…", example = "์ˆ˜์ •๋œ ์‹œ๋ฆฌ์ฆˆ ์„ค๋ช…") + @NotBlank + String description, + + @Schema(type = "String", description = "์ถœ์—ฐ์ง„", example = "๋ฐฐ์šฐA, ๋ฐฐ์šฐB") + @NotBlank + String actors, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "ํƒœ๊ทธ ID ๋ชฉ๋ก", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "String", description = "์ƒˆ ํฌ์Šคํ„ฐ ํŒŒ์ผ๋ช… (๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "์ƒˆ ์ธ๋„ค์ผ ํŒŒ์ผ๋ช… (๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "thumb-new.jpg") + String thumbnailFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java new file mode 100644 index 0000000..a132970 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -0,0 +1,47 @@ +package com.ott.api_admin.series.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์—…๋กœ๋“œ ์š”์ฒญ") +public record SeriesUploadRequest( + @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "์‘๋‹ตํ•˜๋ผ 1988") + @NotBlank + String title, + + @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์„ค๋ช…", example = "๊ฐ€์กฑ๊ณผ ์ด์›ƒ์˜ ๋”ฐ๋œปํ•œ ์ด์•ผ๊ธฐ") + @NotBlank + String description, + + @Schema(type = "String", description = "์ถœ์—ฐ์ง„", example = "์„ฑ๋™์ผ, ์ด์ผํ™”") + @NotBlank + String actors, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "ํƒœ๊ทธ ID ๋ชฉ๋ก", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "thumb.jpg") + @NotBlank + String thumbnailFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java index 4b5b74a..7cc3280 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java @@ -1,29 +1,23 @@ package com.ott.api_admin.series.dto.response; -import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import com.ott.domain.common.PublicStatus; + @Schema(description = "์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") public record SeriesListResponse( - @Schema(type = "Long", description = "์‹œ๋ฆฌ์ฆˆ ID", example = "1") - Long seriesId, + @Schema(type = "Long", description = "๋ฏธ๋””์–ด ID (์‹œ๋ฆฌ์ฆˆ์—์„œ ์ฐธ์กฐ)", example = "1") Long mediaId, - @Schema(type = "String", description = "์ธ๋„ค์ผ URL", example = "https://cdn.example.com/thumbnail.jpg") - String thumbnailUrl, + @Schema(type = "String", description = "์ธ๋„ค์ผ URL", example = "https://cdn.example.com/thumbnail.jpg") String thumbnailUrl, - @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") - String title, + @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") String title, - @Schema(type = "String", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ช…", example = "๋“œ๋ผ๋งˆ") - String categoryName, + @Schema(type = "String", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ช…", example = "๋“œ๋ผ๋งˆ") String categoryName, - @Schema(type = "List", description = "ํƒœ๊ทธ ์ด๋ฆ„ ๋ชฉ๋ก", example = "[\"์Šค๋ฆด๋Ÿฌ\", \"์ถ”๋ฆฌ\"]") - List tagNameList, + @Schema(type = "List", description = "ํƒœ๊ทธ ์ด๋ฆ„ ๋ชฉ๋ก", example = "[\"์Šค๋ฆด๋Ÿฌ\", \"์ถ”๋ฆฌ\"]") List tagNameList, - @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") - PublicStatus publicStatus -) { + @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") PublicStatus publicStatus) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java new file mode 100644 index 0000000..217639d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record SeriesTitleListResponse( + + @Schema(type = "Long", description = "์‹œ๋ฆฌ์ฆˆ ID", example = "1") + Long seriesId, + + @Schema(type = "String", description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") + String title +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java new file mode 100644 index 0000000..8b1c5fc --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ˆ˜์ • ์‘๋‹ต") +public record SeriesUpdateResponse( + @Schema(type = "Long", description = "์‹œ๋ฆฌ์ฆˆ ID", example = "10") + Long seriesId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ Object Key (๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "series/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ Object Key (๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "series/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "https://oplust-content.s3.ap-northeast-2.amazonaws.com/series/10/poster/poster-new.jpg?X-Amz-.../~") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "https://oplust-content.s3.ap-northeast-2.amazonaws.com/series/10/thumbnail/thumb-new.jpg?X-Amz-.../~") + String thumbnailUploadUrl +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java new file mode 100644 index 0000000..1daf12d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์—…๋กœ๋“œ ์‘๋‹ต") +public record SeriesUploadResponse( + @Schema(type = "Long", description = "์ƒ์„ฑ๋œ ์‹œ๋ฆฌ์ฆˆ ID", example = "10") + Long seriesId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ S3 object key", example = "series/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ S3 object key", example = "series/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String thumbnailUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 11191bc..ac6468b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -2,8 +2,12 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; import org.springframework.stereotype.Component; import java.util.List; @@ -11,49 +15,88 @@ @Component public class BackOfficeSeriesMapper { - public SeriesListResponse toSeriesListResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); + public SeriesListResponse toSeriesListResponse(Media media, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); return new SeriesListResponse( - series.getId(), - series.getThumbnailUrl(), - series.getTitle(), + media.getId(), + media.getThumbnailUrl(), + media.getTitle(), categoryName, tagNameList, - series.getPublicStatus() + media.getPublicStatus() + ); + } + + public SeriesTitleListResponse toSeriesTitleList(Series series) { + return new SeriesTitleListResponse( + series.getId(), + series.getMedia().getTitle() ); } - public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); + public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, String uploaderName, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); return new SeriesDetailResponse( series.getId(), - series.getTitle(), - series.getDescription(), + media.getTitle(), + media.getDescription(), categoryName, tagNameList, - series.getPublicStatus(), - series.getUploader().getNickname(), - series.getBookmarkCount(), + media.getPublicStatus(), + uploaderName, + media.getBookmarkCount(), series.getActors(), - series.getPosterUrl(), - series.getThumbnailUrl() + media.getPosterUrl(), + media.getThumbnailUrl() + ); + } + + public SeriesUploadResponse toSeriesUploadResponse( + Long seriesId, + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + return new SeriesUploadResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl + ); + } + + public SeriesUpdateResponse toSeriesUpdateResponse( + Long seriesId, + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + return new SeriesUpdateResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl ); } - private String extractCategoryName(List seriesTagList) { - return seriesTagList.stream() + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() .findFirst() - .map(st -> st.getTag().getCategory().getName()) + .map(mt -> mt.getTag().getCategory().getName()) .orElse(null); } - private List extractTagNameList(List seriesTagList) { - return seriesTagList.stream() - .map(st -> st.getTag().getName()) + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) .toList(); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 1c693fc..4981e19 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,29 +1,38 @@ package com.ott.api_admin.series.service; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +import com.ott.api_admin.upload.support.MediaTagLinker; +import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.domain.series_tag.repository.SeriesTagRepository; import com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.member.domain.Member; import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; +import com.ott.domain.series.repository.SeriesRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.Collections; +import java.util.stream.Collectors; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; @RequiredArgsConstructor @Service @@ -31,36 +40,52 @@ public class BackOfficeSeriesService { private final BackOfficeSeriesMapper backOfficeSeriesMapper; + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; - private final SeriesTagRepository seriesTagRepository; + private final UploadHelper uploadHelper; + private final MediaTagLinker mediaTagLinker; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending()); + Pageable pageable = PageRequest.of(page, size); - // 1. keyword ์œ ๋ฌด์— ๋”ฐ๋ผ ๋ถ„๊ธฐ / ์‹œ๋ฆฌ์ฆˆ ๋Œ€์ƒ ํŽ˜์ด์ง• - Page seriesPage = StringUtils.hasText(searchWord) - ? seriesRepository.findByTitleContaining(searchWord, pageable) - : seriesRepository.findAll(pageable); + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWord(pageable, MediaType.SERIES, searchWord); - // 2. ์กฐํšŒ๋œ ์‹œ๋ฆฌ์ฆˆ ID ๋ชฉ๋ก ์ถ”์ถœ - List seriesIdList = seriesPage.getContent().stream() - .map(Series::getId) + List mediaIdList = mediaPage.getContent().stream() + .map(Media::getId) .toList(); - // 3. IN์ ˆ๋กœ ํƒœ๊ทธ ์ผ๊ด„ ์กฐํšŒ - Map> tagListBySeriesId = seriesIdList.isEmpty() + Map> tagListByMediaId = mediaIdList.isEmpty() ? Collections.emptyMap() - : seriesTagRepository.findWithTagAndCategoryBySeriesIds(seriesIdList).stream() - .collect(Collectors.groupingBy(st -> st.getSeries().getId())); + : mediaTagRepository.findWithTagAndCategoryByMediaIds(mediaIdList).stream() + .collect(Collectors.groupingBy(mt -> mt.getMedia().getId())); - List responseList = seriesPage.getContent().stream() - .map(series -> backOfficeSeriesMapper.toSeriesListResponse( - series, - tagListBySeriesId.getOrDefault(series.getId(), List.of()) + List responseList = mediaPage.getContent().stream() + .map(media -> backOfficeSeriesMapper.toSeriesListResponse( + media, + tagListByMediaId.getOrDefault(media.getId(), List.of()) )) .toList(); + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public PageResponse getSeriesTitle(Integer page, Integer size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + Page seriesPage = seriesRepository.findSeriesListWithMediaBySearchWord(pageable, searchWord); + + List responseList = seriesPage.getContent().stream() + .map(backOfficeSeriesMapper::toSeriesTitleList) + .toList(); + PageInfo pageInfo = PageInfo.toPageInfo( seriesPage.getNumber(), seriesPage.getTotalPages(), @@ -70,13 +95,94 @@ public PageResponse getSeries(int page, int size, String sea } @Transactional(readOnly = true) - public SeriesDetailResponse getSeriesDetail(Long seriesId) { - Series series = seriesRepository.findById(seriesId) + public SeriesDetailResponse getSeriesDetail(Long mediaId) { + Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - List seriesTagList = seriesTagRepository - .findWithTagAndCategoryBySeriesIds(List.of(seriesId)); + Media media = series.getMedia(); + String uploaderNickname = media.getUploader().getNickname(); + + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); - return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); + return backOfficeSeriesMapper.toSeriesDetailResponse(series, media, uploaderNickname, mediaTagList); + } + + @Transactional + public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request, Long memberId) { + Member uploader = uploadHelper.resolveUploader(memberId); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SERIES) + .publicStatus(request.publicStatus()) + .build() + ); + + Series series = seriesRepository.save( + Series.builder() + .media(media) + .actors(request.actors()) + .build() + ); + + Long seriesId = series.getId(); + UploadHelper.ImageCreateUploadResult imageCreateUploadResult = uploadHelper.prepareImageCreate( + "series", seriesId, request.posterFileName(), request.thumbnailFileName() + ); + media.updateImageKeys( + imageCreateUploadResult.posterObjectUrl(), + imageCreateUploadResult.thumbnailObjectUrl() + ); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeSeriesMapper.toSeriesUploadResponse( + seriesId, + imageCreateUploadResult.posterObjectKey(), + imageCreateUploadResult.thumbnailObjectKey(), + imageCreateUploadResult.posterUploadUrl(), + imageCreateUploadResult.thumbnailUploadUrl() + ); + } + + @Transactional + public SeriesUpdateResponse updateSeriesUpload(Long seriesId, SeriesUpdateRequest request) { + Series series = seriesRepository.findWithMediaById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Media media = series.getMedia(); + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + series.updateActors(request.actors()); + + UploadHelper.ImageUpdateUploadResult imageUpdateUploadResult = uploadHelper.prepareImageUpdate( + "series", + seriesId, + request.posterFileName(), + request.thumbnailFileName(), + media.getPosterUrl(), + media.getThumbnailUrl() + ); + + media.updateImageKeys( + imageUpdateUploadResult.nextPosterUrl(), + imageUpdateUploadResult.nextThumbnailUrl() + ); + + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeSeriesMapper.toSeriesUpdateResponse( + seriesId, + imageUpdateUploadResult.posterObjectKey(), + imageUpdateUploadResult.thumbnailObjectKey(), + imageUpdateUploadResult.posterUploadUrl(), + imageUpdateUploadResult.thumbnailUploadUrl() + ); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java new file mode 100644 index 0000000..5450484 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -0,0 +1,139 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BackOffice Short-Form API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์ˆํผ ๊ด€๋ฆฌ API") +public interface BackOfficeShortFormApi { + + @Operation(summary = "์ˆํผ ๋ชฉ๋ก ์กฐํšŒ", description = "์ˆํผ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ShortFormListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์ˆํผ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ˆํผ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getShortFormList( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, + @Parameter(description = "๊ณต๊ฐœ ์—ฌ๋ถ€. ๊ณต๊ฐœ/๋น„๊ณต๊ฐœ๋กœ ๋‚˜๋‰ฉ๋‹ˆ๋‹ค.", required = false, example = "PUBLIC") @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus, + Authentication authentication + ); + + @Operation(summary = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ (์ˆํผ ์—…๋กœ๋“œ ํŽ˜์ด์ง€)", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ํŽ˜์ด์ง• dataList ๊ตฌ์„ฑ", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = OriginMediaTitleListResponse.class)))} + ), + @ApiResponse( + responseCode = "200", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getOriginMediaTitle( + @Parameter(description = "์กฐํšŒํ•  ํŽ˜์ด์ง€์˜ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. **page๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ๊ธฐ๋ณธ๊ฐ’์€ 10์ž…๋‹ˆ๋‹ค.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(description = "์ œ๋ชฉ ๋ถ€๋ถ„์ผ์น˜ ๊ฒ€์ƒ‰์–ด. ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord + ); + + @Operation(summary = "์ˆํผ ์ƒ์„ธ ์กฐํšŒ", description = "์ˆํผ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ˆํผ ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormDetailResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ˆํผ ์ƒ์„ธ ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + }) + ResponseEntity> getShortFormDetail( + @Parameter(description = "์กฐํšŒํ•  ์ˆํผ์˜ ๋ฏธ๋””์–ด ID", required = true) @PathVariable("mediaId") Long mediaId, + Authentication authentication + ); + + @Operation(summary = "์ˆํผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ", description = "์ˆํผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  S3 ์—…๋กœ๋“œ์šฉ Presigned URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ˆํผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ˆํผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ๋ฐ Presigned URL ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN, EDITOR ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createShortFormUpload( + @Parameter(description = "ShortFormUploadRequest ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @Valid @RequestBody ShortFormUploadRequest request, + + @Parameter(hidden = true) + @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์ˆํผ ์ˆ˜์ •", description = "์ˆํผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  ํ•„์š” ์‹œ ํŒŒ์ผ ๊ต์ฒด์šฉ Presigned URL์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "์ˆํผ ์ˆ˜์ • ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormUpdateResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "์ˆํผ ์ˆ˜์ • ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ (ADMIN, EDITOR ์ ‘๊ทผ ๊ฐ€๋Šฅ)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> updateShortFormUpload( + @Parameter(description = "์ˆ˜์ • ๋Œ€์ƒ ์ˆํผ ID", required = true, example = "1") + @PathVariable("shortformId") Long shortformId, + + @Parameter(description = "ShortFormUpdateRequest ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.", required = true) + @Valid @RequestBody ShortFormUpdateRequest request, + Authentication authentication + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java new file mode 100644 index 0000000..1ec5ece --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -0,0 +1,90 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; +import com.ott.api_admin.shortform.service.BackOfficeShortFormService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.common.PublicStatus; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/back-office/short-forms") +@RequiredArgsConstructor +public class BackOfficeShortFormController implements BackOfficeShortFormApi { + + private final BackOfficeShortFormService backOfficeShortFormService; + + @Override + @GetMapping + public ResponseEntity>> getShortFormList( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormList(page, size, searchWord, publicStatus, authentication)) + ); + } + + @Override + @GetMapping("/origin-media") + public ResponseEntity>> getOriginMediaTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getOriginMediaTitle(page, size, searchWord)) + ); + } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getShortFormDetail( + @PathVariable("mediaId") Long mediaId, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormDetail(mediaId, authentication)) + ); + } + + @Override + @PostMapping("/upload") + public ResponseEntity> createShortFormUpload( + @Valid @RequestBody ShortFormUploadRequest request, + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request, memberId))); + } + + @Override + @PatchMapping("/{shortformId}/upload") + public ResponseEntity> updateShortFormUpload( + @PathVariable("shortformId") Long shortformId, + @Valid @RequestBody ShortFormUpdateRequest request, + Authentication authentication + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.updateShortFormUpload(shortformId, request, authentication))); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java new file mode 100644 index 0000000..15fd948 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java @@ -0,0 +1,50 @@ +package com.ott.api_admin.shortform.dto.request; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +@Schema(description = "์ˆํผ ์ˆ˜์ • ์š”์ฒญ") +public record ShortFormUpdateRequest( + @Schema(type = "Long", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ID", example = "1") + @NotNull + Long originId, + + @Schema(type = "String", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ํƒ€์ž…", example = "SERIES") + @NotNull + MediaType mediaType, + + @Schema(type = "String", description = "์ˆํผ ์ œ๋ชฉ", example = "ํ•˜์ด๋ผ์ดํŠธ ์ˆ˜์ •") + @NotBlank + String title, + + @Schema(type = "String", description = "์ˆํผ ์„ค๋ช…", example = "๋ช…์žฅ๋ฉด ํ•˜์ด๋ผ์ดํŠธ ์ˆ˜์ •") + @NotBlank + String description, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "60") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "10240") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์›๋ณธ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์›๋ณธ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "thumb-new.jpg") + String thumbnailFileName, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ํŒŒ์ผ๋ช…(๊ต์ฒด ์‹œ์—๋งŒ ์ž…๋ ฅ)", example = "origin-new.mp4") + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java new file mode 100644 index 0000000..9321d0e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -0,0 +1,52 @@ +package com.ott.api_admin.shortform.dto.request; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +@Schema(description = "์ˆํผ ์—…๋กœ๋“œ ์š”์ฒญ") +public record ShortFormUploadRequest( + @Schema(type = "Long", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ID", example = "1") + @NotNull + Long originId, + + @Schema(type = "String", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ํƒ€์ž…", example = "SERIES") + @NotNull + MediaType mediaType, + + @Schema(type = "String", description = "์ˆํผ ์ œ๋ชฉ", example = "ํ•˜์ด๋ผ์ดํŠธ") + @NotBlank + String title, + + @Schema(type = "String", description = "์ˆํผ ์„ค๋ช…", example = "๋ช…์žฅ๋ฉด ํ•˜์ด๋ผ์ดํŠธ") + @NotBlank + String description, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์ƒํƒœ", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "60") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "10240") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์›๋ณธ ํŒŒ์ผ๋ช…", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ํŒŒ์ผ๋ช…", example = "origin.mp4") + @NotBlank + String originFileName +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java new file mode 100644 index 0000000..f84b32f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.shortform.dto.response; + +import com.ott.domain.common.MediaType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record OriginMediaTitleListResponse( + + @Schema(type = "Long", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ID", example = "1") Long originId, + + @Schema(type = "String", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") String title, + + @Schema(type = "String", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ํƒ€์ž…", example = "SERIES") MediaType mediaType) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java new file mode 100644 index 0000000..c81184b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java @@ -0,0 +1,51 @@ +package com.ott.api_admin.shortform.dto.response; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "์ˆํผ ์ƒ์„ธ ์กฐํšŒ ์‘๋‹ต") +public record ShortFormDetailResponse( + + @Schema(type = "Long", description = "์ˆํผ ID", example = "1") + Long shortFormId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ(์„ธ๋กœ, 5:7) URL", example = "https://cdn.example.com/poster.jpg") + String posterUrl, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "๊ธฐ์ƒ์ถฉ") + String title, + + @Schema(type = "String", description = "์ฝ˜ํ…์ธ  ์„ค๋ช…", example = "๋ด‰์ค€ํ˜ธ ๊ฐ๋…์˜ ๋ธ”๋ž™์ฝ”๋ฏธ๋”” ์Šค๋ฆด๋Ÿฌ") + String description, + + @Schema(type = "String", description = "์›๋ณธ ์ฝ˜ํ…์ธ  ์ œ๋ชฉ (์‹œ๋ฆฌ์ฆˆ ํ˜น์€ ์ฝ˜ํ…์ธ (๋‹จํŽธ ๋“ฑ))", example = "๋”๊ธ€๋กœ๋ฆฌ ์‹œ์ฆŒ1, ํƒœ๊ทน๊ธฐ ํœ˜๋‚ ๋ฆฌ๋ฉฐ") + String originContentsTitle, + + @Schema(type = "String", description = "์—…๋กœ๋” ๋‹‰๋„ค์ž„", example = "๊ด€๋ฆฌ์ž") + String uploaderNickname, + + @Schema(type = "Integer", description = "์˜์ƒ ๊ธธ์ด(์ดˆ)", example = "7200") + Integer duration, + + @Schema(type = "Integer", description = "์˜์ƒ ํฌ๊ธฐ(KB)", example = "1048576") + Integer videoSize, + + @Schema(type = "String", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ช…", example = "๋“œ๋ผ๋งˆ") + String categoryName, + + @Schema(type = "List", description = "ํƒœ๊ทธ ์ด๋ฆ„ ๋ชฉ๋ก", example = "[\"์Šค๋ฆด๋Ÿฌ\", \"์ถ”๋ฆฌ\"]") + List tagNameList, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") + PublicStatus publicStatus, + + @Schema(type = "Long", description = "๋ถ๋งˆํฌ ์ˆ˜", example = "150") + Long bookmarkCount, + + @Schema(type = "LocalDate", description = "์—…๋กœ๋“œ์ผ", example = "2026-01-15") + LocalDate uploadedDate +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java new file mode 100644 index 0000000..863c2cb --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.shortform.dto.response; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "์ˆํผ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record ShortFormListResponse( + + @Schema(type = "Long", description = "๋ฏธ๋””์–ด ID", example = "1") + Long mediaId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ(์„ธ๋กœ, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") + String posterUrl, + + @Schema(type = "String", description = "์ˆํผ ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ ๋ช…์žฅ๋ฉด") + String title, + + @Schema(type = "String", description = "๊ณต๊ฐœ ์—ฌ๋ถ€", example = "PUBLIC") + PublicStatus publicStatus, + + @Schema(type = "LocalDate", description = "์—…๋กœ๋“œ์ผ", example = "2026-01-15") + LocalDate uploadedDate +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java new file mode 100644 index 0000000..002479d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.shortform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์ˆํผ ์ˆ˜์ • ์‘๋‹ต") +public record ShortFormUpdateResponse( + @Schema(type = "Long", description = "์ˆํผ ID", example = "10") + Long shortFormId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "short-forms/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "short-forms/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ Object Key(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "short-forms/10/origin/origin-new.mp4") + String originObjectKey, + + @Schema(type = "String", description = "๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ Object Key", example = "short-forms/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/short-forms/10/poster/poster-new.jpg?X-Amz-...") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/short-forms/10/thumbnail/thumb-new.jpg?X-Amz-...") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ์—…๋กœ๋“œ URL(๊ต์ฒดํ•˜์ง€ ์•Š์œผ๋ฉด null)", example = "~/short-forms/10/origin/origin-new.mp4?X-Amz-...") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java new file mode 100644 index 0000000..903dd01 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.shortform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์ˆํผ ์—…๋กœ๋“œ ์‘๋‹ต") +public record ShortFormUploadResponse( + @Schema(type = "Long", description = "์ƒ์„ฑ๋œ ์ˆํผ ID", example = "10") + Long shortFormId, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ S3 object key", example = "short-forms/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "์ธ๋„ค์ผ S3 object key", example = "short-forms/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ S3 object key", example = "short-forms/10/origin/origin.mp4") + String originObjectKey, + + @Schema(type = "String", description = "ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ object key", example = "short-forms/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "ํฌ์Šคํ„ฐ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String posterUploadUrl, + + @Schema(type = "String", description = "์ธ๋„ค์ผ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "์›๋ณธ ์˜์ƒ ์—…๋กœ๋“œ์šฉ ์‚ฌ์ „ ์„œ๋ช… URL") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java new file mode 100644 index 0000000..70eae26 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -0,0 +1,121 @@ +package com.ott.api_admin.shortform.mapper; + +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.short_form.domain.ShortForm; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class BackOfficeShortFormMapper { + + public ShortFormListResponse toShortFormListResponse(Media media) { + return new ShortFormListResponse( + media.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getPublicStatus(), + media.getCreatedDate().toLocalDate() + ); + } + + public ShortFormDetailResponse toShortFormDetailResponse(ShortForm shortForm, Media media, String uploaderNickname, String originMediaTitle, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new ShortFormDetailResponse( + shortForm.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getDescription(), + originMediaTitle, + uploaderNickname, + shortForm.getDuration(), + shortForm.getVideoSize(), + categoryName, + tagNameList, + media.getPublicStatus(), + media.getBookmarkCount(), + media.getCreatedDate().toLocalDate() + ); + } + + public OriginMediaTitleListResponse toOriginMediaTitleListResponse( + Media media, Map seriesIdByMediaId, Map contentsIdByMediaId + ) { + Long originId = media.getMediaType() == MediaType.SERIES + ? seriesIdByMediaId.get(media.getId()) + : contentsIdByMediaId.get(media.getId()); + + return new OriginMediaTitleListResponse( + originId, + media.getTitle(), + media.getMediaType() + ); + } + + public ShortFormUploadResponse toShortFormUploadResponse( + Long shortFormId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ShortFormUploadResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + + public ShortFormUpdateResponse toShortFormUpdateResponse( + Long shortFormId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ShortFormUpdateResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java new file mode 100644 index 0000000..748fa6b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -0,0 +1,321 @@ +package com.ott.api_admin.shortform.service; + +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +import com.ott.api_admin.upload.support.UploadHelper; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BackOfficeShortFormService { + + private final BackOfficeShortFormMapper backOfficeShortFormMapper; + + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + private final UploadHelper uploadHelper; + + @Transactional(readOnly = true) + public PageResponse getShortFormList( + Integer page, Integer size, String searchWord, PublicStatus publicStatus, + Authentication authentication) { + Pageable pageable = PageRequest.of(page, size); + + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + if (isEditor) { + uploaderId = memberId; + } + + Page mediaPage = mediaRepository + .findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( + pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeShortFormMapper::toShortFormListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public PageResponse getOriginMediaTitle(Integer page, Integer size, + String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); + + List mediaList = mediaPage.getContent(); + + List seriesMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.SERIES) + .map(Media::getId) + .toList(); + + List contentsMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.CONTENTS) + .map(Media::getId) + .toList(); + + Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() + .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); + + Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList) + .stream() + .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); + + List responseList = mediaList.stream() + .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, + contentsIdByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + + Media media = shortForm.getMedia(); + if (isEditor && !media.getUploader().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + String uploaderNickname = media.getUploader().getNickname(); + + Optional originMedia = shortForm.findOriginMedia(); + String originMediaTitle = null; + if (originMedia.isPresent()) { + originMediaTitle = originMedia.get().getTitle(); + } + + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); + + return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, + originMediaTitle, mediaTagList); + } + + @Transactional + public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request, Long memberId) { + Member uploader = uploadHelper.resolveUploader(memberId); + Series series = null; + Contents contents = null; + + if ( request.mediaType().equals(MediaType.SERIES) ) { + series = seriesRepository.findById(request.originId()) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + contents = resolveContents(request.originId()); + } else { + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); + } + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SHORT_FORM) + .publicStatus(request.publicStatus()) + .build()); + + ShortForm shortForm = shortFormRepository.save( + ShortForm.builder() + .media(media) + .series(series) + .contents(contents) + .duration(request.duration()) + .videoSize(request.videoSize()) + .originUrl("PENDING") + .masterPlaylistUrl("PENDING") + .build()); + + Long shortFormId = shortForm.getId(); + UploadHelper.MediaCreateUploadResult mediaCreateUploadResult = uploadHelper.prepareMediaCreate( + "short-forms", shortFormId, request.posterFileName(), request.thumbnailFileName(), request.originFileName() + ); + + media.updateImageKeys( + mediaCreateUploadResult.posterObjectUrl(), + mediaCreateUploadResult.thumbnailObjectUrl()); + shortForm.updateStorageKeys( + mediaCreateUploadResult.originObjectUrl(), + mediaCreateUploadResult.masterPlaylistObjectUrl()); + + Long originMediaId = resolveOriginMediaId(series, contents); + inheritOriginMediaTags(media, originMediaId); + + return backOfficeShortFormMapper.toShortFormUploadResponse( + shortFormId, + mediaCreateUploadResult.posterObjectKey(), + mediaCreateUploadResult.thumbnailObjectKey(), + mediaCreateUploadResult.originObjectKey(), + mediaCreateUploadResult.masterPlaylistObjectKey(), + mediaCreateUploadResult.posterUploadUrl(), + mediaCreateUploadResult.thumbnailUploadUrl(), + mediaCreateUploadResult.originUploadUrl()); + } + + @Transactional + public ShortFormUpdateResponse updateShortFormUpload(Long shortformId, ShortFormUpdateRequest request, Authentication authentication) { + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByShortFormId(shortformId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Media media = shortForm.getMedia(); + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + if (isEditor && !media.getUploader().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + Series series = null; + Contents contents = null; + + if ( request.mediaType().equals(MediaType.SERIES) ) { + series = seriesRepository.findById(request.originId()) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + contents = resolveContents(request.originId()); + } else { + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); + } + + + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + shortForm.updateMetadata(series, contents, request.duration(), request.videoSize()); + + Long shortFormId = shortForm.getId(); + UploadHelper.MediaUpdateUploadResult mediaUpdateUploadResult = uploadHelper.prepareMediaUpdate( + "short-forms", + shortFormId, + request.posterFileName(), + request.thumbnailFileName(), + request.originFileName(), + media.getPosterUrl(), + media.getThumbnailUrl(), + shortForm.getOriginUrl(), + shortForm.getMasterPlaylistUrl() + ); + + media.updateImageKeys( + mediaUpdateUploadResult.nextPosterUrl(), + mediaUpdateUploadResult.nextThumbnailUrl() + ); + shortForm.updateStorageKeys( + mediaUpdateUploadResult.nextOriginUrl(), + mediaUpdateUploadResult.nextMasterPlaylistUrl() + ); + + Long originMediaId = resolveOriginMediaId(series, contents); + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + inheritOriginMediaTags(media, originMediaId); + + return backOfficeShortFormMapper.toShortFormUpdateResponse( + shortFormId, + mediaUpdateUploadResult.posterObjectKey(), + mediaUpdateUploadResult.thumbnailObjectKey(), + mediaUpdateUploadResult.originObjectKey(), + mediaUpdateUploadResult.masterPlaylistObjectKey(), + mediaUpdateUploadResult.posterUploadUrl(), + mediaUpdateUploadResult.thumbnailUploadUrl(), + mediaUpdateUploadResult.originUploadUrl()); + } + + private Contents resolveContents(Long contentsId) { + if (contentsId == null) { + return null; + } + Contents contents = contentsRepository.findById(contentsId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + // ์‹œ๋ฆฌ์ฆˆ์— ์†ํ•œ ์ฝ˜ํ…์ธ ๋Š” ์ˆํผ ์›๋ณธ์œผ๋กœ ํ—ˆ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + if (contents.getSeries() != null) { + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_CONTENTS_TARGET); + } + return contents; + } + + private Long resolveOriginMediaId(Series series, Contents contents) { + if (series != null) { + return series.getMedia().getId(); + } + if (contents != null) { + return contents.getMedia().getId(); + } + throw new BusinessException(ErrorCode.SHORTFORM_ORIGIN_MEDIA_NOT_FOUND); + } + + private void inheritOriginMediaTags(Media targetMedia, Long originMediaId) { + List originMediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); + if (originMediaTagList.isEmpty()) { + return; + } + + List targetMediaTagList = originMediaTagList.stream() + .map(originMediaTag -> MediaTag.builder() + .media(targetMedia) + .tag(originMediaTag.getTag()) + .build()) + .toList(); + mediaTagRepository.saveAll(targetMediaTagList); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java new file mode 100644 index 0000000..dc26ae2 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java @@ -0,0 +1,37 @@ +package com.ott.api_admin.tag.controller; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "BackOffice Tag API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ํƒœ๊ทธ ์‹œ์ฒญ ํ†ต๊ณ„ API") +public interface BackOfficeTagApi { + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํƒœ๊ทธ ๋‹น์›” ์‹œ์ฒญ ํ†ต๊ณ„ ์กฐํšŒ", description = "ํŠน์ • ์นดํ…Œ๊ณ ๋ฆฌ์— ์†ํ•œ ํƒœ๊ทธ๋“ค์˜ ๋‹น์›” ์‹œ์ฒญ ์ˆ˜๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "ํƒœ๊ทธ ์‹œ์ฒญ ํ†ต๊ณ„ ์กฐํšŒ ์„ฑ๊ณต", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = TagViewResponse.class)))} + ), + @ApiResponse( + responseCode = "400", description = "ํƒœ๊ทธ ์‹œ์ฒญ ํ†ต๊ณ„ ์กฐํšŒ ์‹คํŒจ", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getTagViewStats( + @Parameter(description = "์นดํ…Œ๊ณ ๋ฆฌ ID", required = true) @PathVariable(value = "categoryId") Long categoryId + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java new file mode 100644 index 0000000..13ecef2 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java @@ -0,0 +1,28 @@ +package com.ott.api_admin.tag.controller; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.api_admin.tag.service.BackOfficeTagService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/back-office/admin/tags") +@RequiredArgsConstructor +public class BackOfficeTagController implements BackOfficeTagApi { + + private final BackOfficeTagService backOfficeTagService; + + @Override + @GetMapping("/stats/{categoryId}") + public ResponseEntity>> getTagViewStats( + @PathVariable(value = "categoryId") Long categoryId + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeTagService.getTagViewStats(categoryId)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java new file mode 100644 index 0000000..3c6439d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.tag.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "ํƒœ๊ทธ๋ณ„ ์‹œ์ฒญ ํ†ต๊ณ„ ์‘๋‹ต") +public record TagViewResponse( + + @Schema(type = "String", description = "ํƒœ๊ทธ๋ช…", example = "์Šค๋ฆด๋Ÿฌ") + String tagName, + + @Schema(type = "Long", description = "๋‹น์›” ์‹œ์ฒญ ์ˆ˜", example = "120") + Long viewCount +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java new file mode 100644 index 0000000..fa0229e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java @@ -0,0 +1,16 @@ +package com.ott.api_admin.tag.mapper; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.domain.watch_history.repository.TagViewCountProjection; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeTagMapper { + + public TagViewResponse toTagViewResponse(TagViewCountProjection projection) { + return new TagViewResponse( + projection.tagName(), + projection.viewCount() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java new file mode 100644 index 0000000..e2b5e35 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.tag.service; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.api_admin.tag.mapper.BackOfficeTagMapper; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackOfficeTagService { + + private final BackOfficeTagMapper backOfficeTagMapper; + private final WatchHistoryRepository watchHistoryRepository; + + @Transactional(readOnly = true) + public List getTagViewStats(Long categoryId) { + LocalDateTime startDate = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + LocalDateTime endDate = startDate.plusMonths(1); + + return watchHistoryRepository.countByTagAndCategoryIdAndWatchedBetween(categoryId, startDate, endDate) + .stream() + .map(backOfficeTagMapper::toTagViewResponse) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java new file mode 100644 index 0000000..d252b34 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java @@ -0,0 +1,58 @@ +package com.ott.api_admin.upload.support; + +import java.util.Arrays; +import java.util.Locale; + +public enum ExtensionEnum { + JPG("jpg", "image/jpeg", Category.IMAGE), + JPEG("jpeg", "image/jpeg", Category.IMAGE), + PNG("png", "image/png", Category.IMAGE), + WEBP("webp", "image/webp", Category.IMAGE), + MP4("mp4", "video/mp4", Category.VIDEO), + MOV("mov", "video/quicktime", Category.VIDEO), + WEBM("webm", "video/webm", Category.VIDEO), + M4V("m4v", "video/x-m4v", Category.VIDEO); + + private final String extension; + private final String contentType; + private final Category category; + + ExtensionEnum(String extension, String contentType, Category category) { + this.extension = extension; + this.contentType = contentType; + this.category = category; + } + + public static String resolveImageContentType(String fileName) { + return resolveContentType(fileName, Category.IMAGE); + } + + public static String resolveVideoContentType(String fileName) { + return resolveContentType(fileName, Category.VIDEO); + } + + private static String resolveContentType(String fileName, Category expectedCategory) { + String extractedExtension = extractExtension(fileName); + + return Arrays.stream(values()) + .filter(candidate -> candidate.category == expectedCategory) + .filter(candidate -> candidate.extension.equals(extractedExtension)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported file extension: " + extractedExtension)) + .contentType; + } + + private static String extractExtension(String fileName) { + String trimmed = fileName.trim(); + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + if (extensionDelimiterIndex < 0 || extensionDelimiterIndex == trimmed.length() - 1) { + throw new IllegalArgumentException("File extension is missing"); + } + return trimmed.substring(extensionDelimiterIndex + 1).toLowerCase(Locale.ROOT); + } + + private enum Category { + IMAGE, + VIDEO + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java new file mode 100644 index 0000000..3d00146 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java @@ -0,0 +1,62 @@ +package com.ott.api_admin.upload.support; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.category.domain.Category; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.Status; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class MediaTagLinker { + + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final MediaTagRepository mediaTagRepository; + + public void linkTags(Media media, Long categoryId, List tagIdList) { + + Category category = categoryRepository.findByIdAndStatus(categoryId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TAG_CATEGORY)); + + Set uniqueTagIdSet = new LinkedHashSet<>(); + for (Long tagId : tagIdList) { + if (!uniqueTagIdSet.add(tagId)) { + throw new BusinessException(ErrorCode.DUPLICATE_TAG_IN_LIST); + } + } + + List tagList = tagRepository.findAllByIdInAndStatus(new ArrayList<>(uniqueTagIdSet), Status.ACTIVE); + + // ์นดํ…Œ๊ณ ๋ฆฌ์— ๋งž์ง€ ์•Š๊ฑฐ๋‚˜ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํƒœ๊ทธ๊ฐ€ ํฌํ•จ ํ™•์ธ. + if (tagList.size() != uniqueTagIdSet.size()) { + throw new BusinessException(ErrorCode.INVALID_TAG_SELECTION); + } + boolean hasInvalidCategoryTag = tagList.stream() + .anyMatch(tag -> !tag.getCategory().getId().equals(category.getId())); + if (hasInvalidCategoryTag) { + throw new BusinessException(ErrorCode.INVALID_TAG_SELECTION); + } + + List mediaTagList = tagList.stream() + .map(tag -> MediaTag.builder() + .media(media) + .tag(tag) + .build()) + .toList(); + + mediaTagRepository.saveAll(mediaTagList); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java new file mode 100644 index 0000000..1723801 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -0,0 +1,305 @@ +package com.ott.api_admin.upload.support; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.infra.s3.service.S3PresignService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@RequiredArgsConstructor +@Component +public class UploadHelper { + + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; + + public String buildObjectKey(String resourceRoot, Long resourceId, String assetType, String fileName) { + return resourceRoot + "/" + resourceId + "/" + assetType + "/" + fileName; + } + + public String resolveImageContentType(String fileName) { + try { + return ExtensionEnum.resolveImageContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.UNSUPPORTED_IMAGE_EXTENSION); + } + } + + public String resolveVideoContentType(String fileName) { + try { + return ExtensionEnum.resolveVideoContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.UNSUPPORTED_VIDEO_EXTENSION); + } + } + + public String sanitizeFileName(String fileName) { + String trimmed = fileName == null ? "" : fileName.trim(); + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + String baseName = extensionDelimiterIndex > 0 ? trimmed.substring(0, extensionDelimiterIndex) : trimmed; + String extensionPart = extensionDelimiterIndex > 0 ? trimmed.substring(extensionDelimiterIndex + 1) : ""; + + String sanitizedBaseName = baseName + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z๊ฐ€-ํžฃ_-]", ""); + String sanitizedExtension = extensionPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedBaseName.isBlank()) { + sanitizedBaseName = "file"; + } + if (sanitizedExtension.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION); + } + return sanitizedBaseName + "." + sanitizedExtension; + } + + public Member resolveUploader(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } + + public UploadFileResult prepareRequiredUpload( + String resourceRoot, + Long resourceId, + String assetType, + String fileName, + boolean isVideo + ) { + String sanitizedFileName = sanitizeFileName(fileName); + String objectKey = buildObjectKey(resourceRoot, resourceId, assetType, sanitizedFileName); + String contentType = isVideo + ? resolveVideoContentType(sanitizedFileName) + : resolveImageContentType(sanitizedFileName); + String objectUrl = s3PresignService.toObjectUrl(objectKey); + String uploadUrl = s3PresignService.createPutPresignedUrl(objectKey, contentType); + return new UploadFileResult(objectKey, objectUrl, uploadUrl); + } + + public UploadFileResult prepareOptionalUpload( + String resourceRoot, + Long resourceId, + String assetType, + String fileName, + boolean isVideo + ) { + if (!StringUtils.hasText(fileName)) { + return null; + } + return prepareRequiredUpload(resourceRoot, resourceId, assetType, fileName, isVideo); + } + + public String buildMasterPlaylistObjectKey(String resourceRoot, Long resourceId) { + return resourceRoot + "/" + resourceId + "/transcoded/master.m3u8"; + } + + public String toObjectUrl(String objectKey) { + return s3PresignService.toObjectUrl(objectKey); + } + + public ImageCreateUploadResult prepareImageCreate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName + ) { + UploadFileResult posterUpload = prepareRequiredUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareRequiredUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + + return new ImageCreateUploadResult( + posterUpload.objectKey(), + thumbnailUpload.objectKey(), + posterUpload.objectUrl(), + thumbnailUpload.objectUrl(), + posterUpload.uploadUrl(), + thumbnailUpload.uploadUrl() + ); + } + + public ImageUpdateUploadResult prepareImageUpdate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String currentPosterUrl, + String currentThumbnailUrl + ) { + UploadFileResult posterUpload = prepareOptionalUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareOptionalUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + + String finalPosterUrl = currentPosterUrl; + String finalThumbnailUrl = currentThumbnailUrl; + String posterObjectKey = null; + String thumbnailObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + + if (posterUpload != null) { + finalPosterUrl = posterUpload.objectUrl(); + posterObjectKey = posterUpload.objectKey(); + posterUploadUrl = posterUpload.uploadUrl(); + } + if (thumbnailUpload != null) { + finalThumbnailUrl = thumbnailUpload.objectUrl(); + thumbnailObjectKey = thumbnailUpload.objectKey(); + thumbnailUploadUrl = thumbnailUpload.uploadUrl(); + } + + return new ImageUpdateUploadResult( + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + finalPosterUrl, + finalThumbnailUrl + ); + } + + public MediaCreateUploadResult prepareMediaCreate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String originFileName + ) { + UploadFileResult posterUpload = prepareRequiredUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareRequiredUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + UploadFileResult originUpload = prepareRequiredUpload(resourceRoot, resourceId, "origin", originFileName, true); + + String masterPlaylistObjectKey = buildMasterPlaylistObjectKey(resourceRoot, resourceId); + String masterPlaylistObjectUrl = toObjectUrl(masterPlaylistObjectKey); + + return new MediaCreateUploadResult( + posterUpload.objectKey(), + thumbnailUpload.objectKey(), + originUpload.objectKey(), + masterPlaylistObjectKey, + posterUpload.objectUrl(), + thumbnailUpload.objectUrl(), + originUpload.objectUrl(), + masterPlaylistObjectUrl, + posterUpload.uploadUrl(), + thumbnailUpload.uploadUrl(), + originUpload.uploadUrl() + ); + } + + public MediaUpdateUploadResult prepareMediaUpdate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String originFileName, + String currentPosterUrl, + String currentThumbnailUrl, + String currentOriginUrl, + String currentMasterPlaylistUrl + ) { + UploadFileResult posterUpload = prepareOptionalUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareOptionalUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + UploadFileResult originUpload = prepareOptionalUpload(resourceRoot, resourceId, "origin", originFileName, true); + + String posterObjectKey = null; + String thumbnailObjectKey = null; + String originObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + String originUploadUrl = null; + String finalPosterUrl = currentPosterUrl; + String finalThumbnailUrl = currentThumbnailUrl; + String finalOriginUrl = currentOriginUrl; + String masterPlaylistObjectKey = buildMasterPlaylistObjectKey(resourceRoot, resourceId); + String finalMasterPlaylistUrl = currentMasterPlaylistUrl; + + if (posterUpload != null) { + posterObjectKey = posterUpload.objectKey(); + posterUploadUrl = posterUpload.uploadUrl(); + finalPosterUrl = posterUpload.objectUrl(); + } + if (thumbnailUpload != null) { + thumbnailObjectKey = thumbnailUpload.objectKey(); + thumbnailUploadUrl = thumbnailUpload.uploadUrl(); + finalThumbnailUrl = thumbnailUpload.objectUrl(); + } + if (originUpload != null) { + originObjectKey = originUpload.objectKey(); + originUploadUrl = originUpload.uploadUrl(); + finalOriginUrl = originUpload.objectUrl(); + finalMasterPlaylistUrl = toObjectUrl(masterPlaylistObjectKey); + } + + return new MediaUpdateUploadResult( + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl, + finalPosterUrl, + finalThumbnailUrl, + finalOriginUrl, + finalMasterPlaylistUrl + ); + } + + public record UploadFileResult( + String objectKey, + String objectUrl, + String uploadUrl + ) { + } + + public record ImageCreateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String posterObjectUrl, + String thumbnailObjectUrl, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + } + + public record ImageUpdateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String nextPosterUrl, + String nextThumbnailUrl + ) { + } + + public record MediaCreateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterObjectUrl, + String thumbnailObjectUrl, + String originObjectUrl, + String masterPlaylistObjectUrl, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + } + + public record MediaUpdateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl, + String nextPosterUrl, + String nextThumbnailUrl, + String nextOriginUrl, + String nextMasterPlaylistUrl + ) { + } +} diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 8fb8686..90c4f44 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} @@ -25,3 +25,27 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +# JWT ์„ค์ • +jwt: + secret: ${JWT_SECRET_BASE64} + access-token-expiry: 3200000 # 60๋ถ„ -> ๊ด€๋ฆฌ์ž์˜ ๊ฒฝ์šฐ 1์‹œ๊ฐ„์œผ๋กœ ์ฆ๊ฐ€ + refresh-token-expiry: 1209600000 # 14์ผ + +springdoc: + api-docs: + version: OPENAPI_3_0 + path: /back-office/v3/api-docs + + swagger-ui: + path: /back-office/swagger-ui/index.html \ No newline at end of file diff --git a/apps/api-user/Dockerfile b/apps/api-user/Dockerfile index 692ca0f..42e97cf 100644 --- a/apps/api-user/Dockerfile +++ b/apps/api-user/Dockerfile @@ -3,8 +3,13 @@ WORKDIR /app COPY . . RUN gradle :apps:api-user:bootJar --no-daemon -FROM eclipse-temurin:21-jre +FROM eclipse-temurin:21-jre-jammy WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + COPY --from=build /app/apps/api-user/build/libs/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index faf3af7..da1cf0e 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -2,12 +2,11 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') implementation project(':modules:common-security') implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -16,4 +15,22 @@ dependencies { implementation 'org.flywaydb:flyway-mysql' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // httpClient 5 + implementation 'org.apache.httpcomponents.client5:httpclient5' +} + + +/** + * Flyway๊ฐ€ infra ๋ชจ๋“ˆ์˜ db/migration ๋ฆฌ์†Œ์Šค๋ฅผ ํ™•์‹คํžˆ ์ธ์‹ํ•˜๋„๋ก ์„ค์ • + */ +tasks.named('processResources') { + from(project(':modules:infra-db').sourceSets.main.resources.srcDirs) { + include 'db/migration/**' + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java new file mode 100644 index 0000000..99eba7e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java @@ -0,0 +1,53 @@ +package com.ott.api_user.auth.client; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoUnlinkClient { + + private final RestTemplate restTemplate; + + @Value("${kakao.unlink-url}") + private String unlinkUrl; + + @Value("${kakao.admin-key}") + private String adminKey; + + /** + * ์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ (์–ด๋“œ๋ฏผ ํ‚ค ๋ฐฉ์‹) + * ํƒˆํ‡ด ์‹œ ์นด์นด์˜ค ์„œ๋ฒ„์—์„œ ํ•ด๋‹น ์œ ์ €์™€์˜ ์—ฐ๊ฒฐ์„ ๋Š์Œ + */ + public void unlink(String providerId) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", "KakaoAK " + adminKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("target_id_type", "user_id"); + body.add("target_id", providerId); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(unlinkUrl, request, String.class); + log.info("์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ ์„ฑ๊ณต"); + } catch (Exception e) { + log.error("์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ ์‹คํŒจ"); + throw new BusinessException(ErrorCode.KAKAO_UNLINK_FAILED); + } + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java new file mode 100644 index 0000000..79af5e2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -0,0 +1,31 @@ +package com.ott.api_user.auth.controller; + +import com.ott.common.web.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +@Tag(name = "Auth API", description = "์ธ์ฆ/์ธ๊ฐ€ API") +public interface AuthApi { + + @Operation(summary = "Access Token ์žฌ๋ฐœ๊ธ‰", description = "access token + refresh token ์žฌ๋ฐœ๊ธ‰.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "์žฌ๋ฐœ๊ธ‰ ์„ฑ๊ณต"), + @ApiResponse(responseCode = "401", description = "refreshToken์ด ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ/์œ ํšจํ•˜์ง€ ์•Š์Œ", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + + @Operation(summary = "๋กœ๊ทธ์•„์›ƒ", description = "DB refreshToken์„ ์‚ญ์ œ, accessToken/refreshToken ์ฟ ํ‚ค ์ œ๊ฑฐ") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต"), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java new file mode 100644 index 0000000..e65fe57 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -0,0 +1,87 @@ +package com.ott.api_user.auth.controller; + + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.api_user.auth.service.AuthService; +import com.ott.common.security.util.CookieUtil; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApi { + + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + // Access Token ์žฌ๋ฐœ๊ธ‰ + @PostMapping("reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // access + refresh ์žฌ๋ฐœ๊ธ‰ -> ๋ณด์•ˆ์„ฑ ์ธก๋ฉด + TokenResponse tokenResponse = authService.reissue(refreshToken); + + // ์ฟ ํ‚ค์— ์ €์žฅ + cookieUtil.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + /** + * ๋กœ๊ทธ์•„์›ƒ + * DB๋Š” refreshToken ์‚ญ์ œ + * ์ฟ ํ‚ค๋Š” Controller์—์„œ ์ง์ ‘ ์‚ญ์ œ + */ + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + authService.logout(memberId); + + cookieUtil.deleteCookie(response, "accessToken"); + cookieUtil.deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + + + // ์ฟ ํ‚ค์— ๋Œ€ํ•œ ์ ‘๊ทผ์€ HTTP๊ณ  ์„œ๋น„์Šค๋กœ ๋‚ด๋ ค๊ฐ€๋ฉด ์•ˆ๋˜๊ธฐ ๋•Œ๋ฌธ์— Controller์—์„œ ๊ตฌํ˜„ + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) { + return null; + } + + for (Cookie cookie: request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java new file mode 100644 index 0000000..2bc7777 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package com.ott.api_user.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..5391498 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,60 @@ +package com.ott.api_user.auth.oauth2; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + /** + * ์‚ฌ์šฉ์ž๊ฐ€ ์นด์นด์˜ค ๋กœ๊ทธ์ธํ™”๋ฉด์—์„œ ๋กœ๊ทธ์ธ + * ์นด์นด์˜ค ์ธ์ฆ ์„œ๋ฒ„์—์„œ ์ธ๊ฐ€์ฝ”๋“œ์™€ ํ•จ๊ป˜ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + * ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์˜ OAuth2LoginAuthenicationFilter๊ฐ€ ์ธ๊ฐ€์ฝ”๋“œ๋ฅผ token-url์— ์ „๋‹ฌํ•˜์—ฌ Access token ๊ตํ™˜ (์ž๋™ ๊ตฌํ˜„) + * DefaultOAuthUserService์—์„œ ๊ธฐ๋ณธ์ ์œผ๋กœ loadUser๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ user-info-uri์„ ํ†ตํ•˜์—ฌ ์œ ์ € ๊ฐ์ฒด์ธ oAuth2User๋ฅผ ์ƒ์„ฑ + */ + private final KakaoAuthService kakaoAuthService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2UserRequest -> ์–ด๋–ค ํด๋ผ์ด์ด์–ธํŠธ, provider๊ฐ€ ์ €์žฅ๋จ + + // ๋กœ๊ทธ์ธ๋œ ๊ฐ์ฒด๋Š” ๋กœ๊ทธ์ธ ์ •๋ณด๊ฐ€ ์—†์Œ + // loadUser๋ฅผ ํ†ตํ•ด์„œ info-url๋ฅผ ํ†ตํ•ด attributes๋ฅผ ์ฑ„์šด OAuth2User๋ฅผ ๋งŒ๋“ฌ + OAuth2User oAuth2User = super.loadUser(userRequest); + + // ์นด์นด์˜ค ์‘๋‹ต ๊ฐ์ฒด ํŒŒ์‹ฑ + KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + + // DB ์กฐํšŒ + Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); + + // ์‹ ๊ทœ ํšŒ์› ํŒ๋ณ„ + boolean isNewMember = kakaoAuthService.isNewMember(member); + + // attribute์— memberId(PK)์™€ ์‹ ๊ทœ ์œ ์ € ์œ ๋ฌด๋ฅผ ์ ์žฌ + // payload memberId, isNewMember๋งŒ ๋“ค์–ด๊ฐ -> ๋ฏผ๊ฐํ•œ ์ •๋ณด ์ ์žฌ x + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + attributes.put("memberId", member.getId()); + attributes.put("isNewMember", isNewMember); + + // ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์— ๋„˜๊ธธ ๊ฐ์ฒด ๋ฐ˜ํ™˜ ์ด๋•Œ ๊ถŒํ•œ์€ ROLE_MEMBER์ž„ + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority(member.getRole().getKey())), + attributes, + "id" + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..1d6b225 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java @@ -0,0 +1,35 @@ +package com.ott.api_user.auth.oauth2.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${app.frontend-url}") + private String frontedUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + log.info("OAuth2 ๋กœ๊ทธ์ธ ์‹คํŒจ: {}", exception.getMessage()); + + String targetUrl = frontedUrl + "/auth/login?error=" + + URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..6444125 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -0,0 +1,85 @@ +package com.ott.api_user.auth.oauth2.handler; + +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.common.security.util.CookieUtil; +import com.ott.common.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; + +import java.io.IOException; +import java.util.List; + +/** + * Oauth2 ์„ฑ๊ณต์‹œ ํ•ด๋‹น ํ•ธ๋“ค๋Ÿฌ ์ž๋™ ํ˜ธ์ถœ + * ์นด์นด์˜ค ๋กœ๊ทธ์ธ ์„ฑ๊ณต์‹œ ํ•ด๋‹น ํ•ธ๋“ค๋Ÿฌ์—์„œ ์ฒ˜๋ฆฌ + * JWT ์ƒ์„ฑ(Access, Refresh) + * Refresh Token DB ์ €์žฅ + * ์ฝœ๋ฐฑ URL๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + ๋งŒ๋“  ํ† ํฐ์€ ์ฟ ํ‚ค๋กœ ์ „๋‹ฌ + */ + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final KakaoAuthService kakaoAuthService; + private final CookieUtil cookieUtil; + + @Value("${app.frontend-url}") + private String frontedUrl; + + // 30๋ถ„ + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + // 14์ผ + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + // Oauth2 ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ํ•ด๋‹น ๋ฉ”์†Œ๋“œ๋ฅผ ์Šคํ”„๋ง ์Šคํ๋ฆฌํ‹ฐ๊ฐ€ ์ž๋™ ํ˜ธ์ถœ + // ์ด ์‹œ์ ์—์„œ authenication์— ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์ €์žฅ user-info + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + // memberId, authroies, isNewMember์˜ ๊ฒฐ๊ณผ๊ฐ€ map์œผ๋กœ ์ €์žฅ + OAuth2User principal = (OAuth2User) authentication.getPrincipal(); + + long memberId = ((Number) principal.getAttributes().get("memberId")).longValue(); + boolean isNewMember = (boolean) principal.getAttributes().get("isNewMember"); + + // authorties: ["ROLE_MEMBER"] + List authorties = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + // JWT ์ƒ์„ฑ + String accessToken = jwtTokenProvider.createAccessToken(memberId, authorties); + String refreshToken = jwtTokenProvider.createRefreshToken(memberId, authorties); + + kakaoAuthService.saveRefreshToken(memberId, refreshToken); + + // ์ฟ ํ‚ค๋กœ ์ €์žฅ + cookieUtil.addCookie(response, "accessToken", accessToken, accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + + + // ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ์—๋Š” isNewMember์— ๋”ฐ๋ผ์„œ ๊ฒฝ๋กœ ๋ณ€๊ฒฝ + String targetUrl = isNewMember + ? frontedUrl + "/auth/userinfo" + : frontedUrl + "/"; + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + + } + +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java new file mode 100644 index 0000000..c3566f1 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java @@ -0,0 +1,24 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class KakaoUserInfo { + + private final String providerId; + private final String email; + private final String nickname; + + @SuppressWarnings("unchecked") + public KakaoUserInfo(Map attributes) { + this.providerId = String.valueOf(attributes.get("id")); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + this.nickname = (String) profile.get("nickname"); + this.email = (String) kakaoAccount.get("email"); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java new file mode 100644 index 0000000..e9919c0 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +// ๋‹ค์ค‘ ์†Œ์…œ ๋กœ๊ทธ์ธ ๋„์ž… ์‹œ ์ „๋žต ํŒจํ„ด์œผ๋กœ ๊ตฌ์„ฑ ์˜ˆ์ • +public interface UserInfo { +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java new file mode 100644 index 0000000..045fd65 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java @@ -0,0 +1,74 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * JWT ํ† ํฐ ๊ด€๋ฆฌํ•˜๋Š” ํด๋ž˜์Šค -> ์žฌ๋ฐœ๊ธ‰๊ณผ ๋กœ๊ทธ์•„์›ƒ ๊ตฌํ˜„ + * ๋ชจ๋“  ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉ -> ํ˜„์žฌ๋Š” ์นด์นด์˜ค๋งŒ ์‚ฌ์šฉ + */ +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + + /** + * Access Token ์žฌ๋ฐœ๊ธ‰ + */ + public TokenResponse reissue(String refreshToken) { + // refresh ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + + if (errorCode != null) { + throw new BusinessException(errorCode); + } + + // DB์— ์ €์žฅ๋œ ํ† ํฐ๊ณผ ์ผ์น˜ ์—ฌ๋ถ€ ํ™•์ธ + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = findMemberById(memberId); + + if (!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // ๊ถŒํ•œ ์ถ”์ถœ + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + + // access + refresh ์žฌ๋ฐœ๊ธ‰ + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // refreshToken ๊ฐฑ์‹  ๋ฐ ์ด์ „ ํ† ํฐ ํ๊ธฐ + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new TokenResponse(newAccessToken, newRefreshToken); + + } + + /** + * ๋กœ๊ทธ์•„์›ƒ - Refresh ํ† ํฐ ์‚ญ์ œ + */ + public void logout(Long memberId) { + Member member = findMemberById(memberId); + member.clearRefreshToken(); + } + + // Optipnal ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ์‚ฌ์šฉ + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java new file mode 100644 index 0000000..9e7cd81 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -0,0 +1,59 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * ์นด์นด์˜ค์˜ ๋กœ๊ทธ์ธ ํšŒ์› ๋กœ์ง์œผ๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•จ + * ํšŒ์› ์กฐํšŒ/์ƒ์„ฑ + * ํ”„๋กœํ•„ ๋™๊ธฐํ™” + * ์‹ ๊ทœ ํšŒ์› ํŒ๋ณ„ + * Refresh Token ์ €์žฅ + */ +@Service +@RequiredArgsConstructor +@Transactional +public class KakaoAuthService { + + private final MemberRepository memberRepository; + + // ์นด์นด์˜ค ์‚ฌ์šฉ์ž ์ •๋ณด๋กœ ํšŒ์› ์กฐํšŒ or ์‹ ๊ทœ ์ƒ์„ฑ + // ๊ธฐ์กด ํšŒ์›์ผ ๊ฒฝ์šฐ ํ”„๋กœํ•„ ๋™๊ธฐํ™” ํ•„์š” + public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { + return memberRepository + .findByProviderAndProviderId(Provider.KAKAO, kakaoUserInfo.getProviderId()) + .map(existingMember -> { + existingMember.reactivate(); + existingMember.updateKakaoProfile( + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname()); + return existingMember; + }) + .orElseGet(() -> memberRepository.save( + Member.createKakaoMember( + kakaoUserInfo.getProviderId(), + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname()))); + } + + // ์‹ ๊ทœ ํšŒ์› ํŒ๋ณ„ -> ์ปฌ๋Ÿผ์œผ๋กœ ํŒ๋ณ„ + public boolean isNewMember(Member member) { + return !member.isOnboardingCompleted(); + } + + // refresh token ์ €์žฅ + public void saveRefreshToken(Long memberId, String refreshToken) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.updateRefreshToken(refreshToken); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java new file mode 100644 index 0000000..517d1b9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.service; + +// ์ถ”ํ›„ ๋‹ค์ค‘ ์†Œ์…œ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ ์‹œ ์ „๋žต ํŒจํ„ด์œผ๋กœ ๋ณ€๊ฒฝ +public interface SocialAuthService { +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java new file mode 100644 index 0000000..b6bb4b9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -0,0 +1,75 @@ +package com.ott.api_user.bookmark.controller; + +import com.ott.api_user.bookmark.dto.request.BookmarkRequest; +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Bookmark API", description = "๋ถ๋งˆํฌ API") +public interface BookmarkAPI { + @Operation(summary = "๋ถ๋งˆํฌ ์ˆ˜์ •", description = "๋ฏธ๋””์–ด์— ๋Œ€ํ•œ ๋ถ๋งˆํฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "๋ถ๋งˆํฌ ์ˆ˜์ • ์„ฑ๊ณต"), + @ApiResponse(responseCode = "404", description = "๋ฏธ๋””์–ด ๋˜๋Š” ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + }) + @PostMapping + ResponseEntity> editBookmark( + @Valid @RequestBody BookmarkRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + + // ๋ถ๋งˆํฌ ์ฝ˜ํ…์ธ  or ์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ + @Operation(summary = "๋ถ๋งˆํฌ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ", description = "์œ ์ €๊ฐ€ ๋ถ๋งˆํฌํ•œ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "๋ถ๋งˆํฌ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PageResponse.class))) + }) + @GetMapping("/me/contents") + ResponseEntity>> getBookmarkMediaList( + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", required = true) @RequestParam(defaultValue = "0") @Min(0) Integer page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", required = true) @RequestParam(defaultValue = "10") @Min(0) @Max(100) Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + + // ๋ถ๋งˆํฌ ์ˆํผ ๋ชฉ๋ก ์กฐํšŒ + @Operation(summary = "๋ถ๋งˆํฌ ์ˆํผ ๋ชฉ๋ก ์กฐํšŒ", description = "์œ ์ €๊ฐ€ ๋ถ๋งˆํฌํ•œ ์ˆํผ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "๋ถ๋งˆํฌ ์ˆํผ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PageResponse.class))) + }) + @GetMapping("/me/short-form") + ResponseEntity>> getBookmarkShortFormList( + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)") @RequestParam(defaultValue = "0") @Min(0) Integer page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(defaultValue = "10") @Min(0) @Max(100) Integer size, + @Parameter(hidden = true) Long memberId + ); + + +} + diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java new file mode 100644 index 0000000..c230996 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -0,0 +1,63 @@ +package com.ott.api_user.bookmark.controller; + +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; +import com.ott.common.web.response.PageResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.bookmark.dto.request.BookmarkRequest; +import com.ott.api_user.bookmark.service.BookmarkService; +import com.ott.common.web.response.SuccessResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/bookmarks") +public class BookmarkController implements BookmarkAPI { + + private final BookmarkService bookmarkService; + + // ๋ถ๋งˆํฌ ์ˆ˜์ • + @Override + public ResponseEntity> editBookmark( + @Valid @RequestBody BookmarkRequest request, + @AuthenticationPrincipal Long memberId) { + + bookmarkService.editBookmark(memberId, request.getMediaId()); + return ResponseEntity.noContent().build(); + } + + // ๋ถ๋งˆํฌํ•œ ์ฝ˜ํ…์ธ  or ์‹œ๋ฆฌ์ฆˆ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + @Override + public ResponseEntity>> getBookmarkMediaList( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of( + bookmarkService.getBookmarkMediaList(memberId, page, size))); + } + + // ๋ถ๋งˆํฌํ•œ ์ˆํผ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + @Override + public ResponseEntity>> getBookmarkShortFormList( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of( + bookmarkService.getBookmarkShortFormList(memberId, page, size))); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java new file mode 100644 index 0000000..0bd758a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java @@ -0,0 +1,18 @@ +package com.ott.api_user.bookmark.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "๋ถ๋งˆํฌ ๋“ฑ๋ก/์ทจ์†Œ ์š”์ฒญ DTO") +public class BookmarkRequest { + + @NotNull(message = "mediaId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Positive(message = "mediaId๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Schema(type ="Long", description = "๋ถ๋งˆํฌํ•  ๋ฏธ๋””์–ด ID", example = "1") + private Long mediaId; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java new file mode 100644 index 0000000..27524d8 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -0,0 +1,46 @@ +package com.ott.api_user.bookmark.dto.response; + +import com.ott.domain.bookmark.repository.BookmarkMediaProjection; +import com.ott.domain.common.MediaType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "๋ถ๋งˆํฌํ•œ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์‘๋‹ต DTO") +public class BookmarkMediaResponse { + + @Schema(type ="Long", description = "๋ฏธ๋””์–ด ID", example = "1") + private Long mediaId; + + @Schema(type = "String", description = "๋ฏธ๋””์–ด ํƒ€์ž… (CONTENTS, SERIES)", example = "SERIES") + private MediaType mediaType; + + @Schema(type ="String", description = "๋ฏธ๋””์–ด ์ œ๋ชฉ", example = "์–ด์„œ์™€์š” ๊น€๋งˆ๋ฃจ์˜ ์ˆฒ") + private String title; + + @Schema(type ="String", description = "๋ฏธ๋””์–ด ์„ค๋ช…", example = "ํ—ˆ๊ฑฐ๋ฉ์˜ ์ˆฒ์—์„œ ํž๋ง์„ ์ฆ๊ฒจ๋ด์š”~") + private String description; + + @Schema(type ="String", description = "ํฌ์Šคํ„ฐ URL", example = "https://cdn.ott.com/posters/1.jpg") + private String posterUrl; + + @Schema(type = "Integer", description = "์ด์–ด๋ณด๊ธฐ ์‹œ์  (์ดˆ), CONTENTS๋งŒ ๋ฐ˜ํ™˜ SERIES๋Š” null", example = "150") + private Integer positionSec; + + @Schema(type = "Integer", description = "์ „์ฒด ์žฌ์ƒ ์‹œ๊ฐ„ (์ดˆ), CONTENTS๋งŒ ๋ฐ˜ํ™˜ SERIES๋Š” null", example = "3600") + private Integer duration; + + public static BookmarkMediaResponse from(BookmarkMediaProjection projection) { + return BookmarkMediaResponse.builder() + .mediaId(projection.getMediaId()) + .mediaType(projection.getMediaType()) + .title(projection.getTitle()) + .description(projection.getDescription()) + .posterUrl(projection.getPosterUrl()) + .positionSec(projection.getPositionSec()) + .duration(projection.getDuration()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java new file mode 100644 index 0000000..725b747 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java @@ -0,0 +1,33 @@ +package com.ott.api_user.bookmark.dto.response; + +import com.ott.domain.bookmark.domain.Bookmark; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "๋ถ๋งˆํฌ ์ˆํผ ๋ชฉ๋ก ์‘๋‹ต DTO") +public class BookmarkShortFormResponse { + + @Schema(type ="Long", description = "๋ฏธ๋””์–ด ID", example = "1") + private Long mediaId; + + @Schema(type ="String", description = "๋ฏธ๋””์–ด ์ œ๋ชฉ", example = "๊น€๋งˆ๋ฃจ์˜ ์ˆํผ EP 01") + private String title; + + @Schema(type ="String", description = "๋ฏธ๋””์–ด ์„ค๋ช…", example = "์˜ค๋Š˜์€ ๋ถ๋งˆํฌ API๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค. ์ฐธ ์žฌ๋ฏธ์žˆ์—ˆ๋‹ค!") + private String description; + + @Schema(type ="String", description = "์ธ๋„ค์ผ URL", example = "https://cdn.ott.com/thumbnails/1.jpg") + private String thumbnailUrl; + + public static BookmarkShortFormResponse from(Bookmark bookmark) { + return BookmarkShortFormResponse.builder() + .mediaId(bookmark.getMedia().getId()) + .title(bookmark.getMedia().getTitle()) + .description(bookmark.getMedia().getDescription()) + .thumbnailUrl(bookmark.getMedia().getThumbnailUrl()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java new file mode 100644 index 0000000..45392cc --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -0,0 +1,153 @@ +package com.ott.api_user.bookmark.service; + +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkMediaProjection; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.Status; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final MemberRepository memberRepository; + private final MediaRepository mediaRepository; + private final ContentsRepository contentsRepository; + + /** + * ๋ถ๋งˆํฌ ์ˆ˜์ • + * CONTENTS โ†’ ์‹œ๋ฆฌ์ฆˆ ์—ํ”ผ์†Œ๋“œ๋ฉด ๋ถ€๋ชจ Series.media๋กœ ์ฒ˜๋ฆฌ + * CONTENTS โ†’ ์‹œ๋ฆฌ์ฆˆ๊ฐ€ ์•„๋‹ ๊ฒฝ์šฐ ์ž๊ธฐ ์ž์‹  ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + * SHORT_FORM โ†’ ์ž๊ธฐ ์ž์‹  ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + * SERIES โ†’ ์ž๊ธฐ ์ž์‹  ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + * ํ•ด๋‹น ๋ฉ”์†Œ๋“œ๋กœ bookmark ํ…Œ์ด๋ธ”์—๋Š” ์‹œ๋ฆฌ์ฆˆ / ๋‹จํŽธ ์‹œ๋‚˜๋ฆฌ์˜ค / ์ˆํผ๋งŒ ์ €์žฅ๋จ + */ + @Transactional + public void editBookmark(Long memberId, Long mediaId) { + + Media findMedia = mediaRepository.findById(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + + // ์‹ค์ œ ๋ถ๋งˆํฌ ์ฒ˜๋ฆฌํ•  ํƒ€๊ฒŸ ๋ฏธ๋””์–ด ๊ฒฐ์ • + Media targetMedia = resolveTargetMedia(findMedia); + + // ํ•ด๋‹น ์œ ์ €๊ฐ€ ํ•ด๋‹น ๋ฏธ๋””์–ด์— ๋Œ€ํ•ด์„œ ๋ถ๋งˆํฌ๋ฅผ ํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€ ์ฒดํฌ + bookmarkRepository.findByMemberIdAndMediaId(memberId, targetMedia.getId()) + .ifPresentOrElse( + bookmark -> { + // ์ด๋ฏธ ํ•ด๋‹น ๋ฏธ๋””์–ด์— ๋Œ€ํ•ด์„œ ๋ถ๋งˆํฌํ•œ ๊ฒฝ์šฐ -> DELETE ๋ณ€๊ฒฝ ์ดํ›„ + ์นด์šดํŠธ ๊ฐ์†Œ + if (bookmark.getStatus() == Status.ACTIVE) { + bookmark.updateStatus(Status.DELETE); + targetMedia.decreaseBookmarkCount(); + } else { + // ํ•ด๋‹น ๋ฏธ๋””์–ด์— ๋Œ€ํ•ด ๋ถ๋งˆํฌ๋ฅผ ์•ˆํ•œ ๊ฒฝ์šฐ -> ACTIVE ๋ณ€๊ฒฝ ์ดํ›„ + ์นด์šดํŠธ ์ฆ๊ฐ€ + bookmark.updateStatus(Status.ACTIVE); + targetMedia.increaseBookmarkCount(); + } + }, + () -> { + // ํ–‰ ์ž์ฒด๊ฐ€ ์—†์Œ -> ์‹ ๊ทœ์ž„ -> insert์ดํ›„ ์ƒํƒœ ACTIVE + ์นด์šดํŠธ ์ฆ๊ฐ€ + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException( + ErrorCode.USER_NOT_FOUND)); + + bookmarkRepository.save(Bookmark.builder() + .member(findMember) + .media(targetMedia) + .build()); // ์ƒํƒœ default๊ฐ€ ACTIVE์ž„ + + targetMedia.increaseBookmarkCount(); + }); + } + + // ๋ถ๋งˆํฌ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + // ์ด๋ฏธ DB์ƒ์—๋Š” ์‹œ๋ฆฌ์ฆˆ ์›๋ณธ / ์‹œ๋‚˜๋ฆฌ์˜ค / ์ˆํผ๋งŒ ์ €์žฅ๋˜์–ด ์žˆ์Œ + @Transactional(readOnly = true) + public PageResponse getBookmarkMediaList(Long memberId, Integer page, Integer size) { + + Pageable pageable = PageRequest.of(page, size); + + Page bookmarkPage = + bookmarkRepository.findBookmarkMediaList(memberId, pageable); + + List dataList = bookmarkPage.getContent() + .stream() + .map(BookmarkMediaResponse::from) + .toList(); + + // pageInfo ์ƒ์„ฑ + PageInfo pageInfo = PageInfo.toPageInfo( + bookmarkPage.getNumber(), + bookmarkPage.getTotalPages(), + bookmarkPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, dataList); + } + + // ์ˆํผ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + @Transactional(readOnly = true) + public PageResponse getBookmarkShortFormList(Long memberId, Integer page, + Integer size) { + + Pageable pageable = PageRequest.of(page, size); + + // SHORT_FORM ํƒ€์ž…๋งŒ ์กฐํšŒ + Page bookmarkPage = bookmarkRepository + .findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( + memberId, + Status.ACTIVE, + MediaType.SHORT_FORM, + pageable); + + // DTO ๋ณ€ํ™˜ + List dataList = bookmarkPage.getContent().stream() + .map(BookmarkShortFormResponse::from) + .toList(); + + // pageInfo ์ƒ์„ฑ + PageInfo pageInfo = PageInfo.toPageInfo( + bookmarkPage.getNumber(), + bookmarkPage.getTotalPages(), + bookmarkPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, dataList); + } + + /** + * mediaType์— ๋”ฐ๋ผ ์‹ค์ œ ๋ถ๋งˆํฌ ์ฒ˜๋ฆฌํ•  ํƒ€๊ฒŸ Media ๋ฐ˜ํ™˜ + * CONTENTS โ†’ series ์†Œ์†์ด๋ฉด series.media ๋ฐ˜ํ™˜ + * CONTENTS โ†’ series ์†Œ์†์ด ์•„๋‹ˆ๋ฉด ์ž๊ธฐ ์ž์‹  media ๋ฐ˜ํ™˜ + * SHORT_FORM โ†’ ์ž๊ธฐ ์ž์‹  media ๋ฐ˜ํ™˜ + * SERIES โ†’ ์ž๊ธฐ ์ž์‹  media ๋ฐ˜ํ™˜ + */ + private Media resolveTargetMedia(Media media) { + return switch (media.getMediaType()) { + case CONTENTS -> contentsRepository.findByMediaId(media.getId()) + .filter(contents -> contents.getSeries() != null) + .map(contents -> contents.getSeries().getMedia()) + .orElse(media); + + case SERIES, SHORT_FORM -> media; + }; + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java new file mode 100644 index 0000000..ba2869d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java @@ -0,0 +1,49 @@ +package com.ott.api_user.category.controller; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.tag.dto.response.TagResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "Category", description = "์นดํ…Œ๊ณ ๋ฆฌ API") +@SecurityRequirement(name = "BearerAuth") +public interface CategoryApi { + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ", description = "์ „์ฒด ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = CategoryResponse.class) + ) + ) + }) + ResponseEntity>> getCategories(); + + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ", description = "ํŠน์ • ์นดํ…Œ๊ณ ๋ฆฌ์— ์†ํ•œ ํƒœ๊ทธ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json") + ) + }) + ResponseEntity>> getTagsByCategory( + @Positive @Parameter(description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") Long categoryId + ); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java new file mode 100644 index 0000000..9e73fdd --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java @@ -0,0 +1,39 @@ +package com.ott.api_user.category.controller; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.category.service.CategoryService; +import com.ott.api_user.tag.dto.response.TagResponse; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@Validated +@RequestMapping("/categories") +@RequiredArgsConstructor +public class CategoryController implements CategoryApi { + + private final CategoryService categoryService; + + @Override + @GetMapping + public ResponseEntity>> getCategories() { + return ResponseEntity.ok(SuccessResponse.of(categoryService.getCategories())); + } + + @Override + @GetMapping("{categoryId}/tags") + public ResponseEntity>> getTagsByCategory( + @Positive @PathVariable Long categoryId + ) { + return ResponseEntity.ok(SuccessResponse.of(categoryService.getTagsByCategory(categoryId))); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java b/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java new file mode 100644 index 0000000..89af275 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_user.category.dto.response; + +import com.ott.domain.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์นดํ…Œ๊ณ ๋ฆฌ ์‘๋‹ต DTO") +public class CategoryResponse { + + @Schema(type= "Long", example = "1", description = "์นดํ…Œ๊ณ ๋ฆฌ ID") + private Long categoryId; + + @Schema(type ="String", example = "์˜ํ™”", description = "์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„") + private String name; + + public static CategoryResponse from(Category category) { + return CategoryResponse.builder() + .categoryId(category.getId()) + .name(category.getName()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java new file mode 100644 index 0000000..724c3e2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java @@ -0,0 +1,42 @@ +package com.ott.api_user.category.service; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.tag.dto.response.TagResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.category.domain.Category; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.Status; +import com.ott.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryService { + + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + + public List getCategories() { + return categoryRepository.findAllByStatus(Status.ACTIVE) + .stream() + .map(CategoryResponse::from) + .toList(); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ + public List getTagsByCategory(Long categoryId) { + Category findCategory = categoryRepository.findByIdAndStatus(categoryId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.CATEGORY_NOT_FOUND)); + + return tagRepository.findAllByCategoryAndStatus(findCategory, Status.ACTIVE) + .stream() + .map(TagResponse::from) + .toList(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java new file mode 100644 index 0000000..27e0dd7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -0,0 +1,129 @@ +package com.ott.api_user.comment.controller; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.ContentsCommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import io.swagger.v3.oas.annotations.media.ArraySchema; + + +@Tag(name = "Comment API", description = "๋Œ“๊ธ€ API") +@SecurityRequirement(name = "BearerAuth") +@RequestMapping("/comments") +public interface CommentApi { + + @Operation(summary = "๋Œ“๊ธ€ ์ž‘์„ฑ", description = "์ฝ˜ํ…์ธ ์— ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. ์Šคํฌ์ผ๋Ÿฌ ๊ธฐ๋ณธ๊ฐ’์€ false์ž…๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "๋Œ“๊ธ€ ์ž‘์„ฑ ์„ฑ๊ณต"), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "์ฝ˜ํ…์ธ  ๋˜๋Š” ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + ResponseEntity> createComment( + @Valid @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "๋Œ“๊ธ€ ์ˆ˜์ •", description = "๋ณธ์ธ์ด ์ž‘์„ฑํ•œ ๋Œ“๊ธ€์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "๋Œ“๊ธ€ ์ˆ˜์ • ์„ฑ๊ณต"), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "๋ณธ์ธ ๋Œ“๊ธ€์ด ์•„๋‹˜", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PatchMapping("/{commentId}") + ResponseEntity> updateComment( + @Positive @Parameter(description = "๋Œ“๊ธ€ ID") @PathVariable Long commentId, + @Valid @RequestBody UpdateCommentRequest request, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "๋Œ“๊ธ€ ์‚ญ์ œ", description = "๋ณธ์ธ์ด ์ž‘์„ฑํ•œ ๋Œ“๊ธ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "๋Œ“๊ธ€ ์‚ญ์ œ ์„ฑ๊ณต"), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "๋ณธ์ธ ๋Œ“๊ธ€์ด ์•„๋‹˜", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/{commentId}") + ResponseEntity deleteComment( + @Positive @Parameter(description = "๋Œ“๊ธ€ ID") @PathVariable Long commentId, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ", description = "๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋Œ“๊ธ€ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = MyCommentResponse.class))), + + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + ResponseEntity>> getMyComments( + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", schema = @Schema(type = "Integer", defaultValue = "0")) @RequestParam(defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", schema = @Schema(type = "Integer", defaultValue = "20")) @RequestParam(defaultValue = "20") Integer size, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + + @Operation(summary = "์ฝ˜ํ…์ธ  ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ", description = "์ฝ˜ํ…์ธ ์˜ ๋Œ“๊ธ€ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ์ฝ˜ํ…์ธ  ๋Œ“๊ธ€ ๋ชฉ๋ก ๊ตฌ์„ฑ", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = CommentResponse.class))) }), + @ApiResponse(responseCode = "200", description = "๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @ApiResponse(responseCode = "404", description = "์ฝ˜ํ…์ธ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{contentsId}/comments") + ResponseEntity>> getContentCommentsList( + @Parameter(description = "์ฝ˜ํ…์ธ  ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer size, + @Parameter(description = "์Šคํฌ์ผ๋Ÿฌ ํฌํ•จ ์—ฌ๋ถ€ (true: ์ „์ฒด ์กฐํšŒ, false: ์Šคํฌ ์ œ์™ธ)", example = "false") @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler); + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java new file mode 100644 index 0000000..a6f33d9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -0,0 +1,81 @@ +package com.ott.api_user.comment.controller; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.ContentsCommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import lombok.RequiredArgsConstructor; +import com.ott.api_user.comment.service.CommentService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +public class CommentController implements CommentApi { + + private final CommentService commentService; + + // ๋Œ“๊ธ€ ๋“ฑ๋ก + @Override + @PostMapping + public ResponseEntity> createComment( + @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.status(HttpStatus.CREATED) + .body(SuccessResponse.of(commentService.createComment(memberId, request))); + } + + // ๋Œ“๊ธ€ ์ˆ˜์ • + @Override + @PatchMapping("/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long commentId, + @RequestBody UpdateCommentRequest request, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of(commentService.updateComment(memberId, commentId, request))); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ + @Override + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal Long memberId) { + + commentService.deleteComment(memberId, commentId); + + return ResponseEntity.noContent().build(); + } + + // ๋Œ“๊ธ€ ์กฐํšŒ - ๋ณธ์ธ ๋Œ“๊ธ€๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅ, ์ตœ์‹ ์ˆœ ์ •๋ ฌ + @Override + @GetMapping("/me") + public ResponseEntity>> getMyComments( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size, + @AuthenticationPrincipal Long memberId) { + return ResponseEntity.ok(SuccessResponse.of(commentService.getMyComments(memberId, page, size))); + } + + @Override + public ResponseEntity>> getContentCommentsList( + @PathVariable(value = "contentsId") Long contentsId, + @RequestParam(value = "page", defaultValue = "0") Integer pageParam, + @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, + @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler) { + return ResponseEntity.ok( + SuccessResponse.of(commentService.getContentsCommentList(contentsId, pageParam, + sizeParam, includeSpoiler))); + + } +} + diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java new file mode 100644 index 0000000..4ac348f --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java @@ -0,0 +1,27 @@ +package com.ott.api_user.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor +@Schema(description = "๋Œ“๊ธ€ ๋“ฑ๋ก ์š”์ฒญ DTO") +public class CreateCommentRequest { + + @NotNull(message = "์ฝ˜ํ…์ธ  ID๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค.") + @Schema(type= "Long", example = "1", description = "์ฝ˜ํ…์ธ  ID") + private Long contentId; + + @NotBlank(message = "๋Œ“๊ธ€ ๋‚ด์šฉ์€ ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "๋Œ“๊ธ€์€ 100์ž ์ด๋‚ด๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.") + @Schema(type= "String", example = "์•„ ใ…‹ใ…‹ ๋ฐคํ‹ฐํ•˜๋‘ฅ", description = "๋Œ“๊ธ€") + private String content; + + @NotNull(message = "์Šคํฌ ์œ ๋ฌด ์ž…๋ ฅ์€ ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค.") + @Schema(type= "Boolean", example = "true", description = "์Šคํฌ ์œ ๋ฌด, ๋””ํดํŠธ false") + private Boolean isSpoiler = false; + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java new file mode 100644 index 0000000..b757e96 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java @@ -0,0 +1,23 @@ +package com.ott.api_user.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "๋Œ“๊ธ€ ์ˆ˜์ • ์š”์ฒญ DTO") +public class UpdateCommentRequest { + + @NotBlank(message = "๋Œ“๊ธ€ ๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "๋Œ“๊ธ€์€ 100์ž ์ด๋‚ด๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”") + @Schema(type= "String", example = "์•„ ใ…‹ใ…‹ ๋ฐคํ‹ฐํ•˜๋‘ฅ_์ˆ˜์ •123", description = "๋Œ“๊ธ€") + private String content; + + @NotNull(message = "์Šคํฌ ์œ ๋ฌด ์ž…๋ ฅ์€ ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค.") + @Schema(type= "Boolean", example = "true", description = "์Šคํฌ์ผ๋Ÿฌ ํฌํ•จ ์—ฌ๋ถ€") + private Boolean isSpoiler; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..b795d41 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java @@ -0,0 +1,65 @@ +package com.ott.api_user.comment.dto.response; + +import com.ott.domain.comment.domain.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "๋Œ“๊ธ€ ์‘๋‹ต DTO") +public class CommentResponse { + + @Schema(type = "Long", example = "1", description = "๋Œ“๊ธ€ ID") + private Long commentId; + + @Schema(type = "Long", example = "5", description = "์ฝ˜ํ…์ธ  ID") + private Long contentsId; + + @Schema(type = "String", example = "๋ฐคํ‹ฐ ํ•˜๋„ค ใ…‹ใ…‹", description = "๋Œ“๊ธ€ ๋‚ด์šฉ") + private String content; + + @Schema(type = "Boolean", example = "false", description = "์Šคํฌ์ผ๋Ÿฌ ํฌํ•จ ์—ฌ๋ถ€") + private Boolean isSpoiler; + + @Schema(type = "LocalDateTime ", example = "2026.02.15 20:14", description = "์ž‘์„ฑ์ผ์‹œ") + private LocalDateTime createdDate; + + @Schema(description = "์ž‘์„ฑ์ž ์ •๋ณด") + private WriterInfo writer; + + public static CommentResponse from(Comment comment) { + return CommentResponse.builder() + .commentId(comment.getId()) + .contentsId(comment.getContents().getId()) + .content(comment.getContent()) + .isSpoiler(comment.getIsSpoiler()) + .createdDate(comment.getCreatedDate()) + .writer(WriterInfo.from(comment)) + .build(); + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "๋Œ“๊ธ€ ์ž‘์„ฑ์ž ์ •๋ณด") + public static class WriterInfo { + + @Schema(type = "Long", example = "10", description = "์ž‘์„ฑ์ž ํšŒ์› ID") + private Long memberId; + + @Schema(type = "String", example = "๊น€๋งˆ๋ฃจ", description = "์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„") + private String nickname; + + public static WriterInfo from(Comment comment) { + return WriterInfo.builder() + .memberId(comment.getMember().getId()) + .nickname(comment.getMember().getNickname()) + .build(); + } + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/ContentsCommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/ContentsCommentResponse.java new file mode 100644 index 0000000..6157aac --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/ContentsCommentResponse.java @@ -0,0 +1,41 @@ +package com.ott.api_user.comment.dto.response; + +import java.time.LocalDateTime; + +import com.ott.domain.comment.domain.Comment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "แ„ƒแ…ขแ†บแ„€แ…ณแ†ฏ ์กฐํšŒ ์‘๋‹ต DTO") +public class ContentsCommentResponse { + @Schema(description = "๋Œ“๊ธ€ ๊ณ ์œ  ID", example = "1") + private Long commentId; + + @Schema(description = "์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„", example = "์˜ํ™”๊ด‘๋ฌธ์–ด") + private String nickname; + + @Schema(description = "๋Œ“๊ธ€ ๋‚ด์šฉ", example = "์ด ์˜ํ™” ์ง„์งœ ์‹œ๊ฐ„ ๊ฐ€๋Š” ์ค„ ๋ชจ๋ฅด๊ณ  ๋ดค๋„ค์š”!! ๊ฐ•์ถ”ํ•ฉ๋‹ˆ๋‹ค.") + private String content; + + @Schema(description = "์Šคํฌ์ผ๋Ÿฌ ์—ฌ๋ถ€", example = "true") + private boolean isSpoiler; + + @Schema(description = "์ž‘์„ฑ ์ผ์‹œ") + private LocalDateTime createdAt; + + public static ContentsCommentResponse from(Comment comment) { + return ContentsCommentResponse.builder() + .commentId(comment.getId()) + .nickname(comment.getMember().getNickname()) + .content(comment.getContent()) + .isSpoiler(comment.getIsSpoiler()) + .createdAt(comment.getCreatedDate()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java new file mode 100644 index 0000000..302b49c --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java @@ -0,0 +1,45 @@ +package com.ott.api_user.comment.dto.response; + +import com.ott.domain.comment.domain.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "๋‚ด ๋Œ“๊ธ€ ๋ชฉ๋ก ์‘๋‹ต DTO") +public class MyCommentResponse { + + @Schema(type = "Long", example = "1", description = "๋Œ“๊ธ€ ID") + private Long commentId; + + @Schema(type = "String", example = "์ฃผ์ธ๊ณต 43:23์ดˆ์— ์ฃฝ์Œ ใ…‹ใ…‹", description = "๋Œ“๊ธ€ ๋‚ด์šฉ") + private String content; + + @Schema(type = "String", example = "https://cdn.example.com/poster.jpg", description = "์ฝ˜ํ…์ธ  ํฌ์Šคํ„ฐ URL") + private String contentsPosterUrl; + + @Schema(type = "Long", example = "10", description = "์ž‘์„ฑ์ž ํšŒ์› ID") + private Long writerId; + + @Schema(type = "String", example = "๊น€๋งˆ๋ฃจ", description = "์ž‘์„ฑ์ž ๋‹‰๋„ค์ž„") + private String writerNickname; + + @Schema(type = "string", format = "date-time", example = "2024-01-01T00:00:00", description = "์ž‘์„ฑ์ผ์‹œ") + private LocalDateTime createdDate; + + public static MyCommentResponse from(Comment comment) { + return MyCommentResponse.builder() + .commentId(comment.getId()) + .content(comment.getContent()) + .contentsPosterUrl(comment.getContents().getMedia().getPosterUrl()) + .writerId(comment.getMember().getId()) + .writerNickname(comment.getMember().getNickname()) + .createdDate(comment.getCreatedDate()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java new file mode 100644 index 0000000..d0b005f --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -0,0 +1,141 @@ +package com.ott.api_user.comment.service; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.ContentsCommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.comment.repository.CommentRepository; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final ContentsRepository contentsRepository; + private final MemberRepository memberRepository; + + // ๋Œ“๊ธ€ ์ž‘์„ฑ + @Transactional + public CommentResponse createComment(Long memberId, CreateCommentRequest request) { + + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Contents contents = contentsRepository.findByIdAndStatus(request.getContentId(), Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // ํ•œ ์œ ์ €๊ฐ€ ํ•œ ์ฝ˜ํ…์ธ ์— ์—ฌ๋Ÿฌ ๋Œ“๊ธ€ ํ—ˆ์šฉ? + Comment saved = commentRepository.save( + Comment.builder() + .member(findMember) + .contents(contents) + .content(request.getContent()) + .isSpoiler(request.getIsSpoiler()) + .build() + ); + + return CommentResponse.from(saved); + + } + + // ๋Œ“๊ธ€ ์ˆ˜์ • - ๋ณธ์ธ ๋Œ“๊ธ€๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ + @Transactional + public CommentResponse updateComment(Long memberId, Long commentId, @Valid UpdateCommentRequest request) { + Comment comment = commentRepository.findByIdAndStatus(commentId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + // ๋ณธ์ธ๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ + if (!comment.getMember().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.COMMENT_FORBIDDEN); + } + + comment.update(request.getContent(), request.getIsSpoiler()); + + return CommentResponse.from(comment); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ - ๋ณธ์ธ ๋Œ“๊ธ€๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ + @Transactional + public void deleteComment(Long memberId, Long commentId) { + Comment comment = commentRepository.findByIdAndStatus(commentId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + // ๋ณธ์ธ๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ -> softDelete + if (!comment.getMember().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.COMMENT_FORBIDDEN); + } + + comment.updateStatus(Status.DELETE); + } + + // ๋Œ“๊ธ€ ์กฐํšŒ - ๋ณธ์ธ ๋Œ“๊ธ€๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅ(์ตœ์‹ ์ˆœ) + public PageResponse getMyComments( + Long memberId, + Integer page, + Integer size) { + + PageRequest pageable = PageRequest.of(page, size); + + Page commentPage = commentRepository.findMyComments(memberId, Status.ACTIVE, pageable); + + List responseList = commentPage.getContent().stream() + .map(MyCommentResponse::from) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + commentPage.getNumber(), + commentPage.getTotalPages(), + commentPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + public PageResponse getContentsCommentList(Long contentsId, int page, int size, boolean includeSpoiler) { + + if (!contentsRepository.existsByIdAndStatus(contentsId, Status.ACTIVE)) { + throw new BusinessException(ErrorCode.CONTENT_NOT_FOUND); + } + + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + + Page commentPage = commentRepository.findByContents_IdAndStatusWithSpoilerCondition(contentsId, Status.ACTIVE, includeSpoiler, pageable); + + List responseList = commentPage.getContent().stream() + .map(ContentsCommentResponse::from) + .collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.toPageInfo( + commentPage.getNumber(), + commentPage.getTotalPages(), + commentPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java new file mode 100644 index 0000000..66d078b --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java @@ -0,0 +1,17 @@ +package com.ott.api_user.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ContentSource { + TRENDING("TRENDING"), // ์ธ๊ธฐ ์ฐจํŠธ / ์‹ค์‹œ๊ฐ„ ํŠธ๋ Œ๋”ฉ ๋ฆฌ์ŠคํŠธ์—์„œ ์ง„์ž… ์‹œ + BOOKMARK("BOOKMARK"), // ๋ถ๋งˆํฌ/์‹œ์ฒญ ์ค‘ ๋ชฉ๋ก์—์„œ ์ง„์ž… ์‹œ + HISTORY("HISTORY"), // ์ตœ๊ทผ ์‹œ์ฒญ ์ค‘์ธ ์ฝ˜ํ…์ธ ์—์„œ ์ง„์ž… ์‹œ + TAG("TAG"), // ํŠน์ • ํƒœ๊ทธ(์˜ˆ: #์Šค๋ฆด๋Ÿฌ) ํด๋ฆญ ์‹œ + RECOMMEND("RECOMMEND"), // "OO๋‹˜์ด ์ข‹์•„ํ•  ๋งŒํ•œ ๋ฆฌ์ŠคํŠธ"์—์„œ ์ง„์ž… ์‹œ + SEARCH("SEARCH"); // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์ง„์ž… ์‹œ + + private final String value; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java new file mode 100644 index 0000000..438a11e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java @@ -0,0 +1,32 @@ +package com.ott.api_user.config; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +@Configuration +public class RestTemplateConfig { + + private static final int CONNECT_TIMEOUT_MS = 3_000; // ์—ฐ๊ฒฐ ํƒ€์ž„์•„์›ƒ 3์ดˆ + private static final int READ_TIMEOUT_MS = 5_000; // ์ฝ๊ธฐ ํƒ€์ž„์•„์›ƒ 5์ดˆ + + @Bean + public RestTemplate restTemplate() { + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .setResponseTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + + HttpClient httpClient = HttpClientBuilder.create() + .setDefaultRequestConfig(requestConfig) + .build(); + + return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java new file mode 100644 index 0000000..7df9d82 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -0,0 +1,114 @@ +package com.ott.api_user.config; + +import com.ott.api_user.auth.oauth2.CustomOAuth2UserService; +import com.ott.api_user.auth.oauth2.handler.OAuth2FailureHandler; +import com.ott.api_user.auth.oauth2.handler.OAuth2SuccessHandler; +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +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.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + // api-user ์ „์šฉ OAuth2 + private final CustomOAuth2UserService CustomOAuth2UserService; // ์นด์นด์˜ค์—์„œ ๋ฐ›์€ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ ํ›„ DB์— ์ ์žฌ + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + + @Value("${app.frontend-url:http://localhost:8080}") + private String frontedUrl; + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + return http + .csrf(AbstractHttpConfigurer::disable) // csrf ๋น„ํ™œ์„ฑํ™”, Authorization ํ—ค๋”๋กœ ๋ณด๋ƒ„ + .formLogin(AbstractHttpConfigurer::disable) // ์นด์นด์˜ค OAtuh2 + JWT๊ธฐ๋ฐ˜์ด๋ผ ๊ธฐ๋ณธ ๋กœ๊ทธ์ธ ํผ ์•ˆ์”€ + .httpBasic(AbstractHttpConfigurer::disable) // ์นด์นด์˜ค OAtuh2 + JWT๊ธฐ๋ฐ˜์ด๋ผ Basic ์ธ์ฆ ์•ˆ์”€ + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ์ด๋ผ ์„ธ์…˜ ์œ ์ง€ x + + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + + .authorizeHttpRequests(auth -> auth + // ์ธ์ฆ ๋ถˆํ•„์š” + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", + "/oauth2/**", + "/login/oauth2/**", + "/auth/reissue", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + + /* + ์—ญํ• ์ด MEMBER์ธ ์œ ์ €๋งŒ ๊ทธ ์™ธ EndPoint ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๋„๋ก ์„ค์ • + */ + .anyRequest().hasRole("MEMBER") + ) + + // OAuth2 ์นด์นด์˜ค ๋กœ๊ทธ์ธ + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(CustomOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + + // Spring Security๋ณด๋‹ค ๋จผ์ € ์‹คํ–‰ + // ์ฟ ํ‚ค์—์„œ AccessToken์„ ๊บผ๋‚ด์™€์„œ ๊ฒ€์ฆ ์ดํ›„ SecurityContext์— ์ธ์ฆ ์ •๋ณด ๋ฐ•์ œ + // ํ•ด๋‹น ๊ณผ์ •์—์„œ memberId, ROLE์„ context์— ๋„ฃ์–ด์คŒ + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // allowedOrigins -> ํ—ˆ์šฉํ•  ๋„๋ฉ”์ธ ๋‚ด์—ญ + // allowCredentials -> ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์š”์ฒญ์— ์ธ์ฆ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋Š” ๊ฒƒ์„ ํ—ˆ์šฉํ•˜๊ฒ ๋ƒ + // credentials๊ฐ€ true์ผ ๊ฒฝ์šฐ Allow-origin์˜ ๊ฒฝ์šฐ ๊ตฌ์ฒด์ ์ธ ๊ฒฝ๋กœ๋ฅผ ๋ช…์‹œํ•ด์•ผ๋จ + + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); // ๋ชจ๋“  ํ—ค๋” ๋‹ค ๋ฐ›๋Š”๋ฐ ์šฐ๋ฆฌ ์„œ๋น„์Šค์—์„œ๋Š” ์•ˆ์”€ + config.setAllowCredentials(true); // ์ฟ ํ‚ค ์š”์ฒญ์„ ํฌํ•จ + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // ์œ„ ์„ค์ •์„ ๋ชจ๋“  ๊ฒฝ๋กœ์— ์ ์šฉ + return source; + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java new file mode 100644 index 0000000..b7f41a3 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -0,0 +1,37 @@ +package com.ott.api_user.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import com.ott.api_user.content.dto.ContentsDetailResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Contents", description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ๋ฐ ์žฌ์ƒ ๊ด€๋ จ API") +public interface ContentsApi { + + @Operation(summary = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ", description = "๋‹จํŽธ ์˜ํ™”/์—ํ”ผ์†Œ๋“œ์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.(์ฝ˜ํ…์ธ  ์ƒ์„ธ ํŽ˜์ด์ง€)") + @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ์ฝ˜ํ…์ธ  ์ƒ์„ธ ๊ตฌ์„ฑ", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentsDetailResponse.class))) }), + @ApiResponse(responseCode = "200", description = "์ฝ˜ํ…์ธ  ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ContentsDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "์ฝ˜ํ…์ธ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{mediaId}") + ResponseEntity> getContentDetail( + @Parameter(description = "๋ฏธ๋””์–ด ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java new file mode 100644 index 0000000..29cb2e5 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -0,0 +1,29 @@ +package com.ott.api_user.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ott.api_user.content.dto.ContentsDetailResponse; +import com.ott.api_user.content.service.ContentsService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/contents") +public class ContentsController implements ContentsApi { + + private final ContentsService contentService; + + @Override + public ResponseEntity> getContentDetail( + @PathVariable(value = "mediaId") Long mediaId, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok( + SuccessResponse.of(contentService.getContentDetail(mediaId, memberId))); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java new file mode 100644 index 0000000..2923356 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java @@ -0,0 +1,83 @@ +package com.ott.api_user.content.dto; + +import java.util.List; + +import com.ott.domain.contents.domain.Contents; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์ปจํ…์ธ  ์ƒ์„ธ(์žฌ์ƒ) ์กฐํšŒ ์‘๋‹ต DTO") +public class ContentsDetailResponse { + @Schema(description = "๋ฏธ๋””์–ด ๊ณ ์œ  ID", example = "1") + private Long id; + + @Schema(description = "์‹œ๋ฆฌ์ฆˆ ๋ณธ์ฒด์˜ ๋ฏธ๋””์–ด ID (๋‹จํŽธ์ด๋ฉด null)", example = "101") + private Long seriesMediaId; + + + @Schema(description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") + private String title; + + @Schema(description = "์ฝ˜ํ…์ธ  ์„ค๋ช…", example = "๊ฒ€๊ฒฝ ์ˆ˜์‚ฌ๊ทน์˜ ์ƒˆ๋กœ์šด ์ง€ํ‰์„ ์—ฐ ๋“œ๋ผ๋งˆ") + private String description; + + @Schema(description = "์ถœ์—ฐ์ง„", example = "์†กํ˜œ๊ต, ์ด๋„ํ˜„, ์ž„์ง€์—ฐ") + private String actors; + + @Schema(description = "๊ฐ€๋กœํ˜• ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + @Schema(description = "์นดํ…Œ๊ณ ๋ฆฌ", example = "๋“œ๋ผ๋งˆ") + private String category; + + @Schema(description = "ํƒœ๊ทธ ๋ชฉ๋ก", example = "๋“œ๋ผ๋งˆ, ๋ฒ”์ฃ„, ์ˆ˜์‚ฌ") + private List tags; + + @Schema(description = "์‚ฌ์šฉ์ž ๋ถ๋งˆํฌ ์—ฌ๋ถ€", example = "true") + private Boolean isBookmarked; + + @Schema(description = "์‚ฌ์šฉ์ž ์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + private Boolean isLiked; + + @Schema(description = "๋งˆ์Šคํ„ฐ ์žฌ์ƒ๋ชฉ๋ก URL(HLS)", example = "https://example.com/master.m3u8") + private String masterPlaylistUrl; + + + @Schema(description= "์žฌ์ƒ ์‹œ๊ฐ„ (์ดˆ)", example = "3600") + private Integer duration; + + @Schema(description = "๊ธฐ์กด ์ด์–ด๋ณด๊ธฐ ์ง€์ (์—†์œผ๋ฉด 0)", example = "150") + private Integer positionSec; + + public static ContentsDetailResponse from(Contents contents, List tags, + List categories, Boolean isBookmarked, Boolean isLiked, String masterPlaylistUrl, + Integer positionSec) { + + Long seriesMediaId = null; + if (contents.getSeries() != null && contents.getSeries().getMedia() != null) { + seriesMediaId = contents.getSeries().getMedia().getId(); + } + + return ContentsDetailResponse.builder() + .id(contents.getMedia().getId()) + .seriesMediaId(seriesMediaId) + .title(contents.getMedia().getTitle()) + .description(contents.getMedia().getDescription()) + .actors(contents.getActors()) + .thumbnailUrl(contents.getMedia().getThumbnailUrl()) + .category(categories.isEmpty() ? null : categories.get(0)) + .tags(tags) + .isBookmarked(isBookmarked) + .isLiked(isLiked) + .masterPlaylistUrl(masterPlaylistUrl) + .duration(contents.getDuration()) + .positionSec(positionSec) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java new file mode 100644 index 0000000..5815874 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -0,0 +1,49 @@ +package com.ott.api_user.content.service; + +import java.util.List; +import org.springframework.stereotype.Service; +import com.ott.api_user.content.dto.ContentsDetailResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.tag.repository.TagRepository; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentsService { + private final ContentsRepository contentsRepository; + // private final PlaybackRepository playbackRepository; + + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final TagRepository tagRepository; + private final CategoryRepository categoryRepository; + + + // ์žฌ์ƒ ์ƒ์„ธ + public ContentsDetailResponse getContentDetail(Long mediaId, Long memberId) { + Contents contents = contentsRepository.findByMediaIdAndStatusAndMedia_PublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); + + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId,Status.ACTIVE); + Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); + + String masterPlaylistUrl = contents.getMasterPlaylistUrl(); + + Integer positionSec = 0; + + return ContentsDetailResponse.from(contents, tags, categories, isBookmarked, isLiked,masterPlaylistUrl, positionSec); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java new file mode 100644 index 0000000..be08fac --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java @@ -0,0 +1,33 @@ +package com.ott.api_user.likes.controller; + +import com.ott.api_user.likes.dto.request.LikesRequest; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Likes API", description = "์ข‹์•„์š” API") +public interface LikesAPI { + + @Operation(summary = "์ข‹์•„์š” API", description = "์ข‹์•„์š” ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. ๋“ฑ๋ก/์ทจ์†Œ ๋ชจ๋‘ ์ด API๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "์ข‹์•„์š” ์„ฑ๊ณต"), + @ApiResponse(responseCode = "404", description = "๋ฏธ๋””์–ด ๋˜๋Š” ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + ResponseEntity> editLikes( + @Valid @RequestBody LikesRequest request, + @Parameter(hidden = true) Long memberId + ); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java new file mode 100644 index 0000000..b8cc9c4 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java @@ -0,0 +1,29 @@ +package com.ott.api_user.likes.controller; + +import com.ott.api_user.likes.dto.request.LikesRequest; +import com.ott.api_user.likes.service.LikesService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/likes") +public class LikesController implements LikesAPI { + + private final LikesService likesService; + + @Override + public ResponseEntity> editLikes( + @Valid @RequestBody LikesRequest request, + @AuthenticationPrincipal Long memberId) { + + likesService.editLikes(memberId, request.getMediaId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java new file mode 100644 index 0000000..6bcae40 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java @@ -0,0 +1,16 @@ +package com.ott.api_user.likes.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "์ข‹์•„์š” ์š”์ฒญ DTO") +public class LikesRequest { + + @NotNull(message = "mediaId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Schema(type = "Long", description = "์ข‹์•„์š” ํ•  ๋ฏธ๋””์–ด ID", example = "1") + private Long mediaId; +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/response/.gitkeep similarity index 100% rename from apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep rename to apps/api-user/src/main/java/com/ott/api_user/likes/dto/response/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/likes/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java new file mode 100644 index 0000000..cafd15a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java @@ -0,0 +1,90 @@ +package com.ott.api_user.likes.service; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.Status; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.domain.Likes; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikesService { + + private final LikesRepository likesRepository; + private final MemberRepository memberRepository; + private final MediaRepository mediaRepository; + private final ContentsRepository contentsRepository; + + /** + * ์ข‹์•„์š” ๋ฒ„ํŠผ + * CONTENTS โ†’ ์‹œ๋ฆฌ์ฆˆ ์—ํ”ผ์†Œ๋“œ๋ฉด ๋ถ€๋ชจ Series.media๋กœ ์ฒ˜๋ฆฌ + * CONTENTS โ†’ ์‹œ๋ฆฌ์ฆˆ๊ฐ€ ์•„๋‹๊ฒฝ์šฐ ์ž๊ธฐ ์ž์‹  ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + * SHORT_FORM โ†’ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + * SERIES โ†’ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ + */ + @Transactional + public void editLikes(Long memberId, Long mediaId) { + + Media findMedia = mediaRepository.findById(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + + // ์‹ค์ œ ์ข‹์•„์š” ์ฒ˜๋ฆฌํ•  ๋ฏธ๋””์–ด ๊ฒฐ์ • + Media targetMedia = resolveTargetMedia(findMedia); + + // likes ํ…Œ์ด๋ธ”์—์„œ ์ฒ˜์Œ ๋“ฑ๋กํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•จ + likesRepository.findByMemberIdAndMediaId(memberId, targetMedia.getId()) + .ifPresentOrElse( + likes -> { + + if (likes.getStatus() == Status.ACTIVE) { + // ๊ธฐ๋ก์ด ์žˆ์„ ๊ฒฝ์šฐ ACTIVE โ†’ DELETE + ์นด์šดํŠธ ๊ฐ์†Œ + likes.updateStatus(Status.DELETE); + targetMedia.decreaseLikesCount(); + } else { + // ๊ธฐ๋ก์ด ์—†์„ ๊ฒฝ์šฐ DELETE โ†’ ACTIVE + ์นด์šดํŠธ ์ฆ๊ฐ€ + likes.updateStatus(Status.ACTIVE); + targetMedia.increaseLikesCount(); + } + }, + () -> { + // ์‹ ๊ทœ ์ข‹์•„์š”์ผ ๊ฒฝ์šฐ insert + ์นด์šดํŠธ ์ฆ๊ฐ€ + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // insert + likesRepository.save(Likes.builder() + .member(findMember) + .media(targetMedia) + .build()); + + // ์นด์šดํŠธ ์ฆ๊ฐ€ + targetMedia.increaseLikesCount(); + }); + } + + /** + * mediaType์— ๋”ฐ๋ผ ์‹ค์ œ ์ข‹์•„์š” ์ฒ˜๋ฆฌํ•  ํƒ€๊ฒŸ Media ๋ฐ˜ํ™˜ + * CONTENTS โ†’ series ์†Œ์†์ด๋ฉด series.media ๋ฐ˜ํ™˜ + * CONTENTS โ†’ series ์†Œ์†์ด ์•„๋‚˜๋ฉด ์ž๊ธฐ ์ž์‹  media ๋ฐ˜ํ™˜ + * SHORT_FORM โ†’ ์ž๊ธฐ ์ž์‹  media ๋ฐ˜ํ™˜ + * SERIES โ†’ ์ž๊ธฐ ์ž์‹  series ๋ฐ˜ํ™˜ + */ + private Media resolveTargetMedia(Media media) { + return switch (media.getMediaType()) { + case CONTENTS -> contentsRepository.findByMediaId(media.getId()) + .filter(contents -> contents.getSeries() != null) // ์‹œ๋ฆฌ์ฆˆ ์—ํ”ผ์†Œ๋“œ์ธ์ง€ ํ™•์ธ + .map(contents -> contents.getSeries().getMedia()) // ๋ถ€๋ชจ Series.media๋กœ ๊ต์ฒด + .orElse(media); // ๋‹จํŽธ์ด๋ฉด ๊ทธ๋Œ€๋กœ + + case SERIES, SHORT_FORM -> media; // ์‹œ๋ฆฌ์ฆˆ ์ž์ฒด or ์ˆํผ์€ ํ•ญ์ƒ ๊ทธ๋Œ€๋กœ + }; + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java new file mode 100644 index 0000000..781ffd7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -0,0 +1,144 @@ +package com.ott.api_user.member.controller; + +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; +import com.ott.api_user.member.dto.response.*; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + + +@RequestMapping("/member") +@Tag(name = "Member", description = "๋งˆ์ดํŽ˜์ด์ง€ API") +@SecurityRequirement(name = "BearerAuth") // ์ธ์ฆ์ธ๊ฐ€ ํ™•์ธ +public interface MemberApi { + + // ------------------------------------------------------- + // ๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ + // ------------------------------------------------------- + @Operation(summary = "๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ", description = "๋กœ๊ทธ์ธํ•œ ํšŒ์›์˜ ๋‹‰๋„ค์ž„๊ณผ ์„ ํ˜ธ ํƒœ๊ทธ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MyPageResponse.class)) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId); + + + // ------------------------------------------------------- + // ๋‚ด ์ •๋ณด ์ˆ˜์ • + // ------------------------------------------------------- + @Operation(summary = "๋‚ด ์ •๋ณด ์ˆ˜์ •", description = "๋‹‰๋„ค์ž„, ์„ ํ˜ธ ํƒœ๊ทธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ํ•„๋“œ๋Š” ์„ ํƒ์ ์œผ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅํ•˜๋ฉฐ null์ด๋ฉด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์ˆ˜์ • ์„ฑ๊ณต - ๋ณ€๊ฒฝ๋œ ๋งˆ์ดํŽ˜์ด์ง€ ์ •๋ณด ๋ฐ˜ํ™˜", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MyPageResponse.class)) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์› ๋˜๋Š” ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity> updateMyInfo( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody UpdateMemberRequest request + ); + + + // ------------------------------------------------------- + // ์˜จ๋ณด๋”ฉ ์„ ํ˜ธ ํƒœ๊ทธ ์ €์žฅ + // ------------------------------------------------------- + @Operation(summary = "์˜จ๋ณด๋”ฉ์—์„œ ์„ ํ˜ธ ํƒœ๊ทธ ์ €์žฅ", description = "์˜จ๋ณด๋”ฉ ํ™”๋ฉด์—์„œ ์ฒ˜์Œ ์„ ํ˜ธ ํƒœ๊ทธ๋ฅผ ์ˆ˜์ง‘ํ•ฉ๋‹ˆ๋‹ค. ์œ ์ €๋งˆ๋‹ค 1ํšŒ๋งŒ ์‹คํ–‰" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", description = "์„ ํ˜ธ ํƒœ๊ทธ ์ €์žฅ ์„ฑ๊ณต" + ), + @ApiResponse( + responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ (๋นˆ ํƒœ๊ทธ ๋ชฉ๋ก, ์ค‘๋ณต ํƒœ๊ทธ ID ๋“ฑ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์› ๋˜๋Š” ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @PostMapping("/me/tags") + ResponseEntity> setPreferredTags( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody SetPreferredTagRequest request + ); + + + // ------------------------------------------------------- + // ์˜จ๋ณด๋”ฉ ๊ฑด๋„ˆ ๋›ฐ๊ธฐ + // ------------------------------------------------------- + @Operation(summary = "์˜จ๋ณด๋”ฉ ๊ฑด๋„ˆ๋›ฐ๊ธฐ", description = "์˜จ๋ณด๋”ฉ์„ ๊ฑด๋„ˆ๋›ธ ๊ฒฝ์šฐ onboardingCompleted๋ฅผ true๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "๊ฑด๋„ˆ๋›ฐ๊ธฐ ์„ฑ๊ณต"), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/me/onboarding/skip") + ResponseEntity skipOnboarding( + @AuthenticationPrincipal Long memberId); + + + // ============================================================ + // ํšŒ์› ํƒˆํ‡ด + // ============================================================ + @Operation(summary = "ํšŒ์› ํƒˆํ‡ด", description = "ํšŒ์› ํƒˆํ‡ด ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ ๋ฐ ๋ชจ๋“  ๋ฐ์ดํ„ฐ Soft Delete.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "ํƒˆํ‡ด ์„ฑ๊ณต"), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/me") + ResponseEntity withdraw( + @Parameter HttpServletResponse response, + @AuthenticationPrincipal Long memberId); + +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java new file mode 100644 index 0000000..b80cd7e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -0,0 +1,73 @@ +package com.ott.api_user.member.controller; + +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; +import com.ott.api_user.member.dto.response.*; +import com.ott.api_user.member.service.MemberService; +import com.ott.common.security.util.CookieUtil; +import com.ott.common.web.response.SuccessResponse; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/member") +@RequiredArgsConstructor +public class MemberController implements MemberApi { + + private final MemberService memberService; + private final CookieUtil cookie; + + @Override + @GetMapping("/me") + public ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId) { + MyPageResponse response = memberService.getMyPage(memberId); + return ResponseEntity.ok(SuccessResponse.of(response)); + } + + @Override + @PatchMapping("/me") + public ResponseEntity> updateMyInfo( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody UpdateMemberRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.updateMyInfo(memberId, request))); + + } + + @Override + @PostMapping("/me/tags") + public ResponseEntity> setPreferredTags( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody SetPreferredTagRequest request + ) { + memberService.setPreferredTags(memberId, request); + return ResponseEntity.noContent().build(); + } + + + // ํšŒ์› ํƒˆํ‡ด - ํ˜„์žฌ soft delete + // ํšŒ์› ํƒˆํ‡ด ์‹œ DB + ๋ธŒ๋ผ์šฐ์ € ํ† ํฐ ์‚ญ์ œ + @DeleteMapping("/me") + public ResponseEntity withdraw( + HttpServletResponse response, + @AuthenticationPrincipal Long memberId) { + memberService.withdraw(memberId); + + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + // ์˜จ๋ณด๋”ฉ ์Šคํ‚ต ์‹œ ์˜จ๋ณด๋”ฉ ์ปฌ๋Ÿผ true ๋ณ€๊ฒฝ + @Override + @PostMapping("/me/onboarding/skip") + public ResponseEntity skipOnboarding(@AuthenticationPrincipal Long memberId) { + memberService.skipOnboarding(memberId); + return ResponseEntity.noContent().build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java new file mode 100644 index 0000000..16b2897 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java @@ -0,0 +1,21 @@ +package com.ott.api_user.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@Schema(description = "์„ ํ˜ธ ํƒœ๊ทธ ์„ค์ • ์š”์ฒญ DTO (์˜จ๋ณด๋”ฉ)") +public class SetPreferredTagRequest { + + @NotEmpty(message = "ํƒœ๊ทธ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”") + @NotNull + @Schema(type ="List", example = "[1, 3, 13]", description = "์„ ํƒํ•œ ํƒœ๊ทธ ID ๋ชฉ๋ก") + private List tagsId; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java new file mode 100644 index 0000000..1ee9c0b --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java @@ -0,0 +1,21 @@ +package com.ott.api_user.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@Schema(description = "๋‚ด ์ •๋ณด ์ˆ˜์ • ์š”์ฒญ DTO") +public class UpdateMemberRequest { + + @Schema(type = "String", example = "๊น€๋งˆ๋ฃจ1", description = "๋ณ€๊ฒฝํ•  ๋‹‰๋„ค์ž„ / null์ธ ๊ฒฝ์šฐ ๋ณ€๊ฒฝ x") + private String nickname; + + @Schema(type = "List", example = "[1, 3, 14]", description = "๋ณ€๊ฒฝํ•  ์„ ํ˜ธ ํƒœ๊ทธ ID ๋ชฉ๋ก / null์ด๋ฉด ๋ณ€๊ฒฝ x") + private List tagIds; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java new file mode 100644 index 0000000..1057429 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java @@ -0,0 +1,56 @@ +package com.ott.api_user.member.dto.response; + +import com.ott.domain.member.domain.Member; +import com.ott.domain.preferred_tag.domain.PreferredTag; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ DTO") +public class MyPageResponse { + + @Schema(type = "Long", example = "1", description = "ํšŒ์› ๊ณ ์œ  ID") + private Long memberId; + + @Schema(type = "String", example = "๊น€๋งˆ๋ฃจ", description = "๋‹‰๋„ค์ž„") + private String nickname; + + @Schema(description = "์„ ํ˜ธ ํƒœ๊ทธ ๋ชฉ๋ก") + private List preferredTags; + + + public static MyPageResponse from(Member member, List preferredTags) { + List tagInfos = preferredTags.stream() + .map(pt -> PreferredTagInfo.builder() + .tagId(pt.getTag().getId()) + .display(pt.getTag().getCategory().getName() + " | " + pt.getTag().getName()) + .build()) + .toList(); + + return MyPageResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .preferredTags(tagInfos) // ์„ ํ˜ธํƒœ๊ทธ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ ์ถœ๋ ฅ + .build(); + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "์„ ํ˜ธ ํƒœ๊ทธ ์•„์ดํ…œ") + public static class PreferredTagInfo { + + @Schema(type = "Long", example = "13", description = "ํƒœ๊ทธ ๊ณ ์œ  ID") + private Long tagId; + + @Schema(type = "String", example = "๋“œ๋ผ๋งˆ | ์Šค๋ฆด๋Ÿฌ", description = "ํ”„๋ก ํŠธ ํ™”๋ฉด์šฉ String") + private String display; + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java new file mode 100644 index 0000000..b114b52 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -0,0 +1,191 @@ +package com.ott.api_user.member.service; + +import com.ott.api_user.auth.client.KakaoUnlinkClient; +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; +import com.ott.api_user.member.dto.response.*; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.click_event.repository.ClickRepository; +import com.ott.domain.comment.repository.CommentRepository; +import com.ott.domain.common.Status; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.preferred_tag.domain.PreferredTag; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.RecentWatchProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final PreferredTagRepository preferredTagRepository; + private final TagRepository tagRepository; + private final WatchHistoryRepository watchHistoryRepository; + private final MediaRepository mediaRepository; + + // ํšŒ์› ํƒˆํ‡ด ์‹œ soft delete + private final KakaoUnlinkClient kakaoUnlinkClient; + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final PlaybackRepository playbackRepository; + private final CommentRepository commentRepository; + private final ClickRepository clickRepository; + + /** + * ๋งˆ์ด ํŽ˜์ด์ง€ ์กฐํšŒ : ๋‹‰๋„ค์ž„, ์„ ํ˜ธํƒœ๊ทธ List ๋ฐ˜ํ™˜ + */ + public MyPageResponse getMyPage(Long memberId) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ๊ฑด๋„ˆ๋›ฐ๊ธฐํ•œ ์œ ์ €๋Š” ๋นˆ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ + List preferredTags = preferredTagRepository. + findAllWithTagAndCategoryByMemberIdAndStatus(memberId, Status.ACTIVE); + + return MyPageResponse.from(findMember, preferredTags); + } + + + /** + * ๋งˆ์ดํŽ˜์ด์ง€ ๋‚ด ์ •๋ณด ์ˆ˜์ • : ๋‹‰๋„ค์ž„, ์„ ํ˜ธํƒœ๊ทธ ๋ณ€๊ฒฝ ํ›„ ๋ฐ˜ํ™˜ + */ + @Transactional + public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ + if (request.getNickname() != null) { + if (request.getNickname().isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "๊ณต๋ฐฑ์€ ์ž…๋ ฅํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + findMember.updateNickname(request.getNickname()); + } + + + // ์„ ํ˜ธ ํƒœ๊ทธ ๋ณ€๊ฒฝ -> ํƒœ๊ทธ๊ฐ€ ๋„์ธ ๊ฒฝ์šฐ? -> ์ผ๋‹จ ๋งŒ๋“ค๊ณ  ์ถ”๊ฐ€ ์˜ˆ์ • + // 1. ๋ชจ๋“  ํƒœ๊ทธ ์‚ญ์ œ + if (request.getTagIds() != null) { + preferredTagRepository.deleteAllByMember(findMember); + + // ์ƒˆ ํƒœ๊ทธ ์ €์žฅ + List tags = tagRepository.findAllByIdInAndStatus(request.getTagIds(), Status.ACTIVE); + if (tags.size() != request.getTagIds().size()) { + throw new BusinessException(ErrorCode.TAG_NOT_FOUND); + } + + List newTags = tags.stream() + .map(tag -> PreferredTag.builder() + .member(findMember) + .tag(tag) + .build()) + .toList(); + preferredTagRepository.saveAll(newTags); + } + + // ๋ณ€๊ฒฝ ํ•˜๊ณ  ์ตœ์‹  ์ƒํƒœ ์œ ์ง€ + List preferredTags = preferredTagRepository + .findAllWithTagAndCategoryByMemberIdAndStatus(memberId, Status.ACTIVE); + + return MyPageResponse.from(findMember, preferredTags); + } + + /** + * ์˜จ๋ณด๋”ฉ ํ™”๋ฉด : ์ดˆ๊ธฐ 1ํšŒ๋งŒ ๋…ธ์ถœ๋จ + */ + @Transactional + public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { + Member findMember = memberRepository.findByIdAndStatus(memberId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + //์žฌ ํ˜ธ์ถœ ์‹œ ์ค‘๋ณต ๋ฐฉ์ง€ ์ฝ”๋“œ + preferredTagRepository.deleteAllByMember(findMember); + + List tags = tagRepository.findAllByIdInAndStatus(request.getTagsId(), Status.ACTIVE); + if (tags.size() != request.getTagsId().size()) { + throw new BusinessException(ErrorCode.TAG_NOT_FOUND); + } + + List preferredTags = tags.stream() + .map(tag -> PreferredTag.builder() + .member(findMember) + .tag(tag) + .build()) + .toList(); + + preferredTagRepository.saveAll(preferredTags); + findMember.completeOnboarding(); + } + + + /** + * ํšŒ์› ํƒˆํ‡ด + * 1. ์นด์นด์˜ค ํšŒ์›์ธ ๊ฒฝ์šฐ ์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ + * 2. ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ Soft Delete + * 3. ํšŒ์› Soft Delete + refreshToken ์ดˆ๊ธฐํ™” + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 1. ์นด์นด์˜ค ์—ฐ๊ฒฐ ๋Š๊ธฐ + if (member.getProvider() == Provider.KAKAO) { + kakaoUnlinkClient.unlink(member.getProviderId()); + } + + // ๋ฒŒํฌ ์ฟผ๋ฆฌ ์ดํ›„ ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ๊ฐ€ ์ดˆ๊ธฐํ™” ๋˜๊ธฐ ๋•Œ๋ฌธ์— member.withdraw ๋จผ์ € ์ˆ˜ํ–‰ + // JPA๊ฐ€ ๋ณ€๊ฒฝ ๊ฐ์ง€๋ฅผ ๋ชปํ•ด์„œ ์ˆœ์„œ ๋ณ€๊ฒฝ + // 2. ํšŒ์› Soft Delete + member.withdraw(); + + // ํƒˆํ‡ด ํšŒ์›์˜ ACTIVEํ•œ ๋ถ๋งˆํฌ ์ˆ˜ ์ฐจ๊ฐ + bookmarkRepository.decreaseBookmarkCountByMemberId(memberId); + bookmarkRepository.softDeleteAllByMemberId(memberId); + + + // ํƒˆํ‡ด ํšŒ์›์˜ ACTIVEํ•œ ์ข‹์•„์š” ์ˆ˜ ์ฐจ๊ฐ + likesRepository.decreaseLikesCountByMemberId(memberId); + likesRepository.softDeleteAllByMemberId(memberId); + + // 3. ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ Soft Delete + preferredTagRepository.softDeleteAllByMemberId(memberId); + watchHistoryRepository.softDeleteAllByMemberId(memberId); + playbackRepository.softDeleteAllByMemberId(memberId); + commentRepository.softDeleteAllByMemberId(memberId); + clickRepository.softDeleteAllByMemberId(memberId); + } + + /** + * ์˜จ๋ณด๋”ฉ ๊ฑด๋„ˆ๋›ฐ๊ธฐ - onboardingCompleted = true ์ฒ˜๋ฆฌ + */ + @Transactional + public void skipOnboarding(Long memberId) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + findMember.completeOnboarding(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java new file mode 100644 index 0000000..9f61b4a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java @@ -0,0 +1,156 @@ +package com.ott.api_user.playlist.controller; + +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@RequestMapping("/playlists") +@SecurityRequirement(name = "BearerAuth") // ์ธ์ฆ์ธ๊ฐ€ ํ™•์ธ +@Tag(name = "Playlist", description = "ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ API") +public interface PlayListAPI { + + // ------------------------------------------------------- + // ํƒœ๊ทธ๋ณ„ ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ + // ------------------------------------------------------- + @Operation(summary = "[๋งˆ์ดํŽ˜์ด์ง€] ํƒœ๊ทธ๋ณ„ ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ๋ฆฌ์ŠคํŠธ ์กฐํšŒ", description = "ํ•ด๋‹น ํƒœ๊ทธ์— ์†ํ•˜๋Š” ์ฝ˜ํ…์ธ ๋ฅผ ์ตœ๋Œ€ 20๊ฐœ ๋ฐ˜ํ™˜" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagPlaylistResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", description = "์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์› ๋˜๋Š” ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/{tagId}") + ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); + + + // ------------------------------------------------------- + // ์ „์ฒด ์‹œ์ฒญ์ด๋ ฅ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง• ์กฐํšŒ + // ------------------------------------------------------- + @Operation(summary = "[๋งˆ์ดํŽ˜์ด์ง€] ๊ณผ๊ฑฐ ์‹œ์ฒญ ์ด๋ ฅ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ", description = "์ „์ฒด ์‹œ์ฒญ์ด๋ ฅ์„ ์ตœ์‹ ์ˆœ์œผ๋กœ 10๊ฐœ์”ฉ ํŽ˜์ด์ง• ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ด์–ด๋ณด๊ธฐ ์‹œ์  ํฌํ•จ.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", description = "ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me/history") + ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0") + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ); + + @Operation(summary = "OO ๋‹˜์ด ์ข‹์•„ํ•˜์‹ค๋งŒํ•œ ์ฝ˜ํ…์ธ ", description = "์œ ์ € ์ทจํ–ฅ์„ ํ•ฉ์‚ฐํ•˜์—ฌ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. (ํ™ˆ ํ™”๋ฉด ์…”ํ”Œ ์ง€์›)") + @GetMapping("/recommend") + ResponseEntity>> getRecommendPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ(0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์„ ํ˜ธ ํƒœ๊ทธ ์ˆœ์œ„๋ณ„ ๋ฆฌ์ŠคํŠธ", description = "์œ ์ €์˜ Top 3 ํƒœ๊ทธ ์ˆœ์œ„๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/tags/top") + ResponseEntity> getTopTagPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Max(value = 2, message = "์ธ๋ฑ์Šค๋Š” 2 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") @Parameter(description = "์œ ์ € ์ทจํ–ฅ ์ˆœ์œ„ (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์ƒ์„ธ ํŽ˜์ด์ง€ - ํŠน์ • ํ•ด์‹œํƒœ๊ทธ ๋ฆฌ์ŠคํŠธ", description = "ํ•ด๋‹น ํƒœ๊ทธ์˜ ์˜์ƒ๋งŒ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/tags/{tagId}") + ResponseEntity>> getTagPlaylists( + @Parameter(description = "ํƒœ๊ทธ ID", required = true) @PathVariable(value = "tagId") Long tagId, + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์ธ๊ธฐ ์ฐจํŠธ (Trending)", description = "๋ถ๋งˆํฌ๊ฐ€ ๋งŽ์€ ์ธ๊ธฐ ์ˆœ์„œ๋Œ€๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/trending") + ResponseEntity>> getTrendingPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "์‹œ์ฒญ ์ด๋ ฅ (History)", description = "์œ ์ €๊ฐ€ ์ตœ๊ทผ ์‹œ์ฒญํ•œ ์˜์ƒ ๋ชฉ๋ก์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/history") + ResponseEntity>> getHistoryPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "๋ถ๋งˆํฌ ๋ชฉ๋ก (Bookmark)", description = "์œ ์ €๊ฐ€ ๋ถ๋งˆํฌํ•œ ์˜์ƒ ๋ชฉ๋ก์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/bookmarks") + ResponseEntity>> getBookmarkPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "๊ฒ€์ƒ‰ ์ƒ์„ธ ํŽ˜์ด์ง€ ์žฌ์ƒ๋ชฉ๋ก", description = "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์ง„์ž… ์‹œ ์ข…ํ•ฉ ์ถ”์ฒœ ๋ฆฌ์ŠคํŠธ๋กœ ๋Œ€์ฒดํ•˜์—ฌ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/search") + ResponseEntity>> getSearchPlaylists( + @Parameter(description = "ํ˜„์žฌ ์˜์ƒ ID", required = true) @RequestParam(value = "excludeMediaId") Long excludeMediaId, + @PositiveOrZero @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java new file mode 100644 index 0000000..f9dbd9d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -0,0 +1,191 @@ +package com.ott.api_user.playlist.controller; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.common.ContentSource; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; +import com.ott.api_user.playlist.service.PlaylisStrategytService; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.service.PlaylistService; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/playlists") +public class PlaylistController implements PlayListAPI { + + private final PlaylistService playlistService; + private final PlaylisStrategytService playlisStrategytService; + + // ํƒœ๊ทธ ๋ณ„ ์ถ”์ฒœ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + @Override + @GetMapping("/me/{tagId}") + public ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(playlistService.getRecommendContentsByTag(memberId, tagId))); + } + + + // ๊ณผ๊ฑฐ ์‹œ์ฒญ ์ด๋ ฅ ์กฐํšŒ, 10๊ฐœ์”ฉ ์กฐํšŒ + @Override + @GetMapping("/me/history") + public ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ) { + return ResponseEntity.ok(SuccessResponse.of(playlistService.getWatchHistoryPlaylist(memberId, page))); + + } + + + // 1. ์ข…ํ•ฉ ์ถ”์ฒœ + @Override + public ResponseEntity>> getRecommendPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.RECOMMEND); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 2. Top 3 ํƒœ๊ทธ๋ณ„ ๋ฆฌ์ŠคํŠธ + @Override + public ResponseEntity> getTopTagPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "index") Integer index, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TAG); + condition.setIndex(index); + condition.setExcludeMediaId(excludeMediaId); + + if (memberId != null) { + condition.setMemberId(memberId); + } + + Pageable pageable = PageRequest.of(page, size); + + return ResponseEntity.ok(SuccessResponse.of(playlisStrategytService.getTopTagPlaylistWithMetadata(condition, pageable))); + } + + // 3. ํŠน์ • ํƒœ๊ทธ ๋‹จ๊ฑด ๋ฆฌ์ŠคํŠธ + @Override + public ResponseEntity>> getTagPlaylists( + @PathVariable(value = "tagId") Long tagId, + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TAG); + condition.setTagId(tagId); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 4. ์ธ๊ธฐ ์ฐจํŠธ + @Override + public ResponseEntity>> getTrendingPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TRENDING); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + + + // 5. ์‹œ์ฒญ ์ด๋ ฅ + @Override + public ResponseEntity>> getHistoryPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.HISTORY); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 6. ๋ถ๋งˆํฌ + @Override + public ResponseEntity>> getBookmarkPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.BOOKMARK); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 8. ๊ฒ€์ƒ‰ ํŽ˜์ด์ง€์—์„œ ์ง„์ž… + @Override + public ResponseEntity>> getSearchPlaylists( + @RequestParam(value = "excludeMediaId") Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.SEARCH); + condition.setExcludeMediaId(excludeMediaId); + + // ์„œ๋น„์Šค๋‹จ์—์„œ RECOMMEND๋กœ ์šฐํšŒ๋จ! + return execute(condition, page, size, memberId); + } + + + // ๊ณตํ†ต ์‘๋‹ต ๋ฉ”์„œ๋“œ + private ResponseEntity>> execute( + PlaylistCondition condition, Integer pageParam, Integer sizeParam, Long memberId) { + + if (memberId != null) { + condition.setMemberId(memberId); + } + + Pageable pageable = PageRequest.of(pageParam, sizeParam); + + return ResponseEntity.ok(SuccessResponse.of(playlisStrategytService.getPlaylists(condition, pageable))); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java new file mode 100644 index 0000000..d650259 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java @@ -0,0 +1,39 @@ +package com.ott.api_user.playlist.dto.request; + + + +import com.ott.api_user.common.ContentSource; +import com.ott.domain.common.MediaType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +// ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ๊ณตํ†ต ์š”์ฒญ DTO +// ์ง„์ž…์ , ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ or ์žฌ์ƒ๋ชฉ๋ก (๋‘˜์€ ์žฌ์‚ฌ์šฉ, ํ˜„์žฌ ์ปจํ…์ธ  id ์— ๋”ฐ๋ผ) ์— ๋”ฐ๋ผ ๋“ค์–ด์˜ค๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉ +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "ํ”Œ๋ ˆ์ด ๋ฆฌ์ŠคํŠธ ๊ณตํ†ต ์š”์ฒญ DTO") +public class PlaylistCondition { + + @Schema(description = "์ง„์ž…์  ์†Œ์Šค ํƒ€์ž…", example = "TRENDING, RECOMMEND, HISTORY ๋“ฑ ..") + private ContentSource contentSource; + + @Schema(description = "๋ฏธ๋””์–ด ํƒ€์ž…", example = "SERIES, CONTENTS") + private MediaType mediaType; + + @Schema(description = "์‚ฌ์šฉ์ž ๊ณ ์œ  ID", example = "1") + private Long memberId; + + @Schema(description = "ํ˜„์žฌ ์ปจํ…์ธ ์˜ Id", example = "1") // ์ƒ์„ธ ํŽ˜์ด์ง€ ์ง„์ž… ์‹œ ์žฌ์ƒ๋ชฉ๋ก์—์„œ ์ œ์™ธ + private Long excludeMediaId; + + @Schema(description = "ํƒœ๊ทธ ๊ณ ์œ  ID", example = "1") + private Long tagId; + + @Schema(description = "ํƒœ๊ทธ ๋žญํ‚น ์ธ๋ฑ์Šค (0, 1, 2)", example = "0") + private Integer index; +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java new file mode 100644 index 0000000..47c89e7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java @@ -0,0 +1,41 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import lombok.AccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ์กฐํšŒ ์‘๋‹ต DTO") +public class PlaylistResponse { + + @Schema(description = "๋ฏธ๋””์–ด ๊ณ ์œ  ID", example = "100") + private Long mediaId; + + @Schema(description = "์ปจํ…์ธ  ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") + private String title; + + @Schema(description = "ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ URL", example = "https://s3.../poster.jpg") + private String posterUrl; + + @Schema(description = "๊ฐ€๋กœํ˜• ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + @Schema(description = "๋ฏธ๋””์–ด ํƒ€์ž… (UI ๋ถ„๊ธฐ ์ฒ˜๋ฆฌ ๋ฐ ๋ผ์šฐํŒ…์šฉ)", example = "SERIES") + private MediaType mediaType; + + public static PlaylistResponse from(Media media) { + return PlaylistResponse.builder() + .mediaId(media.getId()) + .title(media.getTitle()) + .posterUrl(media.getPosterUrl()) + .thumbnailUrl(media.getThumbnailUrl()) + .mediaType(media.getMediaType()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java new file mode 100644 index 0000000..7d8162e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java @@ -0,0 +1,40 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.domain.common.MediaType; +import com.ott.domain.watch_history.repository.RecentWatchProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์‹œ์ฒญ์ด๋ ฅ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ DTO") +public class RecentWatchResponse { + + @Schema(type = "Long", example = "3", description = "๋ฏธ๋””์–ด ID") + private Long mediaId; + + @Schema(type = "String", example = "CONTENTS", description = "๋ฏธ๋””์–ด ํƒ€์ž… (CONTENTS, SERIES, SHORT_FORM)") + private MediaType mediaType; + + @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "ํฌ์Šคํ„ฐ URL") + private String posterUrl; + + @Schema(type = "Integer", example = "150", description = "์ด์–ด๋ณด๊ธฐ ์‹œ์  (์ดˆ), ์—†์œผ๋ฉด 0") + private Integer positionSec; + + @Schema(type = "Integer", example = "3600", description = "์ „์ฒด ์žฌ์ƒ ์‹œ๊ฐ„ (์ดˆ)") + private Integer duration; + + public static RecentWatchResponse from(RecentWatchProjection projection) { + return RecentWatchResponse.builder() + .mediaId(projection.getMediaId()) + .mediaType(projection.getMediaType()) + .posterUrl(projection.getPosterUrl()) + .positionSec(projection.getPositionSec()) + .duration(projection.getDuration()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java new file mode 100644 index 0000000..173337b --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java @@ -0,0 +1,32 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.domain.common.MediaType; +import com.ott.domain.media.repository.TagContentProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "ํƒœ๊ทธ๋ณ„ ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ์•„์ดํ…œ") +public class TagPlaylistResponse { + + @Schema(type = "Long", example = "5", description = "๋ฏธ๋””์–ด ID") + private Long mediaId; + + @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "ํฌ์Šคํ„ฐ URL") + private String posterUrl; + + @Schema(type = "String", example = "SERIES", description = "๋ฏธ๋””์–ด ํƒ€์ž… (SERIES / CONTENTS)") + private MediaType mediaType; + + public static TagPlaylistResponse from(TagContentProjection projection) { + return TagPlaylistResponse.builder() + .mediaId(projection.getMediaId()) + .posterUrl(projection.getPosterUrl()) + .mediaType(projection.getMediaType()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java new file mode 100644 index 0000000..7149d72 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java @@ -0,0 +1,40 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.common.web.response.PageResponse; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "ํ™ˆ ํ™”๋ฉด ํƒœ๊ทธ๋ณ„ ์„น์…˜ ์‘๋‹ต DTO") +public class TopTagPlaylistResponse { + + // ์œ ๋นˆ๋‹˜์ด ์ข‹์•„ํ•˜๋Š” #๋กœ๋งจ์Šค ์˜ํ™” + // ์œ ๋นˆ๋‹˜์ด ์ข‹์•„ํ•˜๋Š” #๋กœ๋งจ์Šค ๋“œ๋ผ๋งˆ + @Schema(description = "์นดํ…Œ๊ณ ๋ฆฌ ์ •๋ณด") + private CategoryInfo category; + + @Schema(description = "ํƒœ๊ทธ ์ •๋ณด") + private TagInfo tag; + + @Schema(description = "ํ•ด๋‹น ํƒœ๊ทธ์˜ ๋ฏธ๋””์–ด ๋ชฉ๋ก") + private PageResponse medias; + + @Getter + @Builder + public static class CategoryInfo { + private Long id; + private String name; + } + + @Getter + @Builder + public static class TagInfo { + private Long id; + private String name; + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java new file mode 100644 index 0000000..f33a8f8 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java @@ -0,0 +1,121 @@ +package com.ott.api_user.playlist.service; + +import com.ott.api_user.common.ContentSource; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; +import com.ott.api_user.playlist.service.strategy.PlaylistStrategy; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.media.domain.Media; +import com.ott.domain.tag.domain.Tag; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaylisStrategytService { + + private final Map strategyMap; + private final PlaylistPreferenceService preferenceService; + + public PageResponse getPlaylists(PlaylistCondition condition, Pageable pageable) { + + if (condition.getContentSource() == null) { + throw new BusinessException(ErrorCode.INVALID_PLAYLIST_SOURCE); + } + + // 1. ์ „๋žต ์„ ํƒ + PlaylistStrategy strategy = getStrategy(condition); + + // 2. ๋ฐ์ดํ„ฐ ์กฐํšŒ + Page mediaPage = strategy.getPlaylist(condition, pageable); + + // 3. Entity -> DTO ๋ณ€ํ™˜ + List contentList = mediaPage.getContent().stream() + .map(PlaylistResponse::from) + .toList(); + + // 4. PageInfo ์ƒ์„ฑ + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + (int) mediaPage.getTotalElements() + ); + + return PageResponse.toPageResponse(pageInfo, contentList); + } + + + public TopTagPlaylistResponse getTopTagPlaylistWithMetadata(PlaylistCondition condition, Pageable pageable){ + // ์œ„ ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ๋ฏธ๋””์–ด ๋ฆฌ์ŠคํŠธ ์ถ”์ถœ + PageResponse mediaPage = getPlaylists(condition, pageable); + + List topTags = preferenceService.getTopTags(condition.getMemberId()); + + TopTagPlaylistResponse.CategoryInfo categoryInfo = null; + TopTagPlaylistResponse.TagInfo tagInfo = null; + + if (condition.getIndex() != null && condition.getIndex() >= 0 && condition.getIndex() < topTags.size()) { + Tag targetTag = topTags.get(condition.getIndex()); + + // TagInfo ๊ฐ์ฒด ์กฐ๋ฆฝ + tagInfo = TopTagPlaylistResponse.TagInfo.builder() + .id(targetTag.getId()) + .name(targetTag.getName()) + .build(); + + // CategoryInfo ๊ฐ์ฒด ์กฐ๋ฆฝ + if (targetTag.getCategory() != null) { + categoryInfo = TopTagPlaylistResponse.CategoryInfo.builder() + .id(targetTag.getCategory().getId()) + .name(targetTag.getCategory().getName()) + .build(); + } + } + + return TopTagPlaylistResponse.builder() + .category(categoryInfo) + .tag(tagInfo) + .medias(mediaPage) // ์œ„์—์„œ ๊ฐ€์ ธ์˜จ PageResponse๋ฅผ ๊ทธ๋Œ€๋กœ ๋„ฃ์Œ + .build(); + } + + + + private PlaylistStrategy getStrategy(PlaylistCondition condition) { + String strategyKey = determineStrategyKey(condition); + PlaylistStrategy strategy = strategyMap.get(strategyKey); + + if (strategy == null) { + strategy = strategyMap.get(ContentSource.RECOMMEND.name()); + } + + // ์—ฌ์ „ํžˆ null์ด๋ผ๋ฉด ์‹œ์Šคํ…œ ์„ค์ • ์˜ค๋ฅ˜์ด๋ฏ€๋กœ S001 ์—๋Ÿฌ ๋ฐœ์ƒ + if (strategy == null) { + throw new BusinessException(ErrorCode.STRATEGY_NOT_FOUND); + } + return strategy; + } + + + + private String determineStrategyKey(PlaylistCondition condition) { + ContentSource source = condition.getContentSource(); + + // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์ƒ์„ธ๋กœ ์ง„์ž…ํ•œ ์‹œ ์žฌ์ƒ๋ชฉ๋ก์€ ์ถ”์ฒœ์œผ๋กœ ๋Œ€์ฒด + if (source == ContentSource.SEARCH && condition.getExcludeMediaId() != null) { + return ContentSource.RECOMMEND.name(); + } + return source.name(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java new file mode 100644 index 0000000..6911e31 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -0,0 +1,125 @@ +package com.ott.api_user.playlist.service; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ott.domain.common.Status; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; + +import lombok.RequiredArgsConstructor; + + +// ์œ ์ €์˜ ํ–‰๋™(์„ ํ˜ธํƒœ๊ทธ, ์‹œ์ฒญ - ํƒœ๊ทธ , ์„ ํ˜ธ ํƒœ๊ทธ)๋ฅผ ์ˆ˜์ง‘ํ•˜์—ฌ +// Top3 ํƒœ๊ทธ์™€ oo ๋‹˜์ด ์ข‹์•„ํ•˜์‹ค๋งŒํ•œ ์ฝ˜ํ…์ธ  +// ์ข…ํ•ฉ ์ ์ˆ˜ํ‘œ ๊ณ„์‚ฐ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaylistPreferenceService { + + private final PreferredTagRepository preferredTagRepository; + private final PlaybackRepository playbackRepository; + private final LikesRepository likesRepository; + private final TagRepository tagRepository; + private final MediaTagRepository mediaTagRepository; + + /* + * [TAG ์ „๋žต์šฉ] ์‹œ์ฒญ ์ด๋ ฅ(+3) + ์„ ํ˜ธ ํƒœ๊ทธ(+5) ์ ์ˆ˜๋งŒ ํ•ฉ์‚ฐํ•˜์—ฌ Top 3 ํƒœ๊ทธ ์ถ”์ถœ + */ + public List getTopTags(Long memberId){ + // ํƒœ๊ทธ๋ณ„ ํ•ฉ์‚ฐ ์ ์ˆ˜๋ฅผ ๋‹ด์„ ์ ์ˆ˜ํ‘œ + Map tagScores = new HashMap<>(); + + // ์ตœ๊ทผ 100๊ฐœ๊นŒ์ง€๋งŒ ๊ฐ€์ ธ์˜ด + Pageable limit100 = PageRequest.of(0, 100); + + + // 1. ์˜จ๋ณด๋”ฉ ์„ ํ˜ธ ํƒœ๊ทธ ๊ฐ€์ค‘์น˜ ๋ฐ˜์˜ (+5์ ) + // Map.merge ๋ฅผ ํ†ตํ•ด ๋ˆ„์  ์ ์ˆ˜ ๊ณ„์‚ฐ + preferredTagRepository.findTagIdsByMemberId(memberId, Status.ACTIVE) + .forEach(id -> tagScores.merge(id, 5, Integer::sum)); + + + // 2. ์ตœ๊ทผ ์‹œ์ฒญ ์ด๋ ฅ ๊ฐ€์ค‘์น˜ ๋ฐ˜์˜ (+3์ ) + // [1๋‹จ๊ณ„] ์ตœ๊ทผ ์‹œ์ฒญํ•œ ์˜์ƒ์— ๋Œ€ํ•ด '์˜์ƒ ID' ์ตœ๋Œ€ 100๊ฐœ๋ฅผ ๊ฐ€์ ธ์˜ด + List playedMediaIds = playbackRepository.findRecentPlayedMediaIds(memberId, Status.ACTIVE, limit100); + + // [2๋‹จ๊ณ„] ๊ฐ€์ ธ์˜จ ์˜์ƒ์ด ํ•˜๋‚˜๋ผ๋„ ์žˆ๋‹ค๋ฉด, ๊ทธ ์˜์ƒ๋“ค์˜ 'ํƒœ๊ทธ ID'๋ฅผ ํ•œ ๋ฒˆ์— ๊ฐ€์ ธ์™€ ์ ์ˆ˜ ๋ถ€์—ฌ + if (!playedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) + .forEach(id -> tagScores.merge(id, 3, Integer::sum)); // ํ˜น์€ totalScores.merge + } + + // 3. ์ ์ˆ˜๊ฐ€ ๊ฐ€์žฅ ๋†’์€ ์ˆœ(๋‚ด๋ฆผ์ฐจ์ˆœ)์œผ๋กœ ์ •๋ ฌํ•œ ๋’ค, ์ƒ์œ„ 3๊ฐœ์˜ ํƒœ๊ทธ ID๋งŒ ์ถ”์ถœ + List topTagIds = tagScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(3) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + // [Fallback ์ฒ˜๋ฆฌ] ์ •๋ณด๊ฐ€ ์•„์˜ˆ ์—†๋Š” ์‹ ๊ทœ ์œ ์ €๋ผ๋ฉด? -> ์‹œ์Šคํ…œ ์ „์ฒด ํƒœ๊ทธ ์ค‘ ๋ฌด์ž‘์œ„ 3๊ฐœ๋ฅผ ๋˜์ ธ์คŒ + if (topTagIds.isEmpty()) { + List allTags = tagRepository.findAll(); + Collections.shuffle(allTags); + return allTags.stream().limit(3).collect(Collectors.toList()); + } + + // ์ตœ์ข…์ ์œผ๋กœ ์ถ”์ถœ๋œ 3๊ฐœ์˜ ID๋กœ ์‹ค์ œ Tag ์—”ํ‹ฐํ‹ฐ๋“ค์„ DB์—์„œ ๊ฐ€์ ธ์™€ ๋ฐ˜ํ™˜ + // findAllById (In ์ ˆ์€ ์ˆœ์„œ ๋ณด์žฅ x ํ•œ๋ฒˆ ๋” TopTagIds ์˜ ์ธ๋ฑ์Šค ์ˆœ์„œ์— ๋งž๊ฒŒ ์ •๋ ฌํ•ด์ฃผ์–ด์•ผํ•จ) + List tags = tagRepository.findAllById(topTagIds); + tags.sort(Comparator.comparing(tag -> topTagIds.indexOf(tag.getId()))); + + return tags; + + } + + + /** + * [RECOMMEND ์ „๋žต์šฉ] + * ์„ ํ˜ธ ํƒœ๊ทธ(+5) + ์‹œ์ฒญ ์ด๋ ฅ(+3) + ์ข‹์•„์š”(+2) - ์ข…ํ•ฉ ์ ์ˆ˜ํ‘œ ๋ฐ˜ํ™˜ + */ + public Map getTotalTagScores(Long memberId) { + Map totalScores = new HashMap<>(); + Pageable limit100 = PageRequest.of(0, 100); + + // 1. ๊ณ ์ • ์ทจํ–ฅ: ์˜จ๋ณด๋”ฉ ์„ ํ˜ธ ํƒœ๊ทธ (+5์ ) + preferredTagRepository.findTagIdsByMemberId(memberId, Status.ACTIVE) + .forEach(id -> totalScores.merge(id, 5, Integer::sum)); + + // 2. ์ตœ๊ทผ ๊ด€์‹ฌ์‚ฌ: ์ตœ๊ทผ ์‹œ์ฒญ ์ด๋ ฅ (+3์ ) + List playedMediaIds = playbackRepository.findRecentPlayedMediaIds(memberId, Status.ACTIVE, limit100); + + if (!playedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) + .forEach(id -> totalScores.merge(id, 3, Integer::sum)); + } + + // 3. ๊ฐ•ํ•œ ์„ ํ˜ธ๋„: ์ตœ๊ทผ ์ข‹์•„์š” ๋ˆ„๋ฅธ ์ด๋ ฅ (+2์ ) + // [1๋‹จ๊ณ„] ์ตœ๊ทผ ์ข‹์•„์š” ๋ˆ„๋ฅธ '์˜์ƒ ID' ์ตœ๋Œ€ 100๊ฐœ๋ฅผ ๊ฐ€์ ธ์˜ด + List likedMediaIds = likesRepository.findRecentLikedMediaIds(memberId, Status.ACTIVE, limit100); + + // [2๋‹จ๊ณ„] ๊ฐ€์ ธ์˜จ ์˜์ƒ์ด ํ•˜๋‚˜๋ผ๋„ ์žˆ๋‹ค๋ฉด, ๊ทธ ์˜์ƒ๋“ค์˜ 'ํƒœ๊ทธ ID'๋ฅผ ํ•œ ๋ฒˆ์— ๊ฐ€์ ธ์™€ ์ ์ˆ˜ ๋ถ€์—ฌ + if (!likedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(likedMediaIds) + .forEach(id -> totalScores.merge(id, 2, Integer::sum)); + } + + // ๋งŒ๋“ค์–ด์ง„ ์œ ์ €์˜ ์ตœ์ข… ์ ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ + return totalScores; + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java new file mode 100644 index 0000000..991e193 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -0,0 +1,76 @@ +package com.ott.api_user.playlist.service; + +import java.util.List; + +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.RecentWatchProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.contents.repository.ContentsRepository; + + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PlaylistService { + + private final ContentsRepository contentsRepository; + private final MemberRepository memberRepository; + private final TagRepository tagRepository; + private final MediaRepository mediaRepository; + private final WatchHistoryRepository watchHistoryRepository; + + + // ํƒœ๊ทธ๋ณ„ ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ (์ตœ๋Œ€ 20๊ฐœ) + @Transactional(readOnly = true) + public List getRecommendContentsByTag(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + tagRepository.findById(tagId) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + return mediaRepository.findRecommendContentsByTagId(tagId, 20) + .stream() + .map(TagPlaylistResponse::from) + .toList(); + } + + // ์ „์ฒด ์‹œ์ฒญ์ด๋ ฅ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง• ์กฐํšŒ (์ตœ์‹ ์ˆœ, 10๊ฐœ์”ฉ) + @Transactional(readOnly = true) + public PageResponse getWatchHistoryPlaylist(Long memberId, Integer page) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + PageRequest pageable = PageRequest.of(page, 10); + + Page watchPage = + watchHistoryRepository.findWatchHistoryByMemberId(memberId, pageable); + + List dataList = watchPage.getContent() + .stream() + .map(RecentWatchResponse::from) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + watchPage.getNumber(), + watchPage.getTotalPages(), + watchPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, dataList); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java new file mode 100644 index 0000000..0cbf11d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java @@ -0,0 +1,26 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +// ๋ถ๋งˆํฌ ๋ชฉ๋ก - ํŠน์ • ํšŒ์›์ด ๋ถ๋งˆํฌํ•œ ๋ฏธ๋””์–ด ๋ฆฌ์ŠคํŠธ +@Component("BOOKMARK") +@RequiredArgsConstructor +public class BookmarkPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + return mediaRepository.findBookmarkedPlaylists( + condition.getMemberId(), + condition.getExcludeMediaId(), + pageable + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java new file mode 100644 index 0000000..9584b1a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java @@ -0,0 +1,26 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +//์‹œ์ฒญ ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ - playback (์‹œ์ฒญ๊ธฐ๋ก)์„ ํ† ๋Œ€๋กœ ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์ตœ๊ทผ๋‚ ์งœ ์ˆœ ๋ฆฌ์ŠคํŠธ +@Component("HISTORY") +@RequiredArgsConstructor +public class HistoryPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + return mediaRepository.findHistoryPlaylists( + condition.getMemberId(), + condition.getExcludeMediaId(), + pageable + ); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java new file mode 100644 index 0000000..92db5c7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java @@ -0,0 +1,21 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; + +//๊ณตํ†ต ์ธํ„ฐํŽ˜์ด์Šค + +// ๊ธฐ์กด์˜ swtich ๋ฌธ์œผ๋กœ ์ž‘์„ฑํ•ด๋‘” ์ง„์ž… ์‹œ์ ์— ๋”ฐ๋ฅธ ๋ถ„๊ธฐ์ฒ˜๋ฆฌ๋ฅผ +// ์ „๋žต ํŒจํ„ด์„ ๋„์ž…ํ•˜์—ฌ ๊ณตํ†ต์œผ๋กœ +public interface PlaylistStrategy { + /** + * ์กฐ๊ฑด์— ๋งž๋Š” ๋ฏธ๋””์–ด ๋ชฉ๋ก์„ ์ฐพ์•„์˜ต๋‹ˆ๋‹ค. + * @param condition ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋„˜์–ด์˜จ ์ง„์ž… ์‹œ์  + * @param pageable ํŽ˜์ด์ง• ์ •๋ณด + * @return DB์—์„œ ์ฐพ์•„์˜จ Media ์—”ํ‹ฐํ‹ฐ๋“ค์˜ ํŽ˜์ด์ง€ ๊ฐ์ฒด + */ + Page getPlaylist(PlaylistCondition condition, Pageable pageable); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java new file mode 100644 index 0000000..719942b --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java @@ -0,0 +1,58 @@ +package com.ott.api_user.playlist.service.strategy; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.service.PlaylistPreferenceService; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + + +// ๊ฐœ์ธํ™” ์ถ”์ฒœ - ์‹œ์ฒญ์ด๋ ฅ + ์ข‹์•„์š” + ๊ธฐ์กด ์„ ํ˜ธ ํƒœ๊ทธ +@Component("RECOMMEND") +@RequiredArgsConstructor +public class RecommendPlaylistStrategy implements PlaylistStrategy { + + private final PlaylistPreferenceService preferenceService; + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + boolean isHomeScreen = (condition.getExcludeMediaId() == null); + + // ์œ ์ €์˜ ๋ชจ๋“  ํ–‰๋™(+5, +3, +2)์ด ํ•ฉ์‚ฐ๋œ ์ข…ํ•ฉ ์ ์ˆ˜ํ‘œ(Map)๋ฅผ ๊ฐ€์ ธ์˜ด + Map tagScores = preferenceService.getTotalTagScores(condition.getMemberId()); + + int fetchLimit = (pageable.getPageNumber() == 0 && isHomeScreen) ? 50 : pageable.getPageSize(); + + long fetchOffset = (isHomeScreen) ? 0 : pageable.getOffset(); + + + // QueryDSL CaseBuilder ์ฟผ๋ฆฌ ์‹คํ–‰ -> DB ๋‚ด๋ถ€์—์„œ ์ ์ˆ˜ ํ•ฉ์‚ฐ ํ›„ ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ๋œ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ + List mediaPool = mediaRepository.findRecommendedMedias( + tagScores, + condition.getExcludeMediaId(), + fetchLimit, + fetchOffset + ); + + if (pageable.getPageNumber() == 0 && isHomeScreen) { + Collections.shuffle(mediaPool); + } + + + int limit = Math.min(mediaPool.size(), pageable.getPageSize()); + long totalCount = isHomeScreen ? mediaPool.size() : 1000L; + + return new PageImpl<>(mediaPool.subList(0, limit), pageable, totalCount); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java new file mode 100644 index 0000000..e241acc --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java @@ -0,0 +1,78 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.service.PlaylistPreferenceService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.tag.domain.Tag; + +/** + * ํƒœ๊ทธ๋ณ„ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ์ „๋žต + * ํŠน์ • ํƒœ๊ทธ ID๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ด€๋ จ ๋ฏธ๋””์–ด ๋ชฉ๋ก์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + */ +@Component("TAG") +@RequiredArgsConstructor +public class TagPlaylistStrategy implements PlaylistStrategy { + + private final PlaylistPreferenceService preferenceService; + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + Long targetTagId = condition.getTagId(); + + // ํ™ˆํ™”๋ฉด์ธ์ง€ ์žฌ์ƒ๋ชฉ๋ก์ธ์ง€ ๊ตฌ๋ถ„ํ•จ + boolean isHomeScreen = (condition.getExcludeMediaId() == null); + + // 1. ๋ช…์‹œ์ ์ธ tagId ์—†์ด index๋งŒ ๋„˜์–ด์˜จ ๊ฒฝ์šฐ (ํŠน์ • ํƒœ๊ทธ๋ณ„ ๋ฆฌ์ŠคํŠธ๋ฅผ ํ™ˆ ํ™”๋ฉด์— ๋…ธ์ถœ ์‹œํ‚ค๊ณ  ์‹ถ์„ ๋•Œ ์žฌ์‚ฌ์šฉ) + if (targetTagId == null && condition.getIndex() != null) { + + // ์œ ์ €์˜ ์ทจํ–ฅ Top 3 ํƒœ๊ทธ๋ฅผ ๊ณ„์‚ฐํ•ด์„œ ๊ฐ€์ ธ์˜ด + List topTags = preferenceService.getTopTags(condition.getMemberId()); + int index = condition.getIndex(); + + // ํ”„๋ก ํŠธ๊ฐ€ ์š”์ฒญํ•œ ์ˆœ์œ„(index)์˜ ํƒœ๊ทธ ID๋ฅผ ํƒ€๊ฒŸ์œผ๋กœ ์„ค์ • + if (index >= 0 && condition.getIndex() < topTags.size()) { + targetTagId = topTags.get(condition.getIndex()).getId(); + } else { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + if(targetTagId == null){ + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + // 2. ๋ชจ์ˆ˜ ํ’€๋ง: ํ™ˆ ํ™”๋ฉด(page=0)์€ ์„ž๊ธฐ ์œ„ํ•ด 50๊ฐœ๋ฅผ ๋„‰๋„‰ํžˆ ๊ฐ€์ ธ์˜ค๊ณ , ์ƒ์„ธ ํŽ˜์ด์ง€๋Š” ์š”๊ตฌํ•œ ๋งŒํผ๋งŒ ๊ฐ€์ ธ์˜ด + int fetchLimit = (pageable.getPageNumber() == 0 && isHomeScreen) ? 50 : pageable.getPageSize(); + + // ํ™ˆ ํ™”๋ฉด์€ ํ•ญ์ƒ ๋ฌด์ž‘์œ„๋กœ ์„ž์„ ๊ฑฐ๋‹ˆ๊นŒ 0์œผ๋กœ ๊ณ ์ •, ์ƒ์„ธ ํŽ˜์ด์ง€๋Š” ํŽ˜์ด์ง€์— ๋งž๊ฒŒ ๊ฑด๋„ˆ๋œ€ + long fetchOffset = (isHomeScreen) ? 0 : pageable.getOffset(); + + List mediaPool = mediaRepository.findMediasByTagId(targetTagId, condition.getExcludeMediaId(), fetchLimit, fetchOffset); + + // 3. ๋””์Šค์ปค๋ฒ„๋ฆฌ UX: ํ™ˆ ํ™”๋ฉด์ผ ๊ฒฝ์šฐ์—๋งŒ ๋งค๋ฒˆ ์ƒˆ๋กœ์šด ์ฝ˜ํ…์ธ ๋ฅผ ๋ฐœ๊ฒฌํ•˜๋„๋ก ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฌด์ž‘์œ„๋กœ ์„ž์Œ + if (pageable.getPageNumber() == 0 && isHomeScreen) { + Collections.shuffle(mediaPool); + } + + // 4. ํ”„๋ก ํŠธ๊ฐ€ ์š”๊ตฌํ•œ ์‚ฌ์ด์ฆˆ(์˜ˆ: 20๊ฐœ)๋งŒํผ๋งŒ ์ž˜๋ผ์„œ Page ๊ฐ์ฒด๋กœ ํฌ์žฅ ํ›„ ๋ฐ˜ํ™˜ + int limit = Math.min(mediaPool.size(), pageable.getPageSize()); + + // ์ƒ์„ธ ํŽ˜์ด์ง€ ๋ฌดํ•œ ์Šคํฌ๋กค์ด ๋Š๊ธฐ์ง€ ์•Š๋„๋ก total ๊ฐ’(์„ธ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ)์„ ๋”๋ฏธ ๊ฐ’(1000L) ์œผ๋กœ ์„ธํŒ… + long totalCount = isHomeScreen ? mediaPool.size() : 1000L; + + return new PageImpl<>(mediaPool.subList(0, limit), pageable, totalCount); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java new file mode 100644 index 0000000..5de07b2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java @@ -0,0 +1,28 @@ +package com.ott.api_user.playlist.service.strategy; + + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +import lombok.RequiredArgsConstructor; + +// ์ธ๊ธฐ ์ฐจํŠธ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ - ๋ถ๋งˆํฌ ์ˆœ์œผ๋กœ ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ๋ฆฌ์ŠคํŠธ +@Component("TRENDING") // ContentSource Enum ์ด๋ฆ„๊ณผ ๋˜‘๊ฐ™์ด ๋งž์ถค +@RequiredArgsConstructor +public class TrendingPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + // ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๋‚ด๋ถ€์—์„œ excludeMediaId์˜ ์œ ๋ฌด๋ฅผ ์•Œ์•„์„œ ํŒ๋‹จํ•˜์—ฌ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + return mediaRepository.findTrendingPlaylists( + condition.getExcludeMediaId(), + pageable + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java new file mode 100644 index 0000000..47e97b6 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java @@ -0,0 +1,37 @@ +package com.ott.api_user.search.controller; + +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +@Tag(name = "Search API", description = "ํ†ตํ•ฉ ๊ฒ€์ƒ‰ API์ž…๋‹ˆ๋‹ค.") +public interface SearchApi { + + @Operation(summary = "ํ†ตํ•ฉ ๊ฒ€์ƒ‰ API", description = "์ฝ˜ํ…์ธ ์™€ ์‹œ๋ฆฌ์ฆˆ๋ฅผ ํ†ตํ•ฉํ•˜์—ฌ ์ตœ์‹ ์ˆœ์œผ๋กœ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "๊ฒ€์ƒ‰ ์„ฑ๊ณต", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ (๊ฒ€์ƒ‰์–ด ๋ˆ„๋ฝ ๋“ฑ)", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping + ResponseEntity> search( + @Parameter(description = "๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", required = true) @RequestParam(value = "searchWord") String searchWord, + + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", schema = @Schema(defaultValue = "0")) @RequestParam(value = "page", defaultValue = "0") Integer page, + + @Parameter(description = "ํ•œ ํŽ˜์ด์ง€ ๋‹น ์ตœ๋Œ€ ํ•ญ๋ชฉ ๊ฐœ์ˆ˜", schema = @Schema(defaultValue = "24")) @RequestParam(value = "size", defaultValue = "24") Integer size); + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java new file mode 100644 index 0000000..f6573ba --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java @@ -0,0 +1,29 @@ +//package com.ott.api_user.search.controller; +// +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import com.ott.api_user.search.service.SearchService; +//import com.ott.common.web.response.PageResponse; +//import com.ott.common.web.response.SuccessResponse; +//import lombok.RequiredArgsConstructor; +// +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.RequestParam; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/search") +//public class SearchController implements SearchApi { +// private final SearchService searchService; +// +// @Override +// public ResponseEntity> search( +// @RequestParam String searchWord, +// @RequestParam Integer page, +// @RequestParam Integer size) { +// PageResponse response = searchService.search(searchWord, page, size); +// return ResponseEntity.ok(SuccessResponse.of(response)); +// } +// +//} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java b/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java new file mode 100644 index 0000000..da47881 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java @@ -0,0 +1,32 @@ +package com.ott.api_user.search.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํ•ญ๋ชฉ ์‘๋‹ต DTO") +public class SearchItemResponse { + @Schema(description = "ํ•ญ๋ชฉ ํƒ€์ž… (์ฝ˜ํ…์ธ  ๋˜๋Š” ์‹œ๋ฆฌ์ฆˆ)", example = "CONTENTS") + private String type; + + @Schema(description = "์ฝ˜ํ…์ธ  ๋˜๋Š” ์‹œ๋ฆฌ์ฆˆ์˜ ๊ณ ์œ  ID", example = "101") + private Long id; + + @Schema(description = "์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") + private String title; + + @Schema(description = "ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ URL", example = "https://cdn.ott.com/posters/101.jpg") + private String posterUrl; + + @JsonIgnore // JSON ์‘๋‹ต์—์„œ๋Š” ์ œ์™ธ + @Schema(description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์ •๋ ฌ์šฉ ์ƒ์„ฑ์ผ์‹œ", hidden = true) + private LocalDateTime createdAt; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java new file mode 100644 index 0000000..f442da8 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java @@ -0,0 +1,85 @@ +//package com.ott.api_user.search.service; +// +//import java.util.ArrayList; +//import java.util.Comparator; +//import java.util.List; +//import java.util.stream.Stream; +// +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.PageRequest; +//import org.springframework.data.domain.Pageable; +//import org.springframework.stereotype.Service; +// +//import com.ott.api_user.search.dto.SearchItemResponse; +//import com.ott.common.web.exception.BusinessException; +//import com.ott.common.web.exception.ErrorCode; +//import com.ott.common.web.response.PageInfo; +//import com.ott.common.web.response.PageResponse; +//import com.ott.domain.common.Status; +//import com.ott.domain.contents.repository.ContentsRepository; +//import com.ott.domain.series.domain.Series; +//import com.ott.domain.contents.domain.Contents; +//import com.ott.domain.series.repository.SeriesRepository; +//import lombok.RequiredArgsConstructor; +// +//// ์ตœ์‹ ์ˆœ ์ •๋ ฌ์„ ์œ„ํ•ด DB ํŽ˜์ด์ง• ๋ฐฉ์‹ ๋Œ€์‹ , +//// ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ๋ชจ๋‘ ๊ฐ€์ ธ์™€์„œ Java Stream์œผ๋กœ ์ •๋ ฌ ํ›„, ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณ€๊ฒฝ +//// ์ถ”ํ›„ ๊ฒ€์ƒ‰ ๋Œ€์ƒ์ด ๋Š˜์–ด๋‚˜๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ ์–‘์ด ๋งŽ์•„์งˆ ๊ฒฝ์šฐ, Querydsl ์œผ๋กœ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์ตœ์ ํ™” ํ•„์š”! +//@Service +//@RequiredArgsConstructor +//public class SearchService { +// private final ContentsRepository contentsRepository; +// private final SeriesRepository seriesRepository; +// +// public PageResponse search(String searchWord, int page, int size) { +// +// if (searchWord == null || searchWord.length() < 2) { +// throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); +// } +// +// // ์‚ฌ์šฉ์ž๊ฐ€ ํ”ํ•œ ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ ์‹œ ๋„ˆ๋ฌด ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ +// // ์ผ๋‹จ ์ตœ๋Œ€ 100๊ฐœ๊นŒ์ง€๋งŒ ๊ฐ€์ ธ์˜ค๋„๋ก ์ œํ•œ +// Pageable limit = PageRequest.of(0, 100); +// +// // ์—ํ”ผ์†Œ๋“œ ์ œ์™ธ, ์‹œ๋ฆฌ์ฆˆ์™€ ๋‹จ์ผ ์ฝ˜ํ…์ธ ๋งŒ ๊ฒ€์ƒ‰ +// List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// +// // ์ปจํ…์ธ +์‹œ๋ฆฌ์ฆˆ ํ†ตํ•ฉ ์ •๋ ฌ +// List allResults = Stream.concat( +// contentsList.stream().map(c -> SearchItemResponse.builder() +// .type("CONTENTS") +// .id(c.getId()) +// .title(c.getTitle()) +// .posterUrl(c.getPosterUrl()) +// .createdAt(c.getCreatedDate()) +// .build()), +// seriesList.stream().map(s -> SearchItemResponse.builder() +// .type("SERIES") +// .id(s.getId()) +// .title(s.getTitle()) +// .posterUrl(s.getPosterUrl()) +// .createdAt(s.getCreatedDate()) +// .build())) +// .filter(item -> item.getCreatedAt() != null) +// .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // ํ†ตํ•ฉ ์ตœ์‹ ์ˆœ ์ •๋ ฌ +// .toList(); +// +// // ํŽ˜์ด์ง• ๊ณ„์‚ฐ (์ง์ ‘ ์ž๋ฅด๊ธฐ) +// int totalElements = allResults.size(); +// int totalPages = (int) Math.ceil((double) totalElements / size); +// +// int start = Math.min(page * size, totalElements); +// int end = Math.min(start + size, totalElements); +// +// List pagedResult = allResults.subList(start, end); +// +// PageInfo pageInfo = PageInfo.builder() +// .currentPage(page) +// .totalPage(totalPages) +// .pageSize(size) +// .build(); +// +// return PageResponse.toPageResponse(pageInfo, pagedResult); +// } +//} diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java new file mode 100644 index 0000000..0aaaed5 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -0,0 +1,61 @@ +package com.ott.api_user.series.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ott.api_user.series.dto.SeriesContentsResponse; +import com.ott.api_user.series.dto.SeriesDetailResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Series", description = "์‹œ๋ฆฌ์ฆˆ ์กฐํšŒ API") +public interface SeriesApi { + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ์กฐํšŒ", description = "ํŠน์ • ์‹œ๋ฆฌ์ฆˆ์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.(์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ํŽ˜์ด์ง€)") + @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ๊ตฌ์„ฑ", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesDetailResponse.class))) }), + @ApiResponse(responseCode = "200", description = "์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = SeriesDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "์‹œ๋ฆฌ์ฆˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{mediaId}") + ResponseEntity> getSeriesDetail( + @Parameter(description = "์‹œ๋ฆฌ์ฆˆ ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId // ํ† ํฐ์—์„œ ์ถ”์ถœ (์Šค์›จ๊ฑฐ์—์„œ๋Š” ์ˆจ๊น€) + ); + + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ", description = "ํŠน์ • ์‹œ๋ฆฌ์ฆˆ์— ์†ํ•œ ์ฝ˜ํ…์ธ (์—ํ”ผ์†Œ๋“œ) ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "์กฐํšŒ ์„ฑ๊ณต - ์‹œ๋ฆฌ์ฆˆ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ๊ตฌ์„ฑ", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesContentsResponse.class))) }), + @ApiResponse(responseCode = "200", description = "์‹œ๋ฆฌ์ฆˆ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @ApiResponse(responseCode = "404", description = "์‹œ๋ฆฌ์ฆˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }), + @ApiResponse(responseCode = "400", description = "์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ ์˜ค๋ฅ˜", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + + @GetMapping("/{mediaId}/contents") + ResponseEntity>> getSeriesContents( + @Parameter(description = "์‹œ๋ฆฌ์ฆˆ ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0") @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", example = "24") @RequestParam(defaultValue = "24") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); + + // ์ถ”ํ›„ ์ด์–ด๋ณด๊ธฐ ์ง€์  ์ถ”๊ฐ€ +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java new file mode 100644 index 0000000..2b44f92 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -0,0 +1,44 @@ +package com.ott.api_user.series.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.series.dto.SeriesContentsResponse; +import com.ott.api_user.series.dto.SeriesDetailResponse; +import com.ott.api_user.series.service.SeriesService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.micrometer.core.ipc.http.HttpSender.Response; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/series") +public class SeriesController implements SeriesApi { + private final SeriesService seriesService; + + @Override + public ResponseEntity> getSeriesDetail( + @PathVariable(value = "mediaId") Long mediaId, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok( + SuccessResponse.of(seriesService.getSeriesDetail(mediaId, memberId))); + } + + @Override + public ResponseEntity>> getSeriesContents( + @PathVariable(value = "mediaId") Long mediaId, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok( + SuccessResponse.of(seriesService.getSeriesContents(mediaId, pageParam, sizeParam, memberId))); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java new file mode 100644 index 0000000..68a77fb --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java @@ -0,0 +1,52 @@ +package com.ott.api_user.series.dto; + +import com.ott.domain.contents.domain.Contents; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ๋‚ด ์ฝ˜ํ…์ธ (์—ํ”ผ์†Œ๋“œ) ๋ชฉ๋ก ์•„์ดํ…œ ์‘๋‹ต DTO") +public class SeriesContentsResponse { + @Schema(type = "Long", example = "1", description = "์—ํ”ผ์†Œ๋“œ์˜ ๋ฏธ๋””์–ด ID") + private Long id; + + @Schema(type = "Long" , example = "101", description = "์‹œ๋ฆฌ์ฆˆ ๋ณธ์ฒด์˜ ๋ฏธ๋””์–ด ID") + private Long seriesMediaId; + + + @Schema(type = "String", example = "๋” ๊ธ€๋กœ๋ฆฌ ์‹œ์ฆŒ 1: 1ํ™”", description = "์ฝ˜ํ…์ธ  ์ œ๋ชฉ") + private String title; + + @Schema(type = "String", example = "์ถ”๋ฝํ•˜๋Š” ์ž์—๊ฒ ๋‚ ๊ฐœ๊ฐ€ ์—†๋‹ค...", description = "์ฝ˜ํ…์ธ  ์„ค๋ช…") + private String description; + + @Schema(type = "String", example = "https://cdn.ott.com/thumbnails/c101.jpg", description = "์ฝ˜ํ…์ธ  ์ธ๋„ค์ผ") + private String thumbnailUrl; + + @Schema(type = "Integer", example = "3600", description = "์žฌ์ƒ ์‹œ๊ฐ„ (์ดˆ)") + private Integer duration; + + // ์ด์–ด๋ณด๊ธฐ ์ง€์ ๋„ ์‘๋‹ต์— ํฌํ•จ + @Schema(type = "Integer", example = "1200", description = "์‚ฌ์šฉ์ž๊ฐ€ ๋งˆ์ง€๋ง‰์œผ๋กœ ์‹œ์ฒญํ•œ ์ง€์  (์ดˆ)") + private Integer positionSec; + + public static SeriesContentsResponse from(Contents content) { + // ์ž„์‹œ ์ด์–ด๋ณด๊ธฐ์šฉ (๋‚˜์ค‘์— ๋กœ์ง ์ž‘์„ฑ ํ›„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›๊ฒŒ ์ˆ˜์ • ์˜ˆ์ •) + Integer positionSec = 0; + + return SeriesContentsResponse.builder() + .id(content.getMedia().getId()) + .seriesMediaId(content.getSeries().getMedia().getId()) + .duration(content.getDuration()) + .title(content.getMedia().getTitle()) + .description(content.getMedia().getDescription()) + .thumbnailUrl(content.getMedia().getThumbnailUrl()) + .positionSec(positionSec) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java new file mode 100644 index 0000000..5f39152 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -0,0 +1,61 @@ +package com.ott.api_user.series.dto; + +import java.util.List; + +import com.ott.domain.series.domain.Series; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ์กฐํšŒ ์‘๋‹ต DTO") +public class SeriesDetailResponse { + + @Schema(description = "๋ฏธ๋””์–ด ๊ณ ์œ  ID", example = "101") + private Long id; + + @Schema(description = "์‹œ๋ฆฌ์ฆˆ ์ œ๋ชฉ", example = "๋น„๋ฐ€์˜ ์ˆฒ") + private String title; + + @Schema(description = "์‹œ๋ฆฌ์ฆˆ ์„ค๋ช…", example = "๊ฒ€๊ฒฝ ์ˆ˜์‚ฌ๊ทน์˜ ์ƒˆ๋กœ์šด ์ง€ํ‰์„ ์—ฐ ๋“œ๋ผ๋งˆ") + private String description; + + @Schema(description = "์ถœ์—ฐ์ง„", example = "์†กํ˜œ๊ต, ์ด๋„ํ˜„, ์ž„์ง€์—ฐ") + private String actors; + + @Schema(description = "๊ฐ€๋กœํ˜• ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + @Schema(description = "์นดํ…Œ๊ณ ๋ฆฌ", example = "๋“œ๋ผ๋งˆ") + private String category; + + @Schema(description = "ํƒœ๊ทธ ๋ชฉ๋ก", example = "๋“œ๋ผ๋งˆ, ๋ฒ”์ฃ„, ์ˆ˜์‚ฌ") + private List tags; + + @Schema(description = "์‚ฌ์šฉ์ž ๋ถ๋งˆํฌ ์—ฌ๋ถ€", example = "true") + private Boolean isBookmarked; + + @Schema(description = "์‚ฌ์šฉ์ž ์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + private Boolean isLiked; + + // ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ + public static SeriesDetailResponse of(Series series, List tags, List categories, + Boolean isBookmarked, Boolean isLiked) { + return SeriesDetailResponse.builder() + .id(series.getMedia().getId()) + .actors(series.getActors()) + .title(series.getMedia().getTitle()) + .description(series.getMedia().getDescription()) + .thumbnailUrl(series.getMedia().getThumbnailUrl()) + .category(categories.isEmpty() ? null : categories.get(0)) + .tags(tags) + .isBookmarked(isBookmarked) + .isLiked(isLiked) + .build(); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java new file mode 100644 index 0000000..952e30a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -0,0 +1,87 @@ +package com.ott.api_user.series.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ott.api_user.series.dto.SeriesContentsResponse; +import com.ott.api_user.series.dto.SeriesDetailResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.domain.Likes; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.playback.domain.Playback; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // ๋”ํ‹ฐ ์ฒดํ‚น ๋น„ํ™œ์„ฑํ™” +public class SeriesService { + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final TagRepository tagRepository; + private final CategoryRepository categoryRepository; + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + // private final PlaybackRepository playbackRepository; + + // ์‹œ๋ฆฌ์ฆˆ ์ƒ์„ธ ์กฐํšŒ + public SeriesDetailResponse getSeriesDetail(Long mediaId, Long memberId) { + + Series series = seriesRepository.findByMediaIdAndStatusAndPublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); + + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, + Status.ACTIVE); + Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); + + return SeriesDetailResponse.of(series, tags, categories, isBookmarked, isLiked); + } + + // ์‹œ๋ฆฌ์ฆˆ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ (ํŽ˜์ด์ง•) + // ๋ฐ˜ํ™˜ ํƒ€์ž… ์ œ๋„ค๋ฆญ์œผ๋กœ ์ˆ˜์ • + public PageResponse getSeriesContents(Long mediaId, int page, int size, + Long memberId) { + + Series series = seriesRepository.findByMediaIdAndStatusAndPublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Long targetSeriesId = series.getId(); + + Pageable pageable = PageRequest.of(page, size); + + + Page contentsPage = contentsRepository + .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(targetSeriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); + + List contentsList = contentsPage.getContent().stream().map(SeriesContentsResponse::from).collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.builder() + .currentPage(contentsPage.getNumber()) + .totalPage(contentsPage.getTotalPages()) + .pageSize(contentsPage.getSize()) + .build(); + + return PageResponse.toPageResponse(pageInfo, contentsList); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java new file mode 100644 index 0000000..b34dbf4 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java @@ -0,0 +1,85 @@ +package com.ott.api_user.tag.controller; + +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@RequestMapping("/tag") +@Tag(name = "Tag", description = "ํƒœ๊ทธ API") +@SecurityRequirement(name = "BearerAuth") // ์ธ์ฆ์ธ๊ฐ€ ํ™•์ธ +public interface TagAPI { + + // ------------------------------------------------------- + // ์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ๋žญํ‚น ์กฐํšŒ + // ------------------------------------------------------- + @Operation(summary = "์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ๋žญํ‚น ์กฐํšŒ", description = "์ตœ๊ทผ 1๋‹ฌ๊ฐ„ ์‹œ์ฒญ์ด๋ ฅ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒ์œ„ 4๊ฐœ ํƒœ๊ทธ + ๊ธฐํƒ€ ํ•ญ๋ชฉ์„ ๋ฐ˜ํ™˜" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagRankingResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/ranking") + ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId); + + + // ------------------------------------------------------- + // ํƒœ๊ทธ ์›”๋ณ„ ์‹œ์ฒญ count ๋น„๊ต + // ------------------------------------------------------- + @Operation(summary = "ํƒœ๊ทธ ์›”๋ณ„ ์‹œ์ฒญ count ๋น„๊ต", description = "ํŠน์ • ํƒœ๊ทธ์˜ ์ด๋ฒˆ ๋‹ฌ vs ์ €๋ฒˆ ๋‹ฌ ์‹œ์ฒญ ํšŸ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagMonthlyCompareResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", description = "์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "ํšŒ์› ๋˜๋Š” ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/ranking/{tagId}") + ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java new file mode 100644 index 0000000..c137dcf --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java @@ -0,0 +1,42 @@ +package com.ott.api_user.tag.controller; + +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.api_user.tag.service.TagService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/tag") +@RequiredArgsConstructor +public class TagController implements TagAPI { + + private final TagService tagService; + + + // ์œ ์ € ๋ณ„ 1๋‹ฌ ๊ฐ„ ์ƒ์œ„ ํƒœ๊ทธ ์กฐํšŒ + @Override + @GetMapping("/me/ranking") + public ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(tagService.getTagRanking(memberId))); + } + + // ์œ ์ € ๋ณ„ 2๋‹ฌ ๊ฐ„ ํŠน์ • ํƒœ๊ทธ ์กฐํšŒ + @Override + @GetMapping("/me/ranking/{tagId}") + public ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(tagService.getTagMonthlyCompare(memberId, tagId))); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java new file mode 100644 index 0000000..522a24d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java @@ -0,0 +1,38 @@ +package com.ott.api_user.tag.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "ํƒœ๊ทธ ์›”๋ณ„ ์‹œ์ฒญ count ๋น„๊ต ์‘๋‹ต DTO") +public class TagMonthlyCompareResponse { + + @Schema(type = "Long", example = "3", description = "ํƒœ๊ทธ ID") + private Long tagId; + + @Schema(type = "String", example = "์Šค๋ฆด๋Ÿฌ", description = "ํƒœ๊ทธ๋ช…") + private String tagName; + + @Schema(description = "์ด๋ฒˆ ๋‹ฌ ์‹œ์ฒญ count") + private MonthlyCount currentMonth; + + @Schema(description = "์ €๋ฒˆ ๋‹ฌ ์‹œ์ฒญ count") + private MonthlyCount previousMonth; + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "์›”๋ณ„ ์‹œ์ฒญ count ์•„์ดํ…œ") + public static class MonthlyCount { + + @Schema(type = "String", example = "2026-03", description = "์—ฐ์›” (yyyy-MM)") + private String yearMonth; + + @Schema(type = "Long", example = "12", description = "์‹œ์ฒญ ํšŸ์ˆ˜") + private Long count; + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java new file mode 100644 index 0000000..82b6595 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java @@ -0,0 +1,55 @@ +package com.ott.api_user.tag.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ๋žญํ‚น ์‘๋‹ต DTO") +public class TagRankingResponse { + + @Schema(description = "ํƒœ๊ทธ ๋žญํ‚น ๋ชฉ๋ก (์ƒ์œ„ 4๊ฐœ + ๊ธฐํƒ€ 1๊ฐœ, ์ตœ๋Œ€ 5๊ฐœ)") + private List rankings; + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "ํƒœ๊ทธ ๋žญํ‚น ์•„์ดํ…œ") + public static class TagRankItem { + + @Schema(type = "Long", example = "3", description = "ํƒœ๊ทธ ID (๊ธฐํƒ€ ํ•ญ๋ชฉ์€ null)") + private Long tagId; + + @Schema(type = "String", example = "์Šค๋ฆด๋Ÿฌ", description = "ํƒœ๊ทธ๋ช… (๊ธฐํƒ€ ํ•ญ๋ชฉ์€ '๊ธฐํƒ€')") + private String tagName; + + @Schema(type = "Long", example = "12", description = "์‹œ์ฒญ ํšŸ์ˆ˜") + private Long count; + + @Schema(type = "boolean", example = "false", description = "๊ธฐํƒ€ ํ•ญ๋ชฉ ์—ฌ๋ถ€") + private boolean isEtc; + + public static TagRankItem of(Long tagId, String tagName, Long count) { + return TagRankItem.builder() + .tagId(tagId) + .tagName(tagName) + .count(count) + .isEtc(false) + .build(); + } + + public static TagRankItem ofEtc(Long totalCount) { + return TagRankItem.builder() + .tagId(null) + .tagName("๊ธฐํƒ€") + .count(totalCount) + .isEtc(true) + .build(); + } + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java new file mode 100644 index 0000000..3c6e111 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_user.tag.dto.response; + +import com.ott.domain.tag.domain.Tag; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "ํƒœ๊ทธ ์‘๋‹ต DTO") +public class TagResponse { + + @Schema(type = "Long", example = "1", description = "ํƒœ๊ทธ ๊ณ ์œ  ID") + private Long tagId; + + @Schema(type = "String", example = "๋กœ๋งจ์Šค", description = "ํƒœ๊ทธ๋ช…") + private String name; + + public static TagResponse from(Tag tag) { + return TagResponse.builder() + .tagId(tag.getId()) + .name(tag.getName()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java b/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java new file mode 100644 index 0000000..2110283 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java @@ -0,0 +1,121 @@ +package com.ott.api_user.tag.service; + +import com.ott.api_user.member.service.MemberService; +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.Status; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.TagRankingProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TagService { + + private final MemberRepository memberRepository; + private final WatchHistoryRepository watchHistoryRepository; + private final TagRepository tagRepository; + + /** + * ๋งˆ์ดํŽ˜์ด์ง€ - ์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ์ƒ์œ„ ํƒœ๊ทธ ๋žญํ‚น ์กฐํšŒ 1๋‹ฌ + * - ์ƒ์œ„ 4๊ฐœ: ๊ฐœ๋ณ„ ํƒœ๊ทธ ํ•ญ๋ชฉ + * - ๋‚˜๋จธ์ง€: count ํ•ฉ์‚ฐํ•˜์—ฌ ๊ธฐํƒ€ ํ•ญ๋ชฉ์œผ๋กœ ๋ฐ˜ํ™˜ + */ + @Transactional(readOnly = true) + public TagRankingResponse getTagRanking(Long memberId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ์ง‘๊ณ„์ผ๊ณผ ๋งˆ๊ฐ์ผ ์„ ์ • 1์ผ~๋ง์ผ๊นŒ์ง€ + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime startDate = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime endDate = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // ๋‹ค์Œ ๋‹ฌ 1์ผ 00:00:00 + + List tagRankingProjections = + watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate); + + List rankItems = new ArrayList<>(); + + // ์‹œ์ฒญ์ด๋ ฅ์ด ์—†์„ ๊ฒฝ์šฐ ๋นˆ ๋ฆฌ์ŠคํŠธ๊ฐ€ ์ „๋‹ฌ๋จ + if (tagRankingProjections.isEmpty()) { + return TagRankingResponse.builder().rankings(rankItems).build(); + } + + int topN = Math.min(4, tagRankingProjections.size()); + + // ์ƒ์œ„ 4๊ฐœ ์ถ”๊ฐ€ + for (int i = 0; i < topN; i++) { + TagRankingProjection projection = tagRankingProjections.get(i); + rankItems.add(TagRankingResponse.TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount())); + } + + // ๋‚˜๋จธ์ง€ โ†’ ๊ธฐํƒ€๋กœ ํ•ฉ์‚ฐ + if (tagRankingProjections.size() > 4) { + long etcCount = tagRankingProjections.subList(4, tagRankingProjections.size()) + .stream() + .mapToLong(TagRankingProjection::getCount) + .sum(); + rankItems.add(TagRankingResponse.TagRankItem.ofEtc(etcCount)); + } + + return TagRankingResponse.builder().rankings(rankItems).build(); + } + + /** + * ๋งˆ์ดํŽ˜์ด์ง€ - ํŠน์ • ํƒœ๊ทธ์˜ ์ด๋ฒˆ ๋‹ฌ vs ์ €๋ฒˆ ๋‹ฌ ์‹œ์ฒญ count ๋น„๊ต + */ + @Transactional(readOnly = true) + public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Tag findTag = tagRepository.findByIdAndStatus(tagId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + // ์ด๋ฒˆ ๋‹ฌ ๋ฒ”์œ„ + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime currentStart = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime currentEnd = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // ๋‹ค์Œ ๋‹ฌ 1์ผ 00:00:00 + + // ์ €๋ฒˆ ๋‹ฌ ๋ฒ”์œ„ + YearMonth prevYearMonth = currentYearMonth.minusMonths(1); + LocalDateTime prevStart = prevYearMonth.atDay(1).atStartOfDay(); + LocalDateTime prevEnd = currentYearMonth.atDay(1).atStartOfDay(); // ์ด๋ฒˆ ๋‹ฌ 1์ผ 00:00:00 + + Long currentCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, currentStart, currentEnd); + Long previousCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, prevStart, prevEnd); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); + + // ์ €๋ฒˆ ๋‹ฌ ์‹œ์ฒญ ๊ธฐ๋ก์ด ์—†์œผ๋ฉด null + TagMonthlyCompareResponse.MonthlyCount previousMonth = previousCount > 0 + ? TagMonthlyCompareResponse.MonthlyCount.builder() + .yearMonth(prevYearMonth.format(formatter)) + .count(previousCount) + .build() + : null; + + return TagMonthlyCompareResponse.builder() + .tagId(findTag.getId()) + .tagName(findTag.getName()) + .currentMonth(TagMonthlyCompareResponse.MonthlyCount.builder() + .yearMonth(currentYearMonth.format(formatter)) + .count(currentCount) + .build()) + .previousMonth(previousMonth) + .build(); + } +} diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index 9adde2b..04ce090 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -1,13 +1,40 @@ +app: + frontend-url: ${FRONTEND_URL:http://localhost:8080} + +kakao: + unlink-url: ${KAKAO_UNLINK_URL} # ์นด์นด์˜ค ํšŒ์›ํƒˆํ‡ด ํ•  ๋งํฌ + admin-key: ${KAKAO_ADMIN_KEY} # ํšŒ์›ํƒˆํ‡ด, ์„œ๋น„์Šค ์•ฑ์˜ ์–ด๋“œ๋ฏผ ํ‚ค + server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} + # kakao + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} # REST API + client-secret: ${KAKAO_CLIENT_SECRET} #Client Secret + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-authentication-method: client_secret_post # body์— ๋„ฃ์–ด์„œ ํ† ํฐ ๊ตํ™˜ + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize # ๋กœ๊ทธ์ธ ํ™”๋ฉด + token-uri: https://kauth.kakao.com/oauth/token # ๋ฐ›์€ ์ธ๊ฐ€์ฝ”๋“œ๋ฅผ ํ† ํฐ์œผ๋กœ ๊ตํ™˜ + user-info-uri: https://kapi.kakao.com/v2/user/me # ์œ ์ € ์ •๋ณด ๊ฐ€์ ธ์˜ค๋Š” API + user-name-attribute: id # flyway ์„ค์ • flyway: enabled: true @@ -25,3 +52,23 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +# JWT ์„ค์ • +jwt: + secret: ${JWT_SECRET_BASE64:} + access-token-expiry: 1800000 # 30๋ถ„ + refresh-token-expiry: 1209600000 # 14์ผ + +springdoc: + api-docs: + version: OPENAPI_3_0 diff --git a/apps/monitoring/docker-compose.prod.yml b/apps/monitoring/docker-compose.prod.yml new file mode 100644 index 0000000..559f4b7 --- /dev/null +++ b/apps/monitoring/docker-compose.prod.yml @@ -0,0 +1,4 @@ +services: + prometheus: + volumes: + - ./prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro \ No newline at end of file diff --git a/apps/monitoring/docker-compose.yml b/apps/monitoring/docker-compose.yml new file mode 100644 index 0000000..9761e77 --- /dev/null +++ b/apps/monitoring/docker-compose.yml @@ -0,0 +1,33 @@ +services: + prometheus: + image: prom/prometheus:v2.54.1 + container_name: oplust-prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + restart: unless-stopped + + grafana: + image: grafana/grafana:12.4.0 + container_name: oplust-grafana + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + restart: unless-stopped + +volumes: + prometheus_data: + grafana_data: diff --git a/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml b/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..f29b13b --- /dev/null +++ b/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: "oplust-local" + orgId: 1 + folder: "Oplust" + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 10 + options: + path: /etc/grafana/provisioning/dashboards/json + foldersFromFilesStructure: false \ No newline at end of file diff --git a/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json new file mode 100644 index 0000000..940f7ab --- /dev/null +++ b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json @@ -0,0 +1,447 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [ + + ], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"user-api|admin-api\"}[1m])) by (app)", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "API ์š”์ฒญ ์ฒ˜๋ฆฌ๋Ÿ‰ (RPS)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "{area=\"heap\", job=\"transcoder\"}" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "sum by (app) (jvm_memory_used_bytes{job=~\"user-api|admin-api|transcoder\"})", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "JVM ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ (app๋ณ„)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "builder", + "expr": "max by (app) (up{job=~\"user-api|admin-api|transcoder\"})", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "์„œ๋น„์Šค ์ƒํƒœ ์ ๊ฒ€ (UP)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "avg by (app) (process_cpu_usage{job=~\"user-api|admin-api|transcoder\"} * 100)", + "interval": "", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "CPU ์‚ฌ์šฉ๋ฅ  (app๋ณ„)", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [ + + ], + "templating": { + "list": [ + + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + + }, + "timezone": "browser", + "title": "๋ชจ๋‹ˆํ„ฐ๋ง ๋Œ€์‹œ๋ณด๋“œ", + "uid": "adk6x5b", + "version": 101, + "weekStart": "" +} diff --git a/apps/monitoring/grafana/provisioning/datasources/prometheus.yml b/apps/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..0b304bc --- /dev/null +++ b/apps/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/apps/monitoring/prometheus/prometheus.prod.yml b/apps/monitoring/prometheus/prometheus.prod.yml new file mode 100644 index 0000000..2ef5abc --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.prod.yml @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__USER_API_TARGET__"] + labels: + app: "user-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__ADMIN_API_TARGET__"] + labels: + app: "admin-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__TRANSCODER_TARGET__"] + labels: + app: "transcoder" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "transcoder" diff --git a/apps/monitoring/prometheus/prometheus.prod.yml.tpl b/apps/monitoring/prometheus/prometheus.prod.yml.tpl new file mode 100644 index 0000000..e1830e5 --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.prod.yml.tpl @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__USER_API_TARGET__"] + labels: + app: "user-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__ADMIN_API_TARGET__"] + labels: + app: "admin-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__TRANSCODER_TARGET__"] + labels: + app: "transcoder" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "transcoder" \ No newline at end of file diff --git a/apps/monitoring/prometheus/prometheus.yml b/apps/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..e4386f0 --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.yml @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["api-user:8080"] + labels: + app: "user-api" + env: "local" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["api-admin:8081"] + labels: + app: "admin-api" + env: "local" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["transcoder:8082"] + labels: + app: "transcoder" + env: "local" + relabel_configs: + - target_label: instance + replacement: "transcoder" diff --git a/apps/transcoder/build.gradle b/apps/transcoder/build.gradle index 7ca7b84..37dbd92 100644 --- a/apps/transcoder/build.gradle +++ b/apps/transcoder/build.gradle @@ -2,8 +2,7 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') - implementation project(':modules:common-security') - implementation 'org.springframework.boot:spring-boot-starter-web' -} \ No newline at end of file + implementation 'org.springframework.boot:spring-boot-starter-amqp' +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java new file mode 100644 index 0000000..93c258c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java @@ -0,0 +1,80 @@ +package com.ott.transcoder; + +import com.ott.transcoder.inspection.Inspector; +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.validation.DiskSpaceGuard; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.queue.TranscodeMessage; +import com.ott.transcoder.storage.VideoStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +/** + * ์ž‘์—… ์ „์ฒด ํ๋ฆ„ ์กฐ์œจ + * diskSpaceGuard โ†’ workDir ์ƒ์„ฑ โ†’ download โ†’ inspect โ†’ pipeline ์‹คํ–‰ โ†’ cleanup + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class JobOrchestrator { + + private final DiskSpaceGuard diskSpaceGuard; + private final VideoStorage videoStorage; + private final Inspector inspector; + private final CommandPipeline pipeline; + + @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") + private String tempDir; + + public void handle(TranscodeMessage message) throws Exception { + Long mediaId = message.mediaId(); + // TODO: 0. DB ํ™•์ธ ํ•„์š” + + Path workDir = Path.of(tempDir, "media-" + mediaId); + + // 1. ๋””์Šคํฌ ๊ณต๊ฐ„ ํ™•์ธ + diskSpaceGuard.check(Path.of(message.originUrl())); + + try { + // 2. workDir ์ƒ์„ฑ + Files.createDirectories(workDir); + + // 3. ์›๋ณธ ๋‹ค์šด๋กœ๋“œ + Path inputFile = videoStorage.download(message.originUrl(), workDir); + + // 4. ๊ฒ€์‚ฌ (FileValidator โ†’ Probe โ†’ StreamValidator) + ProbeResult probeResult = inspector.inspect(inputFile); + + // TODO: 5. ์ปค๋งจ๋“œ ์ƒ์„ฑ -> ๊ฐ ์ปค๋งจ๋“œ ํŒŒ์ดํ”„๋ผ์ธ ์‹คํ–‰ + + // 6. ํŒŒ์ดํ”„๋ผ์ธ ์‹คํ–‰ + pipeline.execute(mediaId, inputFile, workDir, probeResult); + + } finally { + cleanUp(workDir); + } + } + + private void cleanUp(Path workDir) { + try { + if (Files.exists(workDir)) { + Files.walk(workDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + }); + log.info("์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์ •๋ฆฌ ์™„๋ฃŒ - {}", workDir); + } + } catch (IOException e) { + log.warn("์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์ •๋ฆฌ ์‹คํŒจ - {}", workDir, e); + } + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java new file mode 100644 index 0000000..0063b1f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java @@ -0,0 +1,58 @@ +package com.ott.transcoder.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.transcoder.queue.TranscodeMessage; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.support.converter.DefaultClassMapper; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** RabbitMQ Exchange/Queue/Binding ์„ค์ •. transcoder.messaging.provider=rabbit ์ผ ๋•Œ ํ™œ์„ฑํ™”. */ +@Configuration +@ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") +public class RabbitConfig { + + public static final String EXCHANGE_NAME = "transcode.exchange"; + public static final String QUEUE_NAME = "transcode.queue"; + public static final String ROUTING_KEY = "transcode.request"; + + @Bean + public DirectExchange transcodeExchange() { + return new DirectExchange(EXCHANGE_NAME); + } + + /** durable=true: RabbitMQ ์žฌ์‹œ์ž‘ ์‹œ์—๋„ ํ ์œ ์ง€ */ + @Bean + public Queue transcodeQueue() { + return new Queue(QUEUE_NAME, true); + } + + /** Exchange์™€ Queue๋ฅผ routing key๋กœ ์—ฐ๊ฒฐ */ + @Bean + public Binding transcodeBinding(Queue transcodeQueue, DirectExchange transcodeExchange) { + return BindingBuilder.bind(transcodeQueue) + .to(transcodeExchange) + .with(ROUTING_KEY); + } + + /** __TypeId__ ํ—ค๋” ์—†๋Š” ๋ฉ”์‹œ์ง€๋„ ์—ญ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•˜๋„๋ก ๊ธฐ๋ณธ ํƒ€์ž… ์ง€์ • */ + @Bean + public DefaultClassMapper classMapper() { + DefaultClassMapper classMapper = new DefaultClassMapper(); + classMapper.setDefaultType(TranscodeMessage.class); + return classMapper; + } + + @Bean + public MessageConverter jacksonMessageConverter(ObjectMapper objectMapper, DefaultClassMapper classMapper) { + Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper); + converter.setClassMapper(classMapper); + return converter; + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep similarity index 100% rename from apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java new file mode 100644 index 0000000..fc203de --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java @@ -0,0 +1,36 @@ +package com.ott.transcoder.ffmpeg; + +import com.ott.domain.video_profile.domain.Resolution; + +/** + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•œ ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์„ค์ • ๋ฌถ์Œ + * + * ํ˜„์žฌ๋Š” Resolution enum ๊ธฐ๋ฐ˜์˜ ๊ณ ์ • ํ”„๋ฆฌ์…‹์ด์ง€๋งŒ, + * ํ–ฅํ›„ TranscodePlanner๊ฐ€ ProbeResult๋ฅผ ๋ถ„์„ํ•˜์—ฌ ๋™์ ์œผ๋กœ ์ƒ์„ฑ + * + * @param resolution ๋Œ€์ƒ ํ•ด์ƒ๋„ (DB ์ €์žฅ์šฉ) + * @param height ์ถœ๋ ฅ ๋†’์ด (px). ๋„ˆ๋น„๋Š” FFmpeg -2 ์˜ต์…˜์œผ๋กœ ์ž๋™ ๊ณ„์‚ฐ + * @param videoBitrate ๋น„๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ (์˜ˆ: "800k", "2400k") + * @param audioBitrate ์˜ค๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ (์˜ˆ: "96k", "128k") + * @param videoCodec ๋น„๋””์˜ค ์ธ์ฝ”๋” (์˜ˆ: "libx264") + * @param audioCodec ์˜ค๋””์˜ค ์ธ์ฝ”๋” (์˜ˆ: "aac") + * @param preset ์ธ์ฝ”๋”ฉ ํ”„๋ฆฌ์…‹ (์˜ˆ: "fast", "medium") + */ +public record TranscodeProfile( + Resolution resolution, + int height, + String videoBitrate, + String audioBitrate, + String videoCodec, + String audioCodec, + String preset +) { + /** ๊ธฐ์กด ํ•˜๋“œ์ฝ”๋”ฉ ๊ฐ’๊ณผ ๋™์ผํ•œ ๊ธฐ๋ณธ ํ”„๋ฆฌ์…‹ */ + public static TranscodeProfile defaultFor(Resolution resolution) { + return switch (resolution) { + case P360 -> new TranscodeProfile(resolution, 360, "800k", "96k", "libx264", "aac", "fast"); + case P720 -> new TranscodeProfile(resolution, 720, "2400k", "128k", "libx264", "aac", "fast"); + case P1080 -> new TranscodeProfile(resolution, 1080, "4800k", "192k", "libx264", "aac", "fast"); + }; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java new file mode 100644 index 0000000..38f1c1c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java @@ -0,0 +1,25 @@ +package com.ott.transcoder.ffmpeg.execution; + +import com.ott.transcoder.ffmpeg.TranscodeProfile; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg ์‹คํ–‰ ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค + * + * FFmpeg๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ์‹(ProcessBuilder, Jaffree ๋“ฑ)์— ๋…๋ฆฝ์ ์œผ๋กœ + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•œ HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ + */ +public interface FfmpegExecutor { + + /** + * ๋‹จ์ผ ํ”„๋กœํŒŒ์ผ์— ๋Œ€ํ•ด HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ + * + * @param inputFile ์›๋ณธ ์˜์ƒ ํŒŒ์ผ ๊ฒฝ๋กœ + * @param outputDir ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ (ํ•˜์œ„์— 360p/, 720p/, 1080p/ ํด๋”๊ฐ€ ์ƒ์„ฑ๋จ) + * @param profile ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์„ค์ • (ํ•ด์ƒ๋„, ๋น„ํŠธ๋ ˆ์ดํŠธ, ์ฝ”๋ฑ ๋“ฑ) + * @return ์ƒ์„ฑ๋œ ๋ฏธ๋””์–ด ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ(media.m3u8) ๊ฒฝ๋กœ + */ + Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 0000000..4b1730f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,86 @@ +package com.ott.transcoder.ffmpeg.execution.processbuilder; + +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ProcessBuilder ๊ธฐ๋ฐ˜ FFmpeg CLI ๋ž˜ํผ + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•ด HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException { + String resolutionKey = profile.resolution().getKey().toLowerCase(); + + // ํ•ด์ƒ๋„๋ณ„ ํ•˜์œ„ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ (์˜ˆ: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolutionKey); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg ๋ช…๋ น์–ด ์กฐ๋ฆฝ โ€” TranscodeProfile์—์„œ ์„ค์ •๊ฐ’์„ ๊ฐ€์ ธ์˜ด + // TODO: FFmpeg Filter Chain ๊ตฌ์„ฑ ๋กœ์ง ์ถ”๊ฐ€ ํ•„์š” + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + profile.height(), + "-c:v", profile.videoCodec(), "-preset", profile.preset(), + "-c:a", profile.audioCodec(), "-b:a", profile.audioBitrate(), + "-b:v", profile.videoBitrate(), + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg ์‹คํ–‰ - resolution: {}, command: {}", resolutionKey, String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg ํƒ€์ž„์•„์›ƒ - resolution: " + resolutionKey); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg ์‹คํŒจ - resolution: " + resolutionKey + ", exitCode: " + exitCode); + } + + log.info("FFmpeg ์™„๋ฃŒ - resolution: {}, output: {}", resolutionKey, playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java new file mode 100644 index 0000000..eaadd58 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java @@ -0,0 +1,33 @@ +package com.ott.transcoder.inspection; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.validation.FileValidator; +import com.ott.transcoder.inspection.validation.StreamValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; + +/** + * ์ž…๋ ฅ ํŒŒ์ผ ๊ฒ€์‚ฌ + * FileValidator โ†’ Probe โ†’ StreamValidator ์ˆœ์„œ๋กœ ์‹คํ–‰ + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class Inspector { + + private final FileValidator fileValidator; + private final FfprobeExecutor ffprobeExecutor; + private final StreamValidator streamValidator; + + public ProbeResult inspect(Path inputFile) { + fileValidator.validate(inputFile); + ProbeResult probeResult = ffprobeExecutor.probe(inputFile); + streamValidator.validate(probeResult); + + return probeResult; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java new file mode 100644 index 0000000..55281fa --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java @@ -0,0 +1,52 @@ +package com.ott.transcoder.inspection.probe; + +/** + * ffprobe ์‹คํ–‰ ๊ฒฐ๊ณผ๋ฅผ ๋‹ด๋Š” ๋ถˆ๋ณ€ ๋ ˆ์ฝ”๋“œ + * + * @param width ์˜์ƒ ๋„ˆ๋น„ (px) + * @param height ์˜์ƒ ๋†’์ด (px) + * @param durationSeconds ์ „์ฒด ์žฌ์ƒ ์‹œ๊ฐ„ (์ดˆ) + * @param videoCodec ๋น„๋””์˜ค ์ฝ”๋ฑ (์˜ˆ: h264, hevc, vp9) + * @param audioCodec ์˜ค๋””์˜ค ์ฝ”๋ฑ (์˜ˆ: aac, opus, "none") + * @param fps ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ + * @param videoBitrate ๋น„๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ (bps) + * @param audioBitrate ์˜ค๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ (bps) + * @param audioChannels ์˜ค๋””์˜ค ์ฑ„๋„ ์ˆ˜ (์˜ˆ: 2=stereo, 6=5.1ch) + * @param pixelFormat ํ”ฝ์…€ ํฌ๋งท (์˜ˆ: yuv420p, yuv422p) + * @param rotation ํšŒ์ „ ๊ฐ๋„ (0, 90, 180, 270). ์Šค๋งˆํŠธํฐ ์„ธ๋กœ ์ดฌ์˜ ์‹œ 90 ๋˜๋Š” 270 + */ +public record ProbeResult( + int width, + int height, + double durationSeconds, + String videoCodec, + String audioCodec, + double fps, + long videoBitrate, + long audioBitrate, + int audioChannels, + String pixelFormat, + int rotation +) { + /** + * ํšŒ์ „์„ ๊ณ ๋ คํ•œ ์‹ค์ œ ์˜์ƒ ๋†’์ด. + * 90ยฐ ๋˜๋Š” 270ยฐ ํšŒ์ „๋œ ์˜์ƒ์€ width์™€ height๊ฐ€ ๋’ค๋ฐ”๋€๋‹ค. + * ์˜ˆ: 1080x1920(์„ธ๋กœ ์ดฌ์˜, rotation=90) โ†’ ์‹ค์ œ ์ถœ๋ ฅ์€ 1920x1080 โ†’ effectiveHeight = 1080 + */ + public int effectiveHeight() { + return isRotated() ? this.width : this.height; + } + + public int effectiveWidth() { + return isRotated() ? this.height : this.width; + } + + public boolean isRotated() { + return rotation == 90 || rotation == 270; + } + + // ํšŒ์ „์„ ๊ณ ๋ คํ•˜์—ฌ ์—…์Šค์ผ€์ผ ์—ฌ๋ถ€ ํŒ๋‹จ + public boolean isUpscaleFor(int targetHeight) { + return targetHeight > effectiveHeight(); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java new file mode 100644 index 0000000..3f4a606 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java @@ -0,0 +1,21 @@ +package com.ott.transcoder.inspection.probe.execution; + +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + +/** + * ffprobe ์‹คํ–‰ ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค + * + * ์ž…๋ ฅ ํŒŒ์ผ์˜ ๋ฏธ๋””์–ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ + */ +public interface FfprobeExecutor { + + /** + * ์ž…๋ ฅ ํŒŒ์ผ์— ๋Œ€ํ•ด ffprobe๋ฅผ ์‹คํ–‰ํ•˜์—ฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ + * + * @param inputFile ๋ถ„์„ ๋Œ€์ƒ ํŒŒ์ผ ๊ฒฝ๋กœ + * @return ์ถ”์ถœ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + */ + ProbeResult probe(Path inputFile); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java new file mode 100644 index 0000000..b473548 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java @@ -0,0 +1,165 @@ +package com.ott.transcoder.inspection.probe.execution.processbuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ffprobe๋ฅผ JSON ์ถœ๋ ฅ ๋ชจ๋“œ๋กœ ์‹คํ–‰ํ•˜์—ฌ ๋ฏธ๋””์–ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ถ”์ถœ + * format(์ปจํ…Œ์ด๋„ˆ ์ •๋ณด)๊ณผ streams(์ŠคํŠธ๋ฆผ ์ •๋ณด)๋ฅผ ํ•จ๊ป˜ ์š”์ฒญํ•˜๊ณ , + * ์ฒซ ๋ฒˆ์งธ ๋น„๋””์˜ค/์˜ค๋””์˜ค ์ŠคํŠธ๋ฆผ์—์„œ ํ•„์š”ํ•œ ํ•„๋“œ๋ฅผ ํŒŒ์‹ฑ + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "transcoder.ffprobe.engine", havingValue = "processbuilder") +public class ProcessBuilderFfprobeExecutor implements FfprobeExecutor { + + private final ObjectMapper objectMapper; + + @Value("${transcoder.ffprobe.path:ffprobe}") + private String ffprobePath; + + @Override + public ProbeResult probe(Path inputFile) { + List command = List.of( + ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + inputFile.toString() + ); + + log.info("ffprobe ์‹คํ–‰ - input: {}", inputFile); + + try { + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + + boolean finished = process.waitFor(2, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("ffprobe ํƒ€์ž„์•„์›ƒ - input: " + inputFile); + } + if (process.exitValue() != 0) { + throw new RuntimeException( + "ffprobe ์‹คํŒจ - exitCode: " + process.exitValue() + ", output: " + output); + } + + return parseJson(output); + + } catch (IOException | InterruptedException e) { + throw new RuntimeException("ffprobe ์‹คํ–‰ ์‹คํŒจ - input: " + inputFile, e); + } + } + + private ProbeResult parseJson(String json) throws IOException { + JsonNode root = objectMapper.readTree(json); + JsonNode streamList = root.get("streams"); + + JsonNode videoStream = null; + JsonNode audioStream = null; + + for (JsonNode stream : streamList) { + String codecType = stream.get("codec_type").asText(); + if ("video".equals(codecType) && videoStream == null) { + videoStream = stream; + } else if ("audio".equals(codecType) && audioStream == null) { + audioStream = stream; + } + } + + if (videoStream == null) { + throw new RuntimeException("๋น„๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ"); + } + + JsonNode format = root.get("format"); // null ๊ฐ€๋Šฅ์„ฑ ์กด์žฌ + + double duration = format.has("duration") + ? format.get("duration").asDouble() + : 0.0; + + double fps = parseFps(videoStream.path("r_frame_rate").asText("0/1")); + + long videoBitrate = videoStream.has("bit_rate") + ? videoStream.get("bit_rate").asLong() + : format.path("bit_rate").asLong(0); + + long audioBitrate = (audioStream != null && audioStream.has("bit_rate")) + ? audioStream.get("bit_rate").asLong() + : 0L; + + String audioCodec = (audioStream != null) + ? audioStream.get("codec_name").asText() + : "none"; + + int audioChannels = (audioStream != null) + ? audioStream.path("channels").asInt(0) + : 0; + + String pixelFormat = videoStream.path("pix_fmt").asText("unknown"); + + int rotation = parseRotation(videoStream); + + return new ProbeResult( + videoStream.get("width").asInt(), + videoStream.get("height").asInt(), + duration, + videoStream.get("codec_name").asText(), + audioCodec, + fps, + videoBitrate, + audioBitrate, + audioChannels, + pixelFormat, + rotation + ); + } + + /** side_data_list[].rotation โ†’ tags.rotate ์ˆœ์œผ๋กœ ํ™•์ธ */ + private int parseRotation(JsonNode videoStream) { + // 1. side_data_list์—์„œ rotation ํ™•์ธ + JsonNode sideDataList = videoStream.path("side_data_list"); + if (sideDataList.isArray()) { + for (JsonNode sideData : sideDataList) { + if (sideData.has("rotation")) { + return Math.abs(sideData.get("rotation").asInt()); + } + } + } + + // 2. tags.rotate ํ™•์ธ (๊ตฌ๋ฒ„์ „ ํ˜ธํ™˜) + JsonNode tags = videoStream.path("tags"); + if (tags.has("rotate")) { + return Math.abs(tags.get("rotate").asInt()); + } + + return 0; + } + + /** "30/1", "30000/1001" ๋“ฑ ๋ถ„์ˆ˜ ํ˜•ํƒœ ํŒŒ์‹ฑ */ + private double parseFps(String rFrameRate) { + String[] parts = rFrameRate.split("/"); + if (parts.length == 2) { + double numerator = Double.parseDouble(parts[0]); + double denominator = Double.parseDouble(parts[1]); + return denominator > 0 ? numerator / denominator : 0.0; + } + return Double.parseDouble(rFrameRate); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java new file mode 100644 index 0000000..43a663f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java @@ -0,0 +1,26 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * ๋‹ค์šด๋กœ๋“œ ์ „ ๋””์Šคํฌ ์—ฌ์œ  ๊ณต๊ฐ„ ๊ฒ€์ฆ + * ์›๋ณธ ํฌ๊ธฐ ร— multiplier๋งŒํผ์˜ ๊ณต๊ฐ„์ด ์žˆ๋Š”์ง€ ํ™•์ธ + */ +@Slf4j +@Component +public class DiskSpaceGuard { + + @Value("${transcoder.validation.disk-space-multiplier:5}") + private double multiplier; + + public void check(Path originPath) { + + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java new file mode 100644 index 0000000..34246d8 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java @@ -0,0 +1,150 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.Map; + +/** + * probe ์ „ ํŒŒ์ผ ์ˆ˜์ค€ ๊ฒ€์ฆ. + * + * ffprobe๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ์ „์—, ํŒŒ์ผ ์ž์ฒด๊ฐ€ ์œ ํšจํ•œ ๋ฏธ๋””์–ด ํŒŒ์ผ์ธ์ง€ ๊ธฐ๋ณธ ๋ฐฉ์–ด์„ ์„ ์นœ๋‹ค. + * ์—ฌ๊ธฐ์„œ ๊ฑธ๋Ÿฌ์ง€๋ฉด ffprobe๋ฅผ ๋Œ๋ฆด ํ•„์š”์กฐ์ฐจ ์—†๋‹ค. + * + * ๊ฒ€์ฆ ํ•ญ๋ชฉ: + * 1. ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ + * 2. ํŒŒ์ผ ํฌ๊ธฐ (0 bytes / ์ƒํ•œ ์ดˆ๊ณผ) + * 3. ์ฝ๊ธฐ ๊ถŒํ•œ + * 4. ๋งค์ง ๋ฐ”์ดํŠธ โ€” ์‹ค์ œ ๋ฏธ๋””์–ด ํฌ๋งท์ธ์ง€ ํ™•์ธ + * 5. ํ™•์žฅ์ž vs ๋งค์ง ๋ฐ”์ดํŠธ ๋ถˆ์ผ์น˜ ๊ฐ์ง€ + */ +@Slf4j +@Component +public class FileValidator { + + /** ํŒŒ์ผ ํฌ๊ธฐ ์ƒํ•œ (๊ธฐ๋ณธ 10GB) */ + @Value("${transcoder.validation.max-file-size-bytes:10737418240}") + private long maxFileSizeBytes; + + private static final Map EXTENSION_TO_FORMAT = Map.of( + "mp4", "MP4", + "mov", "MOV", + "mkv", "MKV", + "webm", "WEBM", + "avi", "AVI", + "flv", "FLV", + "ts", "MPEG-TS" + ); + + public void validate(Path inputFile) { + // 1. ํŒŒ์ผ ์กด์žฌ + if (!Files.exists(inputFile)) { + throw new IllegalStateException("ํŒŒ์ผ์ด ์กด์žฌํ•˜์ง€ ์•Š์Œ - " + inputFile); + } + + // 2. ์ฝ๊ธฐ ๊ถŒํ•œ + if (!Files.isReadable(inputFile)) { + throw new IllegalStateException("ํŒŒ์ผ ์ฝ๊ธฐ ๊ถŒํ•œ ์—†์Œ - " + inputFile); + } + + // 3. ํŒŒ์ผ ํฌ๊ธฐ + long fileSize; + try { + fileSize = Files.size(inputFile); + } catch (IOException e) { + throw new IllegalStateException("ํŒŒ์ผ ํฌ๊ธฐ ํ™•์ธ ์‹คํŒจ - " + inputFile, e); + } + + if (fileSize == 0) { + throw new IllegalStateException("ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ 0 bytes - " + inputFile); + } + if (fileSize > maxFileSizeBytes) { + throw new IllegalStateException( + "ํŒŒ์ผ ํฌ๊ธฐ ์ƒํ•œ ์ดˆ๊ณผ - size: " + fileSize + " bytes, max: " + maxFileSizeBytes + " bytes"); + } + + // 4. ๋งค์ง ๋ฐ”์ดํŠธ ๊ฒ€์ฆ + String detectedFormat = detectFormatByMagicBytes(inputFile); + if (detectedFormat == null) { + throw new IllegalStateException("์•Œ ์ˆ˜ ์—†๋Š” ํŒŒ์ผ ํฌ๋งท (๋งค์ง ๋ฐ”์ดํŠธ ๋ถˆ์ผ์น˜) - " + inputFile); + } + + // 5. ํ™•์žฅ์ž vs ๋งค์ง ๋ฐ”์ดํŠธ ๋ถˆ์ผ์น˜ ๊ฒฝ๊ณ  + String extension = getExtension(inputFile); + String expectedFormat = EXTENSION_TO_FORMAT.get(extension); + if (expectedFormat != null && !expectedFormat.equals(detectedFormat)) { + // MOV์™€ MP4๋Š” ๋™์ผํ•œ ftyp ๊ณ„์—ด์ด๋ฏ€๋กœ ํ˜ธํ™˜์œผ๋กœ ์ทจ๊ธ‰ + if (!isCompatibleFormat(expectedFormat, detectedFormat)) { + log.warn("ํ™•์žฅ์ž-ํฌ๋งท ๋ถˆ์ผ์น˜ - file: {}, extension: .{} ({}), detected: {}", + inputFile.getFileName(), extension, expectedFormat, detectedFormat); + } + } + + log.info("ํŒŒ์ผ ๊ฒ€์ฆ ํ†ต๊ณผ - file: {}, size: {} bytes, format: {}", + inputFile.getFileName(), fileSize, detectedFormat); + } + + private String detectFormatByMagicBytes(Path inputFile) { + byte[] header = new byte[12]; + int bytesRead; + + try (InputStream is = Files.newInputStream(inputFile)) { + bytesRead = is.read(header); + } catch (IOException e) { + throw new IllegalStateException("๋งค์ง ๋ฐ”์ดํŠธ ์ฝ๊ธฐ ์‹คํŒจ - " + inputFile, e); + } + + if (bytesRead < 8) { + return null; + } + + // MP4/MOV: offset 4~7์ด "ftyp" + if (header[4] == 0x66 && header[5] == 0x74 && header[6] == 0x79 && header[7] == 0x70) { + return "MP4"; // MP4/MOV/3GP ๊ณ„์—ด + } + + // MKV/WebM: EBML ํ—ค๋” (0x1A 0x45 0xDF 0xA3) + if (header[0] == 0x1A && header[1] == 0x45 && header[2] == (byte) 0xDF && header[3] == (byte) 0xA3) { + return "MKV"; // MKV/WebM + } + + // AVI: "RIFF" + if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F') { + return "AVI"; + } + + // FLV: "FLV" + if (header[0] == 'F' && header[1] == 'L' && header[2] == 'V') { + return "FLV"; + } + + // MPEG-TS: sync byte 0x47 + if (header[0] == 0x47) { + return "MPEG-TS"; + } + + log.debug("๋งค์ง ๋ฐ”์ดํŠธ ๋ฏธ์‹๋ณ„ - hex: {}", HexFormat.of().formatHex(header, 0, bytesRead)); + return null; + } + + private String getExtension(Path file) { + String fileName = file.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0) return ""; + return fileName.substring(dotIndex + 1).toLowerCase(); + } + + /** MP4/MOV๋Š” ๋™์ผ ftyp ๊ณ„์—ด์ด๋ฏ€๋กœ ํ˜ธํ™˜์œผ๋กœ ์ทจ๊ธ‰ */ + private boolean isCompatibleFormat(String expected, String detected) { + if ("MP4".equals(expected) && "MP4".equals(detected)) return true; + if ("MOV".equals(expected) && "MP4".equals(detected)) return true; + if ("WEBM".equals(expected) && "MKV".equals(detected)) return true; + return false; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java new file mode 100644 index 0000000..c6c796c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java @@ -0,0 +1,91 @@ +package com.ott.transcoder.inspection.validation; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * probe ํ›„ ์ŠคํŠธ๋ฆผ ์ˆ˜์ค€ ๊ฒ€์ฆ + * + * ffprobe ๊ฒฐ๊ณผ(ProbeResult)๋ฅผ ๋ฐ›์•„, ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ง„ํ–‰ํ•ด๋„ ์•ˆ์ „ํ•œ์ง€ ํŒ๋‹จ + * + * ๊ฒ€์ฆ ํ•ญ๋ชฉ: + * 1. ๋น„๋””์˜ค ์ŠคํŠธ๋ฆผ ์กด์žฌ (์˜ค๋””์˜ค๋งŒ ์žˆ๋Š” ํŒŒ์ผ ์ฐจ๋‹จ) + * 2. ๋น„๋””์˜ค ์ฝ”๋ฑ ์ง€์› ์—ฌ๋ถ€ + * 3. duration ์œ ํšจ์„ฑ (0์ดˆ, ๋น„์ •์ƒ์ ์œผ๋กœ ๊ธด ์˜์ƒ) + * 4. ํ•ด์ƒ๋„ ๋ฒ”์œ„ (๋„ˆ๋ฌด ์ž‘๊ฑฐ๋‚˜ ๋„ˆ๋ฌด ํฐ ์˜์ƒ) + * 5. ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ ์ด์ƒ ๊ฐ์ง€ + * 6. ์†์ƒ ๊ฐ์ง€ (๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋ถˆ์™„์ „) + */ +@Slf4j +@Component +public class StreamValidator { + + // FFmpeg์ด ๋””์ฝ”๋”ฉ ๊ฐ€๋Šฅํ•œ ์ผ๋ฐ˜์ ์ธ ๋น„๋””์˜ค ์ฝ”๋ฑ + private static final Set SUPPORTED_VIDEO_CODEC_SET = Set.of( + "h264", "hevc", "h265", "vp8", "vp9", "av1", + "mpeg4", "mpeg2video", "mpeg1video", + "wmv3", "vc1", + "theora", "prores", "dnxhd", + "mjpeg", "rawvideo" + ); + + /** ์ตœ์†Œ ํ•ด์ƒ๋„ (์ด๋ณด๋‹ค ์ž‘์œผ๋ฉด ์˜๋ฏธ ์—†๋Š” ์˜์ƒ) */ + private static final int MIN_RESOLUTION = 32; + + /** ์ตœ๋Œ€ ํ•ด์ƒ๋„ (8K ์ดˆ๊ณผ๋Š” ๋น„์ •์ƒ) */ + private static final int MAX_RESOLUTION = 8192; + + /** ์ตœ๋Œ€ ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ (์ด๋ณด๋‹ค ๋†’์œผ๋ฉด ๋น„์ •์ƒ) */ + private static final double MAX_FPS = 240.0; + + @Value("${transcoder.validation.max-duration-seconds:43200}") + private double maxDurationSeconds; // ๊ธฐ๋ณธ 12์‹œ๊ฐ„ + + public void validate(ProbeResult probeResult) { + // 1. ๋น„๋””์˜ค ์ฝ”๋ฑ ์กด์žฌ ๋ฐ ์ง€์› ์—ฌ๋ถ€ + if (probeResult.videoCodec() == null || probeResult.videoCodec().isBlank()) { + throw new IllegalStateException("๋น„๋””์˜ค ์ฝ”๋ฑ ์ •๋ณด ์—†์Œ"); + } + if (!SUPPORTED_VIDEO_CODEC_SET.contains(probeResult.videoCodec().toLowerCase())) { + throw new IllegalStateException( + "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋น„๋””์˜ค ์ฝ”๋ฑ - codec: " + probeResult.videoCodec()); + } + + // 2. ํ•ด์ƒ๋„ ๋ฒ”์œ„ + if (probeResult.width() < MIN_RESOLUTION || probeResult.height() < MIN_RESOLUTION) { + throw new IllegalStateException( + "ํ•ด์ƒ๋„๊ฐ€ ๋„ˆ๋ฌด ์ž‘์Œ - " + probeResult.width() + "x" + probeResult.height()); + } + if (probeResult.width() > MAX_RESOLUTION || probeResult.height() > MAX_RESOLUTION) { + throw new IllegalStateException( + "ํ•ด์ƒ๋„๊ฐ€ ๋„ˆ๋ฌด ํผ - " + probeResult.width() + "x" + probeResult.height()); + } + + // 3. duration ์œ ํšจ์„ฑ + if (probeResult.durationSeconds() <= 0) { + throw new IllegalStateException( + "duration์ด ์œ ํšจํ•˜์ง€ ์•Š์Œ - " + probeResult.durationSeconds() + "s"); + } + if (probeResult.durationSeconds() > maxDurationSeconds) { + throw new IllegalStateException( + "duration ์ƒํ•œ ์ดˆ๊ณผ - " + probeResult.durationSeconds() + "s, max: " + maxDurationSeconds + "s"); + } + + // 4. ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ ์ด์ƒ + if (probeResult.fps() <= 0) { + throw new IllegalStateException("ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Œ - fps: " + probeResult.fps()); + } + if (probeResult.fps() > MAX_FPS) { + throw new IllegalStateException( + "ํ”„๋ ˆ์ž„๋ ˆ์ดํŠธ๊ฐ€ ๋น„์ •์ƒ์ ์œผ๋กœ ๋†’์Œ - fps: " + probeResult.fps() + ", max: " + MAX_FPS); + } + + log.info("์ŠคํŠธ๋ฆผ ๊ฒ€์ฆ ํ†ต๊ณผ - {}x{}, duration: {}s, codec: {}, fps: {}", + probeResult.width(), probeResult.height(), + probeResult.durationSeconds(), probeResult.videoCodec(), probeResult.fps()); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java new file mode 100644 index 0000000..dabbda4 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java @@ -0,0 +1,14 @@ +package com.ott.transcoder.pipeline; + +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + +/** + * ์ปค๋งจ๋“œ๋ณ„ ๋ฏธ๋””์–ด ์ฒ˜๋ฆฌ ํŒŒ์ดํ”„๋ผ์ธ + * ๊ตฌํ˜„์ฒด๋Š” ๋ฏธ๋””์–ด ์ฒ˜๋ฆฌ ์ž์ฒด์—๋งŒ ์ง‘์ค‘ + */ +public interface CommandPipeline { + + void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java new file mode 100644 index 0000000..9575c71 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -0,0 +1,48 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.storage.VideoStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HlsTranscodePipeline implements CommandPipeline { + + private final TranscodePlanner transcodePlanner; + private final FfmpegExecutor ffmpegExecutor; + private final MasterPlaylistGenerator masterPlaylistGenerator; + private final VideoStorage videoStorage; + + @Override + public void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception { + log.info("HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์‹œ์ž‘ - mediaId: {}", mediaId); + + // plan + // TODO: Filter Chain ๊ตฌ์„ฑ ํ•„์š” + List profileList = transcodePlanner.plan(probeResult); + + // main + for (TranscodeProfile profile : profileList) { + ffmpegExecutor.execute(inputFile, workDir, profile); + } + + // post + List resolutionList = profileList.stream() + .map(TranscodeProfile::resolution) + .toList(); + masterPlaylistGenerator.generate(workDir, resolutionList); + + String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); + log.info("HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์™„๋ฃŒ - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java new file mode 100644 index 0000000..003403e --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java @@ -0,0 +1,49 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** HLS ๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ(master.m3u8) ์ƒ์„ฑ๊ธฐ. ABR variant๋ฅผ ํฌํ•จํ•œ๋‹ค. */ +@Slf4j +@Component +public class MasterPlaylistGenerator { + + /** ํ•ด์ƒ๋„๋ณ„ variant ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (๋Œ€์—ญํญ, ํ™”๋ฉด ํฌ๊ธฐ, ์ƒ๋Œ€ ๊ฒฝ๋กœ) */ + private record Variant(int bandwidth, String resolution, String playlistPath) {} + + private static final Map VARIANT_MAP = Map.of( + Resolution.P360, new Variant(800_000, "640x360", "360p/media.m3u8"), + Resolution.P720, new Variant(2_400_000, "1280x720", "720p/media.m3u8"), + Resolution.P1080, new Variant(4_800_000, "1920x1080", "1080p/media.m3u8") + ); + + /** + * @param outputDir ๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•  ๋””๋ ‰ํ† ๋ฆฌ + * @param resolutionList ํฌํ•จํ•  ํ•ด์ƒ๋„ ๋ชฉ๋ก + * @return ์ƒ์„ฑ๋œ master.m3u8 ๊ฒฝ๋กœ + */ + public Path generate(Path outputDir, List resolutionList) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("#EXTM3U\n"); + + for (Resolution resolution : resolutionList) { + Variant variant = VARIANT_MAP.get(resolution); + sb.append("#EXT-X-STREAM-INF:BANDWIDTH=").append(variant.bandwidth()) + .append(",RESOLUTION=").append(variant.resolution()).append("\n"); + sb.append(variant.playlistPath()).append("\n"); + } + + Path masterPath = outputDir.resolve("master.m3u8"); + Files.writeString(masterPath, sb.toString()); + + log.info("๋งˆ์Šคํ„ฐ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ - path: {}", masterPath); + return masterPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java new file mode 100644 index 0000000..f9491a7 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java @@ -0,0 +1,121 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * ProbeResult๋ฅผ ๋ถ„์„ํ•˜์—ฌ HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋Œ€์ƒ ํ•ด์ƒ๋„/๋น„ํŠธ๋ ˆ์ดํŠธ ๊ฒฐ์ • + * ์—…์Šค์ผ€์ผ ๋ฐฉ์ง€, ์›๋ณธ ๋น„ํŠธ๋ ˆ์ดํŠธ ์ƒํ•œ ์ ์šฉ ๋“ฑ + */ +@Slf4j +@Component +public class TranscodePlanner { + + /** ํ•ด์ƒ๋„๋ณ„ ๋†’์ด (Resolution enum์— height ํ•„๋“œ๊ฐ€ ์—†์œผ๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ ๊ด€๋ฆฌ) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** ํ•ด์ƒ๋„๋ณ„ ๊ธฐ๋ณธ ๋น„๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ (bps) โ€” ์›๋ณธ ๋น„ํŠธ๋ ˆ์ดํŠธ์™€ ๋น„๊ต์šฉ */ + private static final Map DEFAULT_VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, 800_000L, + Resolution.P720, 2_400_000L, + Resolution.P1080, 4_800_000L + ); + + /** ํ•ด์ƒ๋„๋ณ„ ๊ธฐ๋ณธ ์˜ค๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ ๋ฌธ์ž์—ด */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + /** ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋Œ€์ƒ ํ•ด์ƒ๋„ */ + private static final List CANDIDATE_RESOLUTION_LIST = List.of( + Resolution.P360, Resolution.P720, Resolution.P1080 + ); + + /** + * ProbeResult๋ฅผ ๋ถ„์„ํ•˜์—ฌ ํŠธ๋žœ์Šค์ฝ”๋”ฉํ•  ํ”„๋กœํŒŒ์ผ ๋ชฉ๋ก ์ƒ์„ฑ + * + * @param probeResult ffprobe ๊ฒฐ๊ณผ + * @return ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋Œ€์ƒ ํ”„๋กœํŒŒ์ผ ๋ชฉ๋ก (์—…์Šค์ผ€์ผ ํ•ด์ƒ๋„ ์ œ์™ธ) + */ + public List plan(ProbeResult probeResult) { + List profileList = new ArrayList<>(); + + for (Resolution resolution : CANDIDATE_RESOLUTION_LIST) { + int targetHeight = HEIGHT_MAP.get(resolution); + + // ์—…์Šค์ผ€์ผ ๋ฐฉ์ง€ + if (probeResult.isUpscaleFor(targetHeight)) { + continue; + } + + String videoBitrate = decideVideoBitrate(probeResult, resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + String audioCodec = decideAudioCodec(probeResult); + + TranscodeProfile profile = new TranscodeProfile( + resolution, + targetHeight, + videoBitrate, + audioBitrate, + "libx264", + audioCodec, + "fast" + ); + + profileList.add(profile); + } + + if (profileList.isEmpty()) { + // ์›๋ณธ์ด 360p ๋ฏธ๋งŒ์ด์–ด๋„ ์ตœ์†Œ 1๊ฐœ๋Š” ์ƒ์„ฑ (์›๋ณธ ํ•ด์ƒ๋„๋กœ) + log.warn("๋ชจ๋“  ํ•ด์ƒ๋„๊ฐ€ ์—…์Šค์ผ€์ผ โ€” ์ตœ์†Œ ํ”„๋กœํŒŒ์ผ ์ƒ์„ฑ (360p ๊ธฐ์ค€, ์›๋ณธ ๋†’์ด: {})", probeResult.height()); + profileList.add(TranscodeProfile.defaultFor(Resolution.P360)); + } + + log.info("ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๊ณ„ํš ์ˆ˜๋ฆฝ ์™„๋ฃŒ - ๋Œ€์ƒ ํ•ด์ƒ๋„: {}", + profileList.stream().map(p -> p.resolution().getKey()).toList()); + + return profileList; + } + + /** + * ๋น„๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ ๊ฒฐ์ • + * ์›๋ณธ ๋น„ํŠธ๋ ˆ์ดํŠธ๊ฐ€ ๊ธฐ๋ณธ๊ฐ’๋ณด๋‹ค ๋‚ฎ์œผ๋ฉด ์›๋ณธ ๋น„ํŠธ๋ ˆ์ดํŠธ๋ฅผ ์ƒํ•œ์œผ๋กœ ์‚ฌ์šฉํ•˜์—ฌ ๊ณผ๋„ํ•œ ํ• ๋‹น์„ ๋ฐฉ์ง€ + */ + private String decideVideoBitrate(ProbeResult probeResult, Resolution resolution) { + long defaultBitrate = DEFAULT_VIDEO_BITRATE_MAP.get(resolution); + long originBitrate = probeResult.videoBitrate(); + + // ์›๋ณธ ๋น„ํŠธ๋ ˆ์ดํŠธ ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ + if (originBitrate <= 0) { + return formatBitrate(defaultBitrate); + } + + // ์›๋ณธ์ด ๊ธฐ๋ณธ๊ฐ’๋ณด๋‹ค ๋‚ฎ์œผ๋ฉด ์›๋ณธ์„ ์ƒํ•œ์œผ๋กœ + long chosen = Math.min(defaultBitrate, originBitrate); + return formatBitrate(chosen); + } + + private String decideAudioCodec(ProbeResult probeResult) { + return "aac"; + } + + private String formatBitrate(long bps) { + if (bps >= 1_000_000) { + return (bps / 1_000) + "k"; + } + return bps + ""; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java new file mode 100644 index 0000000..afc300a --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java @@ -0,0 +1,12 @@ +package com.ott.transcoder.queue; + +/** + * ๋ฉ”์‹œ์ง€ ํ ์†Œ๋น„์ž ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค + * + * ํ˜„์žฌ ๊ตฌํ˜„์ฒด: RabbitTranscodeListener (RabbitMQ) + * ํ ๊ต์ฒด ์‹œ(SQS ๋“ฑ) ์ƒˆ ๊ตฌํ˜„์ฒด๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค. + */ +public interface MessageListener { + + void listen(TranscodeMessage message) throws Exception; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java new file mode 100644 index 0000000..f4e3bd0 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java @@ -0,0 +1,14 @@ +package com.ott.transcoder.queue; + +/** + * ํŠธ๋žœ์Šค์ฝ”๋”ฉ ์š”์ฒญ ๋ฉ”์‹œ์ง€ DTO. + * + * @param mediaId ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๋Œ€์ƒ ๋ฏธ๋””์–ด ID (Contents ๋˜๋Š” ShortForm์˜ media_id) + * @param originUrl ์›๋ณธ ์˜์ƒ ์œ„์น˜ (๋กœ์ปฌ ๊ฒฝ๋กœ ๋˜๋Š” S3 key) + */ +public record TranscodeMessage( + + Long mediaId, + String originUrl +) { +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java new file mode 100644 index 0000000..d59b158 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java @@ -0,0 +1,30 @@ +package com.ott.transcoder.queue.rabbit; + +import com.ott.transcoder.JobOrchestrator; +import com.ott.transcoder.config.RabbitConfig; +import com.ott.transcoder.queue.MessageListener; +import com.ott.transcoder.queue.TranscodeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") +public class RabbitTranscodeListener implements MessageListener { + + private final JobOrchestrator jobOrchestrator; + + @Override + @RabbitListener(queues = RabbitConfig.QUEUE_NAME) + public void listen(TranscodeMessage message) throws Exception { + log.info("์ž‘์—… ์š”์ฒญ ์ˆ˜์‹  - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); + + jobOrchestrator.handle(message); + + log.info("์ž‘์—… ์š”์ฒญ ์ฒ˜๋ฆฌ ์™„๋ฃŒ - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/queue/sqs/.gitkeep similarity index 100% rename from apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/queue/sqs/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/service/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java new file mode 100644 index 0000000..33fdd84 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java @@ -0,0 +1,77 @@ +package com.ott.transcoder.storage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; + +/** + * ๋กœ์ปฌ ํŒŒ์ผ์‹œ์Šคํ…œ ๊ธฐ๋ฐ˜ VideoStorage ๊ตฌํ˜„์ฒด + * + * ๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ S3 ์—†์ด ๋™์ž‘ํ•˜๊ธฐ ์œ„ํ•œ ๊ตฌํ˜„ + * - download: ๋กœ์ปฌ ๊ฒฝ๋กœ์—์„œ workDir๋กœ ํŒŒ์ผ ๋ณต์‚ฌ + * - upload: workDir ๋‚ด ๋ชจ๋“  ํŒŒ์ผ์„ output-dir ํ•˜์œ„๋กœ ์žฌ๊ท€ ๋ณต์‚ฌ + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "storage.provider", havingValue = "local") +public class LocalVideoStorage implements VideoStorage { + + @Value("${storage.local.output-dir:#{systemProperties['java.io.tmpdir'] + '/ott-storage'}}") + private String outputDir; + + @Override + public Path download(String sourceKey, Path workDir) { + Path source = Path.of(sourceKey); + Path target = workDir.resolve(source.getFileName()); + + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("์›๋ณธ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ - source: " + sourceKey, e); + } + + log.info("์›๋ณธ ๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ - {} โ†’ {}", sourceKey, target); + return target; + } + + /** + * workDir ๋‚ด ๋ชจ๋“  ํŒŒ์ผ์„ output-dir/{destinationPrefix}/ ํ•˜์œ„๋กœ ๋ณต์‚ฌ + * ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ(360p/, 720p/, 1080p/) ๊ทธ๋Œ€๋กœ ์œ ์ง€ + */ + @Override + public String upload(Path localDir, String destinationPrefix) { + Path destination = Path.of(outputDir, destinationPrefix); + + try { + Files.createDirectories(destination); + + try (Stream fileStream = Files.walk(localDir)) { + fileStream.filter(Files::isRegularFile).forEach(file -> { + // workDir ๊ธฐ์ค€ ์ƒ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์œ ์ง€ํ•˜์—ฌ ๋ณต์‚ฌ (์˜ˆ: 360p/media.m3u8) + Path relativePath = localDir.relativize(file); + Path targetFile = destination.resolve(relativePath); + + try { + Files.createDirectories(targetFile.getParent()); + Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ - " + file, e); + } + }); + } + } catch (IOException e) { + throw new UncheckedIOException("์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ ์‹คํŒจ - " + destination, e); + } + + log.info("์—…๋กœ๋“œ ์™„๋ฃŒ - {} โ†’ {}", localDir, destination); + return destination.toString(); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java new file mode 100644 index 0000000..6417267 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java @@ -0,0 +1,26 @@ +package com.ott.transcoder.storage; + +import java.nio.file.Path; + +/** + * ์˜์ƒ ํŒŒ์ผ ์ €์žฅ์†Œ ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค + * S3VideoStorage๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์‹ค์ œ AWS S3 ์—ฐ๋™์œผ๋กœ ๊ต์ฒดํ•  ๊ฒƒ + */ +public interface VideoStorage { + + /** + * ์›๋ณธ ์˜์ƒ์„ ์ €์žฅ์†Œ์—์„œ ๋กœ์ปฌ ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ๋กœ ๊ฐ€์ ธ์˜จ๋‹ค + * @param sourceKey ์›๋ณธ ์œ„์น˜ (๋กœ์ปฌ ๊ฒฝ๋กœ ๋˜๋Š” S3 key) + * @param workDir ๋‹ค์šด๋กœ๋“œ ๋Œ€์ƒ ๋กœ์ปฌ ๋””๋ ‰ํ† ๋ฆฌ + * @return ๋‹ค์šด๋กœ๋“œ๋œ ๋กœ์ปฌ ํŒŒ์ผ ๊ฒฝ๋กœ + */ + Path download(String sourceKey, Path workDir); + + /** + * ํŠธ๋žœ์Šค์ฝ”๋”ฉ ๊ฒฐ๊ณผ๋ฌผ์„ ์ €์žฅ์†Œ์— ์—…๋กœ๋“œ + * @param localDir ์—…๋กœ๋“œํ•  ๋กœ์ปฌ ๋””๋ ‰ํ† ๋ฆฌ (HLS ํŒŒ์ผ๋“ค์ด ๋“ค์–ด์žˆ์Œ) + * @param destinationPrefix ์ €์žฅ์†Œ ๋‚ด ๋ชฉ์ ์ง€ ๊ฒฝ๋กœ (์˜ˆ: "media/1/hls") + * @return ์—…๋กœ๋“œ๋œ ๊ฒฝ๋กœ (DB์— ์ €์žฅํ•  URL ๋˜๋Š” ๊ฒฝ๋กœ) + */ + String upload(Path localDir, String destinationPrefix); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java new file mode 100644 index 0000000..1178e76 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java @@ -0,0 +1,29 @@ +package com.ott.transcoder.transcode; + +import com.ott.domain.video_profile.domain.Resolution; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg ์‹คํ–‰ ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค + * + * FFmpeg๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ์‹(ProcessBuilder, Jaffree ๋“ฑ)์— ๋…๋ฆฝ์ ์œผ๋กœ + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•œ HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. + * + * ๊ตฌํ˜„์ฒด ์ „ํ™˜: transcoder.ffmpeg.engine ํ”„๋กœํผํ‹ฐ๋กœ ์„ ํƒ + * - processbuilder: ProcessBuilderFfmpegExecutor (CLI ์ง์ ‘ ํ˜ธ์ถœ) + * - jaffree: (ํ–ฅํ›„) JaffreeFfmpegExecutor (๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ˜ธ์ถœ) + */ +public interface FfmpegExecutor { + + /** + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•ด HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. + * + * @param inputFile ์›๋ณธ ์˜์ƒ ํŒŒ์ผ ๊ฒฝ๋กœ + * @param outputDir ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ (ํ•˜์œ„์— 360p/, 720p/, 1080p/ ํด๋”๊ฐ€ ์ƒ์„ฑ๋จ) + * @param resolution ๋Œ€์ƒ ํ•ด์ƒ๋„ + * @return ์ƒ์„ฑ๋œ ๋ฏธ๋””์–ด ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ(media.m3u8) ๊ฒฝ๋กœ + */ + Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 0000000..4ef1dc5 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,117 @@ +package com.ott.transcoder.transcode.processbuilder; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.transcode.FfmpegExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * ProcessBuilder ๊ธฐ๋ฐ˜ FFmpeg CLI ๋ž˜ํผ + * + * ์‹œ์Šคํ…œ์— ์„ค์น˜๋œ FFmpeg ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ProcessBuilder๋กœ ์ง์ ‘ ํ˜ธ์ถœ + * ๋‹จ์ผ ํ•ด์ƒ๋„์— ๋Œ€ํ•ด HLS ํŠธ๋žœ์Šค์ฝ”๋”ฉ์„ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, + * ๊ฒฐ๊ณผ๋ฌผ๋กœ media.m3u8 (๋ฏธ๋””์–ด ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ) + segment_XXX.ts (์„ธ๊ทธ๋จผํŠธ ํŒŒ์ผ)๋ฅผ ์ƒ์„ฑ + * + * FFmpeg ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ ํ๋ฆ„: + * Demux(์ปจํ…Œ์ด๋„ˆ ๋ถ„๋ฆฌ) โ†’ Decode(๋””์ฝ”๋”ฉ) โ†’ Filter(์Šค์ผ€์ผ๋ง) โ†’ Encode(์žฌ์ธ์ฝ”๋”ฉ) โ†’ Mux(HLS ํŒจํ‚ค์ง•) + * ์ด ์ „์ฒด๊ฐ€ ํ•˜๋‚˜์˜ FFmpeg ๋ช…๋ น์–ด๋กœ ์‹คํ–‰๋จ + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + /** ํ•ด์ƒ๋„๋ณ„ ์ถœ๋ ฅ ๋†’์ด (๋„ˆ๋น„๋Š” -2๋กœ ์ž๋™ ๊ณ„์‚ฐ, ์ง์ˆ˜ ๋ณด์žฅ) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** ํ•ด์ƒ๋„๋ณ„ ๋น„๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ */ + private static final Map VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, "800k", + Resolution.P720, "2400k", + Resolution.P1080, "4800k" + ); + + /** ํ•ด์ƒ๋„๋ณ„ ์˜ค๋””์˜ค ๋น„ํŠธ๋ ˆ์ดํŠธ */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + /** HLS ์„ธ๊ทธ๋จผํŠธ ํ•˜๋‚˜์˜ ๊ธธ์ด (์ดˆ) */ + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { + int height = HEIGHT_MAP.get(resolution); + String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + + // ํ•ด์ƒ๋„๋ณ„ ํ•˜์œ„ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ (์˜ˆ: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg ๋ช…๋ น์–ด ์กฐ๋ฆฝ + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + height, + "-c:v", "libx264", "-preset", "fast", + "-c:a", "aac", "-b:a", audioBitrate, + "-b:v", videoBitrate, + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg ์‹คํ–‰ - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + // FFmpeg ์ถœ๋ ฅ์„ ์ฝ์–ด์•ผ ํ”„๋กœ์„ธ์Šค๊ฐ€ ๋ธ”๋กœํ‚น๋˜์ง€ ์•Š๋Š”๋‹ค (๋ฒ„ํผ ๊ฐ€๋“ ์ฐธ ๋ฐฉ์ง€) + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg ํƒ€์ž„์•„์›ƒ - resolution: " + resolution.getKey()); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg ์‹คํŒจ - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); + } + + log.info("FFmpeg ์™„๋ฃŒ - resolution: {}, output: {}", resolution.getKey(), playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 0b2640b..c34b772 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -4,10 +4,14 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} + # flyway ์„ค์ • + flyway: + enabled: false + # JPA ์„ค์ • jpa: database-platform: org.hibernate.dialect.MySQL8Dialect @@ -21,3 +25,35 @@ spring: hibernate: show_sql: true format_sql: true + + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + +transcoder: + messaging: + provider: ${TRANSCODER_MESSAGING_PROVIDER} + ffmpeg: + engine: ${TRANSCODER_FFMPEG_ENGINE} + path: ${FFMPEG_PATH} + temp-dir: ${TRANSCODER_TEMP_DIR} + segment-duration: ${TRANSCODER_SEGMENT_DURATION} + ffprobe: + engine: ${TRANSCODER_FFPROBE_ENGINE} + path: ${FFPROBE_PATH} + +storage: + provider: ${STORAGE_PROVIDER} + local: + output-dir: ${STORAGE_OUTPUT_DIR} +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true \ No newline at end of file diff --git a/coderabbit/coderabbit-guidelines.md b/coderabbit/coderabbit-guidelines.md new file mode 100644 index 0000000..b2bc8e2 --- /dev/null +++ b/coderabbit/coderabbit-guidelines.md @@ -0,0 +1,412 @@ +# CodeRabbit Review Guidelines (Backend) + +์ด ๋ฌธ์„œ๋Š” PR ์ฝ”๋“œ๋ฆฌ๋ทฐ์—์„œ CodeRabbit์ด ์ฐธ๊ณ ํ•  ํ”„๋กœ์ ํŠธ ์ฝ”๋”ฉ ๊ทœ์น™์ด๋‹ค. +CodeRabbit ์‚ฌ์ดํŠธ์˜ **Repository Settings > Review Instructions** ์— ์•„๋ž˜ ๋‚ด์šฉ์„ ๋ถ™์—ฌ๋„ฃ๋Š”๋‹ค. + +--- + +## 1) ๋ฆฌ๋ทฐ ์šฐ์„ ์ˆœ์œ„ + +- **P0 (๋จธ์ง€ ์ฐจ๋‹จ)**: ์ปดํŒŒ์ผ ์‹คํŒจ, ๋ถ€ํŒ… ์‹คํŒจ, ์ธ๊ฐ€ ๋ˆ„๋ฝ, ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ํ›ผ์†, SQL injection/XSS ๋“ฑ ๋ณด์•ˆ ์ทจ์•ฝ์  +- **P1 (๋จธ์ง€ ์ „ ๋ณด์™„ ๊ถŒ์žฅ)**: API ๊ณ„์•ฝ ๋ถˆ์ผ์น˜, ํŽ˜์ด์ง•/์กฐํšŒ ํ’ˆ์งˆ, ์ƒํƒœ ์ „์ด ๋ˆ„๋ฝ, N+1 ์ฟผ๋ฆฌ +- **P2 (ํ’ˆ์งˆ ๊ฐœ์„ )**: ์ค‘๋ณต ์ œ๊ฑฐ, ํ…Œ์ŠคํŠธ ๊ฐ€๋…์„ฑ, ๋ถˆํ•„์š”ํ•œ import + +--- + +## 2) ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ๋ฐ ๋ชจ๋“ˆ ๊ฒฝ๊ณ„ + +### ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ ๊ตฌ์„ฑ +``` +backend/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ api-user/ # ์‚ฌ์šฉ์ž API (ํฌํŠธ 8080, Flyway ์‹คํ–‰ ์ฃผ์ฒด) +โ”‚ โ”œโ”€โ”€ api-admin/ # ๊ด€๋ฆฌ์ž API (ํฌํŠธ 8081) +โ”‚ โ””โ”€โ”€ transcoder/ # ํŠธ๋žœ์Šค์ฝ”๋” (ํฌํŠธ 8082) +โ”œโ”€โ”€ modules/ +โ”‚ โ”œโ”€โ”€ domain/ # JPA ์—”ํ‹ฐํ‹ฐ + Repository + enum +โ”‚ โ”œโ”€โ”€ infra/ # Flyway ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜, S3, ์™ธ๋ถ€ ์—ฐ๋™ +โ”‚ โ”œโ”€โ”€ common-web/ # ์‘๋‹ต(SuccessResponse/PageResponse), ์˜ˆ์™ธ, Swagger ์„ค์ • +โ”‚ โ””โ”€โ”€ common-security/# JWT, Security ํ•„ํ„ฐ +``` + +### ์˜์กด ๊ทœ์น™ +- `apps/*` โ†’ `common-web`, `common-security`, `infra`, `domain` ์˜์กด ๊ฐ€๋Šฅ. +- `common-web`์— DB(JPA) ์˜์กด์„ฑ ์ถ”๊ฐ€ ๊ธˆ์ง€. +- `domain` ๋ชจ๋“ˆ์€ ์›น ๋ชจ๋“ˆ์— ์˜์กดํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ์•ฑ ๋‚ด๋ถ€์— ๊ณตํ†ต DTO/์œ ํ‹ธ ์ค‘๋ณต ์ƒ์„ฑ ๊ธˆ์ง€ โ€” `common-web`์— ์ด๋ฏธ ์žˆ๋Š” ๊ฒƒ์„ ์‚ฌ์šฉํ•œ๋‹ค. + +### ์•ฑ ๋‚ด๋ถ€ ํŒจํ‚ค์ง€ ๊ตฌ์กฐ +``` +com.ott.{app-name}/{๋„๋ฉ”์ธ}/ +โ”œโ”€โ”€ controller/ # @RestController (implements XxxApi) +โ”œโ”€โ”€ service/ # ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +โ”œโ”€โ”€ mapper/ # DTO ๋ณ€ํ™˜ (@Component) +โ””โ”€โ”€ dto/ + โ”œโ”€โ”€ request/ + โ””โ”€โ”€ response/ +``` + +### ๋„๋ฉ”์ธ ๋ชจ๋“ˆ ํŒจํ‚ค์ง€ ๊ตฌ์กฐ +``` +com.ott.domain.{๋„๋ฉ”์ธ๋ช…}/ +โ”œโ”€โ”€ domain/ # @Entity +โ””โ”€โ”€ repository/ # JpaRepository + Custom + Impl +``` + +--- + +## 3) Controller ๊ทœ์น™ + +### API ์ธํ„ฐํŽ˜์ด์Šค ๋ถ„๋ฆฌ ํŒจํ„ด +๋ชจ๋“  ์ปจํŠธ๋กค๋Ÿฌ๋Š” **API ์ธํ„ฐํŽ˜์ด์Šค + ๊ตฌํ˜„ ์ปจํŠธ๋กค๋Ÿฌ**๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- `XxxApi.java` (interface): Swagger ์–ด๋…ธํ…Œ์ด์…˜(`@Tag`, `@Operation`, `@ApiResponses`, `@Parameter`) ์ „๋‹ด. +- `XxxController.java` (class): `implements XxxApi`, Spring MVC ์–ด๋…ธํ…Œ์ด์…˜(`@GetMapping` ๋“ฑ)๊ณผ ์„œ๋น„์Šค ํ˜ธ์ถœ๋งŒ ๋‹ด๋‹น. + +```java +// API ์ธํ„ฐํŽ˜์ด์Šค +@Tag(name = "BackOffice Series API", description = "[๋ฐฑ์˜คํ”ผ์Šค] ์‹œ๋ฆฌ์ฆˆ ๊ด€๋ฆฌ API") +public interface BackOfficeSeriesApi { + @Operation(summary = "์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ") + @ApiResponses(...) + ResponseEntity>> getSeries(...); +} + +// ์ปจํŠธ๋กค๋Ÿฌ ๊ตฌํ˜„ +@RestController +@RequestMapping("/back-office") +@RequiredArgsConstructor +public class BackOfficeSeriesController implements BackOfficeSeriesApi { + @Override + @GetMapping("/admin/series") + public ResponseEntity>> getSeries(...) { + return ResponseEntity.ok(SuccessResponse.of(service.getSeries(...))); + } +} +``` + +### Controller ์ฃผ์˜์‚ฌํ•ญ +- Controller์—์„œ ์ง์ ‘ `BusinessException` throw ๊ธˆ์ง€ โ€” ๊ฒ€์ฆ ์–ด๋…ธํ…Œ์ด์…˜(`@Valid`, `@NotBlank` ๋“ฑ) ์‚ฌ์šฉ. +- ์—”ํ‹ฐํ‹ฐ๋ฅผ Response๋กœ ์ง์ ‘ ๋ฐ˜ํ™˜ ๊ธˆ์ง€ โ€” ๋ฐ˜๋“œ์‹œ DTO ๋ณ€ํ™˜. +- `@RequestParam`์— `value` ๋ช…์‹œ: `@RequestParam(value = "page", defaultValue = "0")`. +- `@PathVariable`์— ์ด๋ฆ„ ๋ช…์‹œ: `@PathVariable("memberId")`. +- ํŽ˜์ด์ง€/์‚ฌ์ด์ฆˆ ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” `Integer` (boxed type) ์‚ฌ์šฉ. + +### ์‘๋‹ต ๋ž˜ํ•‘ ํŒจํ„ด +| ์ƒํ™ฉ | ํŒจํ„ด | +|------|------| +| ์กฐํšŒ (๋‹จ๊ฑด/์ผ๋ฐ˜) | `ResponseEntity.ok(SuccessResponse.of(data))` | +| ์กฐํšŒ (ํŽ˜์ด์ง•) | `ResponseEntity.ok(SuccessResponse.of(PageResponse))` | +| ๋ณ€๊ฒฝ (๋ฐ˜ํ™˜๊ฐ’ ์žˆ์Œ) | `SuccessResponse.of(data).asHttp(HttpStatus.OK)` | +| ๋ณ€๊ฒฝ (๋ฐ˜ํ™˜๊ฐ’ ์—†์Œ) | `ResponseEntity.noContent().build()` (204) | + +--- + +## 4) Service ๊ทœ์น™ + +### ํŠธ๋žœ์žญ์…˜ ์–ด๋…ธํ…Œ์ด์…˜ +- ์กฐํšŒ ๋ฉ”์„œ๋“œ: `@Transactional(readOnly = true)` +- ๋ณ€๊ฒฝ ๋ฉ”์„œ๋“œ: `@Transactional` +- ๋ฉ”์„œ๋“œ ๋‹จ์œ„๋กœ ๋ช…์‹œํ•˜๋Š” ๊ฒƒ์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•œ๋‹ค. + +### ์˜ˆ์™ธ ์ฒ˜๋ฆฌ +- Service์—์„œ `BusinessException(ErrorCode.XXX)` throw. +- ์‹œ์Šคํ…œ ์˜ˆ์™ธ๋ฅผ `BusinessException`์œผ๋กœ ๋ž˜ํ•‘ํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” `GlobalExceptionHandler`๊ฐ€ ์ฒ˜๋ฆฌ. +- `orElseThrow(() -> new BusinessException(ErrorCode.XXX))` ํŒจํ„ด ์‚ฌ์šฉ. + +### ํŽ˜์ด์ง• ํŒจํ„ด (3๋‹จ๊ณ„) +```java +// 1. Pageable ์ƒ์„ฑ +Pageable pageable = PageRequest.of(page, size); + +// 2. ์กฐํšŒ +Page mediaPage = repository.findXxx(pageable, ...); + +// 3. DTO ๋ณ€ํ™˜ + PageResponse ์ƒ์„ฑ +List responseList = mediaPage.getContent().stream() + .map(entity -> mapper.toXxxResponse(entity, ...)) + .toList(); +PageInfo pageInfo = PageInfo.toPageInfo(mediaPage.getNumber(), mediaPage.getTotalPages(), mediaPage.getSize()); +return PageResponse.toPageResponse(pageInfo, responseList); +``` + +### Service โ†” Mapper ์—ญํ•  ๋ถ„๋ฆฌ +- **Service**: ๋ฐ์ดํ„ฐ ์กฐํšŒ, ์ถ”์ถœ (์˜ˆ: `media.getUploader().getNickname()`), ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ฒ˜๋ฆฌ. +- **Mapper**: ์ˆœ์ˆ˜ DTO ๋ณ€ํ™˜๋งŒ ๋‹ด๋‹น. Service์—์„œ ์ถ”์ถœํ•œ ๊ฐ’์„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›๋Š”๋‹ค. + +--- + +## 5) DTO ๊ทœ์น™ + +### Response DTO +- **`record`** ์‚ฌ์šฉ. +- ํด๋ž˜์Šค ๋ ˆ๋ฒจ `@Schema(description = "...")` ํ•„์ˆ˜. +- ๋ชจ๋“  ํ•„๋“œ์— `@Schema(type = "...", description = "...", example = "...")` ํ•„์ˆ˜. +- `type`์€ ๋ฌธ์ž์—ด๋กœ ๋ช…์‹œ: `"Long"`, `"String"`, `"List"` ๋“ฑ. + +```java +@Schema(description = "์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต") +public record SeriesListResponse( + @Schema(type = "Long", description = "๋ฏธ๋””์–ด ID", example = "1") + Long mediaId, + @Schema(type = "List", description = "ํƒœ๊ทธ ์ด๋ฆ„ ๋ชฉ๋ก", example = "[\"์Šค๋ฆด๋Ÿฌ\"]") + List tagNameList +) {} +``` + +### Request DTO +- ๋‹จ์ˆœ ์š”์ฒญ: `record` + ํ•„๋“œ์— ๊ฒ€์ฆ ์–ด๋…ธํ…Œ์ด์…˜. +- ๋ณต์žกํ•œ ์š”์ฒญ (๋‹ค์ˆ˜ ํ•„๋“œ, ์ค‘์ฒฉ ๊ฒ€์ฆ): `class` + `@Getter @NoArgsConstructor` + ํ•„๋“œ์— ๊ฒ€์ฆ ์–ด๋…ธํ…Œ์ด์…˜. +- `@Valid @RequestBody`๋กœ ๋ฐ”์ธ๋”ฉ. + +### ๊ณตํ†ต +- ๋‚ด๋ถ€ ์ „๋‹ฌ์šฉ DTO: `class` + `@Getter @AllArgsConstructor` (Swagger ์–ด๋…ธํ…Œ์ด์…˜ ๋ถˆํ•„์š”). + +--- + +## 6) Entity ๊ทœ์น™ + +### ์–ด๋…ธํ…Œ์ด์…˜ ์ˆœ์„œ (๊ณ ์ •) +```java +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "snake_case_table_name") +public class MyEntity extends BaseEntity { ... } +``` + +### ํ•„์ˆ˜ ๊ทœ์น™ +- ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ๋Š” `BaseEntity` ์ƒ์† (์ œ๊ณต: `createdDate`, `modifiedDate`, `status`). +- `@Setter` ์‚ฌ์šฉ ๊ธˆ์ง€ โ€” ๋ณ€๊ฒฝ์€ ๋ช…์‹œ์  ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”์„œ๋“œ๋กœ๋งŒ (`updateXxx()`, `changeXxx()`, `clearXxx()`). +- ์ƒ์„ฑ์€ `static` ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ๋˜๋Š” `@Builder`. +- ์—ฐ๊ด€๊ด€๊ณ„๋Š” ๋ฐ˜๋“œ์‹œ `FetchType.LAZY`. +- Enum ๋งคํ•‘์€ ๋ฐ˜๋“œ์‹œ `@Enumerated(EnumType.STRING)` (ORDINAL ๊ธˆ์ง€). +- `@Column(name = "snake_case")` ๋ช…์‹œ. +- URL ํ•„๋“œ: `columnDefinition = "TEXT"`. + +### ๋„๋ฉ”์ธ ๋กœ์ง ์œ„์น˜ +- ์ƒํƒœ ์ „์ด ๊ฒ€์ฆ, ๋ถˆ๋ณ€์กฐ๊ฑด ๋“ฑ์€ ์—”ํ‹ฐํ‹ฐ ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ์—์„œ ์ฒ˜๋ฆฌ. +```java +public void changeRole(Role targetRole) { + if (!this.role.canTransitionTo(targetRole)) + throw new IllegalArgumentException("Invalid role transition"); + this.role = targetRole; +} +``` + +--- + +## 7) Repository ๊ทœ์น™ + +### 3ํŒŒ์ผ ๊ตฌ์กฐ (Custom Repository) +๋™์  ์ฟผ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ ๋„๋ฉ”์ธ์€ ๋ฐ˜๋“œ์‹œ ์•„๋ž˜ ๊ตฌ์กฐ: +``` +XxxRepository.java โ†’ extends JpaRepository, XxxRepositoryCustom +XxxRepositoryCustom.java โ†’ interface (์ปค์Šคํ…€ ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜) +XxxRepositoryImpl.java โ†’ implements XxxRepositoryCustom (QueryDSL ๊ตฌํ˜„) +``` + +### QueryDSL ์‚ฌ์šฉ ์›์น™ +- **JPQL ์‚ฌ์šฉ ๊ธˆ์ง€** โ€” ๋ชจ๋“  ์ปค์Šคํ…€ ์ฟผ๋ฆฌ๋Š” QueryDSL๋กœ ์ž‘์„ฑ. +- Q-class๋Š” `static import`๋กœ ์‚ฌ์šฉ: `import static com.ott.domain.xxx.domain.QXxx.xxx;` +- `@RequiredArgsConstructor` + `JPAQueryFactory` ์ฃผ์ž…. + +### ํŽ˜์ด์ง• ์ฟผ๋ฆฌ ํŒจํ„ด +```java +List resultList = queryFactory + .selectFrom(entity) + .where(์กฐ๊ฑด๋“ค...) + .orderBy(entity.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + +JPAQuery countQuery = queryFactory + .select(entity.count()) + .from(entity) + .where(์กฐ๊ฑด๋“ค...); + +return PageableExecutionUtils.getPage(resultList, pageable, countQuery::fetchOne); +``` + +### ๋™์  ์กฐ๊ฑด ํ—ฌํผ (null-safe BooleanExpression) +ํ•„ํ„ฐ๊ฐ€ ์—†์„ ๋•Œ `null` ๋ฐ˜ํ™˜ โ€” QueryDSL์ด `null` ์กฐ๊ฑด์„ ๋ฌด์‹œํ•œ๋‹ค. +```java +private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; +} +``` + +### Fetch Join +- N+1 ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ ์‹œ `fetchJoin()` ์‚ฌ์šฉ. +- ๋‹จ๊ฑด ์กฐํšŒ: `.fetchOne()` + `Optional.ofNullable()` ๋ž˜ํ•‘. +- ๋ชฉ๋ก(IN์ ˆ): ๋ณ„๋„ ๋ฉ”์„œ๋“œ๋กœ ๋ถ„๋ฆฌ, `.in()` ์‚ฌ์šฉ. +- ๋‹จ๊ฑด vs ๋ชฉ๋ก์€ ๋ณ„๋„ ๋ฉ”์„œ๋“œ โ€” ๋‹จ๊ฑด์— `List.of(id)` + IN์ ˆ ์‚ฌ์šฉ ๊ธˆ์ง€. + +### ๋ฉ”์„œ๋“œ ๋„ค์ด๋ฐ +- `findWith{์กฐ์ธ๋Œ€์ƒ}By{์กฐ๊ฑด}`: `findWithMediaAndUploaderByMediaId` +- `findWith{์กฐ์ธ๋Œ€์ƒ}By{์กฐ๊ฑด}s` (๋ณต์ˆ˜): `findWithTagAndCategoryByMediaIds` + +--- + +## 8) Mapper ๊ทœ์น™ + +### ๊ตฌํ˜„ ๋ฐฉ์‹ +- MapStruct ๋ฏธ์‚ฌ์šฉ. ์ˆœ์ˆ˜ `@Component` ํด๋ž˜์Šค๋กœ ์ˆ˜๋™ ๋งคํ•‘. +- ๋ฉ”์„œ๋“œ๋ช…: `to{TargetDtoType}` (์˜ˆ: `toSeriesListResponse`, `toMemberListResponse`). +- ๋ฐ˜๋ณต๋˜๋Š” ์ถ”์ถœ ๋กœ์ง์€ `private` ํ—ฌํผ ๋ฉ”์„œ๋“œ๋กœ ๋ถ„๋ฆฌ (์˜ˆ: `extractCategoryName`, `extractTagNameList`). +- ์ŠคํŠธ๋ฆผ์—์„œ ๋ฉ”์„œ๋“œ ์ฐธ์กฐ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ: `mapper::toXxxResponse`. + +--- + +## 9) Enum ๊ทœ์น™ + +- ๋ชจ๋“  enum์€ `@AllArgsConstructor @Getter` + `key`/`value` ๋‘ ํ•„๋“œ ๊ตฌ์กฐ. +```java +@AllArgsConstructor +@Getter +public enum MediaType { + SERIES("SERIES", "์‹œ๋ฆฌ์ฆˆ"), + CONTENTS("CONTENTS", "์ฝ˜ํ…์ธ "), + SHORT_FORM("SHORT_FORM", "์ˆํผ"); + String key; + String value; +} +``` +- ์ƒํƒœ ๋“ฑ์˜ ์ „์ด๋Š” enum ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ๋„ ์ ๊ทน ํ™œ์šฉ. + +--- + +## 10) ๋„ค์ด๋ฐ ๊ทœ์น™ + +### ์ปฌ๋ ‰์…˜ ๋ณ€์ˆ˜ +- ๋ฐ˜๋“œ์‹œ `๋„๋ฉ”์ธ์˜๋ฏธ + List` ์ ‘๋ฏธ์‚ฌ: `mediaIdList`, `tagNameList`, `responseList`, `mediaTagList`. +- ๋‹จ์ˆœ ๋ณต์ˆ˜ํ˜•(`tags`, `users`, `items`) ๊ธˆ์ง€. +- DTO ํ•„๋“œ, ์„œ๋น„์Šค ๋กœ์ปฌ ๋ณ€์ˆ˜, ์‘๋‹ต ํ•„๋“œ ๋ชจ๋‘ ๋™์ผ ๊ธฐ์ค€. + +### ํด๋ž˜์Šค ๋„ค์ด๋ฐ +| ๋Œ€์ƒ | ํŒจํ„ด | ์˜ˆ์‹œ | +|------|------|------| +| ์ปจํŠธ๋กค๋Ÿฌ (์‚ฌ์šฉ์ž) | `{Domain}Controller` | `SearchController` | +| ์ปจํŠธ๋กค๋Ÿฌ (๊ด€๋ฆฌ์ž) | `BackOffice{Domain}Controller` | `BackOfficeSeriesController` | +| API ์ธํ„ฐํŽ˜์ด์Šค | `{Domain}Api` / `BackOffice{Domain}Api` | `BackOfficeSeriesApi` | +| ์„œ๋น„์Šค | `{Domain}Service` / `BackOffice{Domain}Service` | `BackOfficeSeriesService` | +| ๋งคํผ | `BackOffice{Domain}Mapper` | `BackOfficeSeriesMapper` | +| Request DTO | `{๋™์ž‘}Request` | `AdminLoginRequest`, `ChangeRoleRequest` | +| Response DTO | `{Domain}{List/Detail}Response` | `SeriesListResponse`, `SeriesDetailResponse` | + +### ๋ฉ”์„œ๋“œ ๋„ค์ด๋ฐ +| ๋ ˆ์ด์–ด | ํŒจํ„ด | ์˜ˆ์‹œ | +|--------|--------------------------------------------------|------| +| Service (์กฐํšŒ) | `get{Domain}List`, `get{Domain}Detail` | `getSeriesList`, `getSeriesDetail` | +| Service (๋ณ€๊ฒฝ) | ๋™์‚ฌ + ๋ช…์‚ฌ | `changeRole` | +| Mapper | `to{TargetDto}` | `toSeriesListResponse` | +| Entity (๋ณ€๊ฒฝ) | `update{Field}`, `change{Field}`, `clear{Field}` | `updateRefreshToken` | +| QueryDSL ํ—ฌํผ | ํ•„๋“œ๋ช… + ์กฐ๊ฑด | `titleContains`, `roleEq` | + +[//]: # (| Repository | `findWith{Joins}By{Condition}` | `findWithMediaAndUploaderByMediaId` |) ์ž„์‹œ ์ œ๊ฑฐ +--- + +## 11) Lombok ์‚ฌ์šฉ ๊ทœ์น™ + +| ์–ด๋…ธํ…Œ์ด์…˜ | ์‚ฌ์šฉ ์œ„์น˜ | ๋น„๊ณ  | +|-----------|----------|------| +| `@Getter` | ์—”ํ‹ฐํ‹ฐ, class ๊ธฐ๋ฐ˜ DTO | record์—๋Š” ๋ถˆํ•„์š” | +| `@NoArgsConstructor(PROTECTED)` | ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ | JPA ํ”„๋ก์‹œ ์šฉ | +| `@AllArgsConstructor(PRIVATE)` | ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ | Builder ๊ฐ•์ œ | +| `@Builder` | ์—”ํ‹ฐํ‹ฐ | ์ƒ์„ฑ ์ „์šฉ | +| `@RequiredArgsConstructor` | Controller, Service, Mapper, Impl | ์ƒ์„ฑ์ž ์ฃผ์ž… | +| `@Slf4j` | ExceptionHandler, Security ํ•ธ๋“ค๋Ÿฌ | | +| `@Setter` | **์‚ฌ์šฉ ๊ธˆ์ง€** | ์–ด๋””์—์„œ๋“  ๊ธˆ์ง€ | + +- `@Autowired` ํ•„๋“œ ์ฃผ์ž… ๊ธˆ์ง€ โ€” `@RequiredArgsConstructor` + `private final` ์‚ฌ์šฉ. + +--- + +## 12) ๊ถŒํ•œ/๋ณด์•ˆ + +- Spring Security: `STATELESS` ์„ธ์…˜ + JWT ํ•„ํ„ฐ. +- ADMIN ์ „์šฉ ๋ฐฑ์˜คํ”ผ์Šค API์— `permitAll` ๊ธˆ์ง€. +- `@Bean SecurityFilterChain`์—์„œ URL๋ณ„ ์ธ๊ฐ€ ์„ค์ •. +- `permitAll` ํ—ˆ์šฉ ๋Œ€์ƒ: `/actuator/**`, `/swagger-ui/**`, `/v3/api-docs/**` ๋“ฑ ์ธํ”„๋ผ ์—”๋“œํฌ์ธํŠธ๋งŒ. +- EDITOR ์ œํ•œ: ๋กฑํผ ์—…๋กœ๋“œ, ์‹œ๋ฆฌ์ฆˆ ๊ด€๋ฆฌ, ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ, ๋Œ€์‹œ๋ณด๋“œ ์ ‘๊ทผ ๋ถˆ๊ฐ€. + +--- + +## 13) DB/Flyway ๊ทœ์น™ + +- ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฒฝ๋กœ: `modules/infra/src/main/resources/db/migration` +- ํŒŒ์ผ๋ช…: `V{version}__{description}.sql` +- ์šด์˜ ๋ชจ๋“œ: `ddl-auto: validate` (Hibernate๊ฐ€ ์Šคํ‚ค๋งˆ ์ž๋™ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Œ) +- Flyway ์‹คํ–‰ ์ฃผ์ฒด: `api-user` ์•ฑ๋งŒ. +- ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ PR์€ ๋ฐ˜๋“œ์‹œ SQL + ์—”ํ‹ฐํ‹ฐ + ์˜ํ–ฅ ๋ฒ”์œ„๋ฅผ ํ•จ๊ป˜ ๋ฆฌ๋ทฐ. + +--- + +## 14) Media ํ…Œ์ด๋ธ” ๊ณ„์ธต (V2 ๊ตฌ์กฐ) + +- `media` ํ…Œ์ด๋ธ”์ด ๊ณตํ†ต ๋ถ€๋ชจ (Class Table Inheritance, ๋น„์‹๋ณ„ 1:1). +- `series`, `contents`, `short_form`์€ ๊ฐ์ž ์ž์ฒด PK + `media_id` UNIQUE FK. +- ๊ณตํ†ต ํ•„๋“œ(`title`, `description`, `posterUrl`, `thumbnailUrl`, `publicStatus`, `bookmarkCount`, `likesCount`, `uploader`)๋Š” ๋ฐ˜๋“œ์‹œ `Media` ๊ฒฝ์œ  ์ ‘๊ทผ. + - `series.getMedia().getTitle()` (O) + - `series.getTitle()` (X โ€” ํ•„๋“œ ์—†์Œ) +- ํƒœ๊ทธ: `media_tag` ํ…Œ์ด๋ธ” ์ค‘์‹ฌ. ๊ตฌ `series_tag`/`contents_tag` ์‚ฌ์šฉ ๊ธˆ์ง€. +- `bookmark`/`likes`: `media_id` FK๋กœ ํ†ตํ•ฉ. ๊ตฌ `target_id + target_type` ํŒจํ„ด ์‚ฌ์šฉ ๊ธˆ์ง€. +- `ingest_job`: `media_id` FK ์‚ฌ์šฉ. ๊ตฌ `contents_id + short_form_id` ํŒจํ„ด ์‚ฌ์šฉ ๊ธˆ์ง€. + +--- + +## 15) ErrorCode ์ฒด๊ณ„ + +| ์ ‘๋‘์‚ฌ | ์นดํ…Œ๊ณ ๋ฆฌ | ์˜ˆ์‹œ | +|--------|----------|------| +| C0XX | Common | `INVALID_INPUT`, `MISSING_PARAMETER` | +| A0XX | Auth | `UNAUTHORIZED`, `INVALID_TOKEN`, `EXPIRED_TOKEN` | +| U0XX | User | `USER_NOT_FOUND`, `DUPLICATE_EMAIL` | +| B0XX | Business | `CONTENT_NOT_FOUND`, `SERIES_NOT_FOUND` | + +- ์ƒˆ ์—๋Ÿฌ ์ฝ”๋“œ: `modules/common-web/.../exception/ErrorCode.java`์— ์ถ”๊ฐ€. + +--- + +## 16) ๋ฆฌ๋ทฐ ์ฝ”๋ฉ˜ํŠธ ํ˜•์‹ + +CodeRabbit์ด ์ฝ”๋ฉ˜ํŠธ๋ฅผ ๋‚จ๊ธธ ๋•Œ ์•„๋ž˜ 3๊ฐ€์ง€๋ฅผ ํฌํ•จ: +- **์‹ฌ๊ฐ๋„**: P0 / P1 / P2 +- **๊ทผ๊ฑฐ**: ํŒŒ์ผ๋ช…:๋ผ์ธ๋ฒˆํ˜ธ +- **์ˆ˜์ • ์ œ์•ˆ**: ๊ตฌ์ฒด์ ์ธ ์ฝ”๋“œ ๋˜๋Š” ๊ตฌ์กฐ ๋ณ€๊ฒฝ์•ˆ + +--- + +## 17) ์ตœ์†Œ ๊ฒ€์ฆ ๊ธฐ์ค€ + +- `./gradlew clean build -x test` โ€” ์ปดํŒŒ์ผ ์„ฑ๊ณต +- `./gradlew test` โ€” ํ…Œ์ŠคํŠธ ํ†ต๊ณผ +- DB ๋ณ€๊ฒฝ ํฌํ•จ PR: Flyway ์ ์šฉ ํ›„ ๋ถ€ํŒ… ๊ฒ€์ฆ + +--- + +## ๋ถ€๋ก: ํŒ€ ๋‚ด ํ™•์ • ์‚ฌํ•ญ + +์•„๋ž˜ ํ•ญ๋ชฉ์€ ํ˜„์žฌ ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ ์ผ๊ด€๋˜์ง€ ์•Š๊ฑฐ๋‚˜, ๋ช…์‹œ์ ์œผ๋กœ ํ™•์ •๋˜์ง€ ์•Š์€ ๋ถ€๋ถ„์ด๋‹ค. ํŒ€ ๋‚ด ๋…ผ์˜ ํ›„ ๊ฒฐ์ •ํ•˜๋ฉด ์œ„ ๊ทœ์น™์— ๋ฐ˜์˜ํ•œ๋‹ค. + +### A. Response ๋ž˜ํ•‘ ์Šคํƒ€์ผ ํ†ต์ผ +ํ˜„์žฌ ๋‘ ๊ฐ€์ง€ ์Šคํƒ€์ผ์ด ํ˜ผ์šฉ๋จ: +- `ResponseEntity.ok(SuccessResponse.of(service.method()))` โ€” SeriesController, MemberController ์„ ํƒ + +### B. @Transactional ์„ ์–ธ ์œ„์น˜ +ํ˜„์žฌ ๋‘ ๊ฐ€์ง€ ์Šคํƒ€์ผ ํ˜ผ์šฉ: +- ๋ฉ”์„œ๋“œ ๋‹จ์œ„ ๋ช…์‹œ (BackOfficeSeriesService, BackOfficeMemberService) ์„ ํƒ + +### C. Request DTO โ€” record vs class ๊ธฐ์ค€ +- `record` (ChangeRoleRequest) ์„ ํƒ + +### D. Mapper์—์„œ์˜ ๋ฐ์ดํ„ฐ ์ถ”์ถœ ๋ฒ”์œ„ +Mapper ๋‚ด์—์„œ `extractCategoryName`, `extractTagNameList` ๊ฐ™์€ ๊ฐ€๊ณต ๋กœ์ง์ด ์กด์žฌํ•œ๋‹ค. +Service๊ฐ€ ์›์‹œ ๋ฐ์ดํ„ฐ๋ฅผ ๋„˜๊ธฐ๊ณ  Mapper๊ฐ€ ๊ฐ€๊ณตํ•˜๋Š” ๊ฒƒ. + +### E. ํ…Œ์ŠคํŠธ ์ „๋žต +- ์•„์ง ๋ฏธ์ •. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 600fd61..c046af3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ - services: + # ============ DB ============ mysql: image: mysql:8.0 container_name: ott-mysql @@ -10,48 +10,92 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_CHARACTER_SET_SERVER: utf8mb4 MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci - ports: - "3307:3306" volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER} -p${MYSQL_PASSWORD} --silent"] interval: 10s timeout: 5s - retries: 5 + retries: 20 - # ============ ์•ฑ ============ - api-admin: - build: # ์ด๋ฏธ์ง€๋ฅผ ๋‹ค์šด๋ฐ›๋Š”๊ฒŒ ์•„๋‹ˆ๋ผ ํ•ด๋‹น ๊ฒฝ๋กœ์—์„œ ๋นŒ๋“œ + # ============ Flyway ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ•ด๋‹น (๋จผ์ € ๊ธฐ๋™) ============ + api-user: + build: context: . - dockerfile: apps/api-admin/Dockerfile - container_name: ott-api-admin + dockerfile: apps/api-user/Dockerfile + container_name: ott-api-user ports: - - "8081:8081" + - "8080:8080" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + KAKAO_UNLINK_URL: ${KAKAO_UNLINK_URL} + KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} + FRONTEND_URL: ${FRONTEND_URL} depends_on: mysql: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s - api-user: + # ============ Flyway ์™„๋ฃŒ ์ดํ›„ ๋นŒ๋“œํ•˜๋Š” ์•ฑ ============ + api-admin: build: context: . - dockerfile: apps/api-user/Dockerfile - container_name: ott-api-user + dockerfile: apps/api-admin/Dockerfile + container_name: ott-api-admin ports: - - "8080:8080" + - "8081:8081" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy + # ============ Message Queue ============ + rabbitmq: + image: rabbitmq:3-management + container_name: ott-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "rabbitmq-diagnostics -q ping" ] + interval: 10s + timeout: 5s + retries: 12 + + # ============ Transcoder ์›Œ์ปค ============ transcoder: build: context: . @@ -63,11 +107,56 @@ services: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + + RABBITMQ_HOST: ${RABBITMQ_HOST} + RABBITMQ_PORT: ${RABBITMQ_PORT} + RABBITMQ_USERNAME: ${RABBITMQ_USERNAME} + RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD} + + TRANSCODER_MESSAGING_PROVIDER: ${TRANSCODER_MESSAGING_PROVIDER} + TRANSCODER_FFMPEG_ENGINE: ${TRANSCODER_FFMPEG_ENGINE} + FFMPEG_PATH: ${FFMPEG_PATH} + TRANSCODER_TEMP_DIR: ${TRANSCODER_TEMP_DIR} + TRANSCODER_SEGMENT_DURATION: ${TRANSCODER_SEGMENT_DURATION} + STORAGE_PROVIDER: ${STORAGE_PROVIDER} + STORAGE_OUTPUT_DIR: ${STORAGE_OUTPUT_DIR} depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy + rabbitmq: + condition: service_healthy + + # ============ Monitoring ============ + prometheus: + image: prom/prometheus:v2.54.1 + container_name: ott-prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - "9090:9090" + volumes: + - ./apps/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + + grafana: + image: grafana/grafana:12.4.0 + container_name: ott-grafana + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} + volumes: + - grafana-data:/var/lib/grafana + - ./apps/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus volumes: mysql-data: - - + prometheus-data: + grafana-data: diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep b/git similarity index 100% rename from apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep rename to git diff --git a/modules/common-security/build.gradle b/modules/common-security/build.gradle index 1d63f8f..292b489 100644 --- a/modules/common-security/build.gradle +++ b/modules/common-security/build.gradle @@ -1,7 +1,15 @@ dependencies { implementation project(':modules:domain') + implementation project(':modules:common-web') + + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' -} \ No newline at end of file +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..5c22967 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.ott.common.security.filter; + +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * ๋“ค์–ด์˜ค๋Š” ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ„์„œ ํ† ํฐ์„ ๊บผ๋‚ด์„œ provider์—๊ฒŒ ๊ฒ€์ฆ์„ ์š”์ฒญ + * ํ† ํฐ์€ ๋ณดํ†ต Authorization ํ—ค๋” or accssToken ์ฟ ํ‚ค์—์„œ ๊บผ๋ƒ„ // ํ˜„์žฌ๋Š” ์ฟ ํ‚ค์— httpOnly๋กœ ์ €์žฅ์ค‘ + * provider์—์„œ ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๊ณ  ๊ฒ€์ฆ์ด ์„ฑ๊ณตํ•˜๋ฉด Authentication๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ด์„œ SecurityContextHolder์— authentication ์ €์žฅํ•จ + * ์ดํ›„ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ authentication ๋ฐ›์•„์„œ ์‚ฌ์šฉํ•จ + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // ํ† ํฐ ๊บผ๋‚ด์˜ด + String token = resolveToken(request); + + // ํ† ํฐ์„ ๊ฒ€์ฆํ•˜์—ฌ ์ธ์ฆ ์—†์Œ, ๋งŒ๋ฃŒ๋จ, ์œ ํšจx์ผ ๊ฒฝ์šฐ ์—๋Ÿฌ์ฝ”๋“œ๋ฅผ ์ €์žฅ, ๊ฒ€์ฆ ํ†ต๊ณผ ์‹œ Authentication ์ƒ์„ฑ + if(token != null) { + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(token); + + if(errorCode == null) { + Long memberId = jwtTokenProvider.getMemberId(token); + + // auth: ["ROLE_USER"] + List authorities = jwtTokenProvider.getAuthorities(token); + + // Authentication์„ ๋งŒ๋“ฌ -> ๋ฏผ๊ฐํ•œ ์ •๋ณด ์ €์žฅ x + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberId, // principal // ์ถ”ํ›„ UserDetails๋กœ ๋ณ€๊ฒฝํ•  ์˜ˆ์ • ์•„๋งˆ๋„ + null, // credentials + authorities.stream() // grantedAuthorities + .map(SimpleGrantedAuthority::new) + .toList() + ); + // Authenication์„ SecurityContext์— ๋„ฃ์Œ + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + // ์‹คํŒจํ•  ๊ฒฝ์šฐ ํ•ด๋‹น ์—๋Ÿฌ์ฝ”๋“œ๋ฅผ reqeust์— ๋„ฃ์Œ + request.setAttribute(JwtAuthenticationEntryPoint.ERROR_CODE, errorCode); + } + } + + filterChain.doFilter(request, response); + } + + // ํ† ํฐ ๋นผ์˜ค๊ธฐ + private String resolveToken(HttpServletRequest request) { + //Authorization ํ—ค๋”์—์„œ ํ† ํฐ ๋นผ์˜ค๊ธฐ ์‹œ๋„ + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + + // ์ฟ ํ‚ค์—์„œ accessToken ๋นผ์˜ค๊ธฐ ์‹œ๋„ + if(request.getCookies() != null) { + for(Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..23f0246 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,41 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 403 ๊ถŒํ•œ ์—†์Œ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.FORBIDDEN, accessDeniedException.getMessage()); + + response.setStatus(ErrorCode.FORBIDDEN.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..6ba704d --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,45 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 401 ์ธ์ฆ ์•ˆ๋จ, ์œ ํšจํ•˜์ง€์•Š์Œ, ๋งŒ๋ฃŒ๋จ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + public static final String ERROR_CODE = "AUTH_ERROR_CODE"; + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + // Filter์—์„œ ๋ฐ›์•„์˜จ 002, 003 ์—๋Ÿฌ์ผ ๊ฒฝ์šฐ ํ•ด๋‹น ์—๋Ÿฌ ์‚ฌ์šฉ + Object attribute = request.getAttribute(ERROR_CODE); + ErrorCode errorCode = (attribute instanceof ErrorCode) ? (ErrorCode) attribute : ErrorCode.UNAUTHORIZED; + ErrorResponse errorResponse = ErrorResponse.of(errorCode, authException.getMessage()); + + response.setStatus(errorCode.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..98421fa --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java @@ -0,0 +1,101 @@ +package com.ott.common.security.jwt; + +import com.ott.common.web.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.List; + + +/** + * Access/Refresh JWT ์ƒ์„ฑ + * JWT ๊ฒ€์ฆ, JWT ํŒŒ์‹ฑ(claim ์ถ”์ถœ) + */ +@Component +public class JwtTokenProvider { + + private static final String CLAIM_AUTH = "auth"; + + private final SecretKey key; + private final long accessTokenExpiry; + private final long refreshTokenExpiry; + + + public JwtTokenProvider( + @Value("${jwt.secret}") String base64Secret, + @Value("${jwt.access-token-expiry}")long accessTokenExpiry, + @Value("${jwt.refresh-token-expiry}")long refreshTokenExpiry) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); + this.accessTokenExpiry = accessTokenExpiry; + this.refreshTokenExpiry = refreshTokenExpiry; + } + + // Access JWT ์ƒ์„ฑ + public String createAccessToken(Long memberId, List authorities) { + return createToken(memberId, authorities, accessTokenExpiry); + } + + // Refresh JWT ์ƒ์„ฑ + public String createRefreshToken(Long memberId, List authorities) { + return createToken(memberId, authorities, refreshTokenExpiry); + } + + // JWT ์ƒ์„ฑ + // header๋Š” ์ž๋™์œผ๋กœ ์ƒ๊น€ + // claim -> sub, auth, iat(issued at), exp(expiration)์ด ๋“ค์–ด๊ฐ + // signature -> Ec(header+payload) + private String createToken(Long memberId, List authorities, long expiryMillis) { + Date now = new Date(); + return Jwts.builder() + .subject(String.valueOf(memberId)) + .claim(CLAIM_AUTH, authorities) // ["ROLE_MEMBER", "ROLE_ADMIN", "ROLE_EDITOR"] + .issuedAt(now) + .expiration(new Date(now.getTime() + expiryMillis)) + .signWith(key) // ์„œ๋ช… + .compact(); + } + + // Claim ํŒŒ์‹ฑ ๋ฐ ๊ฒ€์ฆ + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) //header, payload, signature ๋ถ„๋ฆฌ ํ›„ ๋””์ฝ”๋”ฉ ํ›„ json ํŒŒ์‹ฑ + // ๊ฒ€์ฆ ์„ฑ๊ณต ์‹œ Jwt ๋ฐ˜ํ™˜ + .getPayload(); // payload๋งŒ ์ถ”์ถœ + } + + public Long getMemberId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + + // claims์ค‘์—์„œ auth๋ฅผ ๊บผ๋‚ด์™€์„œ ํ•ด๋‹น ํ† ํฐ์˜ ROLEํ™•์ธ -> ["ROLE_USER"] + @SuppressWarnings("unchecked") + public List getAuthorities(String token) { + Object auth = getClaims(token).get(CLAIM_AUTH); + if (auth == null) return List.of(); + return (List) auth; + } + + // validate ๊ฒฐ๊ณผ๋ฅผ ErrorCode๋กœ ๋ณ€ํ™˜ 002, 003์— ๋Œ€ํ•œ ์—๋Ÿฌ ์ฝ”๋“œ๋ฅผ ์•Œ์•„์•ผ๋จ + public ErrorCode validateAndGetErrorCode(String token) { + try { + getClaims(token); + return null; + } catch (ExpiredJwtException e) { + return ErrorCode.EXPIRED_TOKEN; // A003 + } catch (JwtException | IllegalArgumentException e) { + return ErrorCode.INVALID_TOKEN; // A002 + } + } + +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java new file mode 100644 index 0000000..fb8931b --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -0,0 +1,34 @@ +package com.ott.common.security.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .domain("openthetaste.cloud") // ๋กœ์ปฌ ํ…Œ์ŠคํŠธ ์‹œ ์ฃผ์„์ฒ˜๋ฆฌ!!! + .httpOnly(true) // JS ์ ‘๊ทผ ์ฐจ๋‹จ -> ํฌ๋กœ์Šค ์‚ฌ์ดํŠธ ์Šคํฌ๋ฆฝํŠธ ๊ณต๊ฒฉ ๋Œ€๋น„ + .secure(true) // HTTPS ์š”์ฒญ๋งŒ ํ—ˆ์šฉ + .path("/") // ๋ชจ๋“  ๊ฒฝ๋กœ๋กœ ์ „์†ก + .maxAge(maxAge) + .sameSite("None") // ํฌ๋กœ์Šค ์‚ฌ์ดํŠธ์— ๋Œ€ํ•ด์„œ ์ฟ ํ‚ค ์ „์†ก ํ—ˆ์šฉ + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .domain("openthetaste.cloud") // ๋กœ์ปฌ ํ…Œ์ŠคํŠธ ์‹œ ์ฃผ์„์ฒ˜๋ฆฌ!!! + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} \ No newline at end of file diff --git a/modules/common-web/build.gradle b/modules/common-web/build.gradle index 2a56d97..1a32d15 100644 --- a/modules/common-web/build.gradle +++ b/modules/common-web/build.gradle @@ -2,5 +2,7 @@ apply plugin: 'java-library' dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'io.micrometer:micrometer-registry-prometheus' api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' -} \ No newline at end of file +} diff --git a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java index 345bbcc..c0d70f7 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java +++ b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java @@ -10,14 +10,12 @@ public class WebMvcConfig implements WebMvcConfigurer { private final long MAX_AGE_SECS = 3600; -// @Value("${app.cors.allowed-origins}") -// private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") -// .allowedOrigins(allowedOrigins) - .allowedOriginPatterns("*") + // .allowedOrigins(allowedOrigins) + .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) @@ -26,6 +24,10 @@ public void addCorsMappings(CorsRegistry registry) { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/**").addResourceLocations("classpath:/static/"); + registry.addResourceHandler("/**") + .addResourceLocations( + "classpath:/static/", + "classpath:/META-INF/resources/", + "classpath:/META-INF/resources/webjars/"); } } diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 97c1dff..36b8b6e 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -22,8 +22,11 @@ public enum ErrorCode { INVALID_TYPE(HttpStatus.BAD_REQUEST, "C003", "ํƒ€์ž…์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค"), MISSING_BODY(HttpStatus.BAD_REQUEST, "C004", "์š”์ฒญ ๋ณธ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค"), JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "C005", "JSON ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค"), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "C006", "๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C007", "ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค"), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C999", "์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"), // ========== Auth (A) - ์ธ์ฆ/์ธ๊ฐ€ ========== @@ -31,14 +34,46 @@ public enum ErrorCode { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "๋งŒ๋ฃŒ๋œ ํ† ํฐ์ž…๋‹ˆ๋‹ค"), FORBIDDEN(HttpStatus.FORBIDDEN, "A004", "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"), + KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "A005", "์นด์นด์˜ค ์ธ์ฆ ์„œ๋ฒ„์— ์ ‘๊ทผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), // ========== User (U) - ์‚ฌ์šฉ์ž ========== USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค"), - // ========== Business (B) - ๋น„์ฆˆ๋‹ˆ์Šค ========== - CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "์ฝ˜ํ…์ธ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), - SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "์‹œ๋ฆฌ์ฆˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + + // ========== Business (B) - ๋น„์ฆˆ๋‹ˆ์Šค (์กฐํšŒ ์‹คํŒจ: 100๋ฒˆ๋Œ€) ========== + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B101", "์ฝ˜ํ…์ธ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B102", "์‹œ๋ฆฌ์ฆˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B103", "์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B104", "ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B105", "๋ฏธ๋””์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B106", "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B107", "๋ถ๋งˆํฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + + // ========== Business (B) - ๋น„์ฆˆ๋‹ˆ์Šค (์ •์ฑ…/์œ ํšจ์„ฑ: 200๋ฒˆ๋Œ€) ========== + SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B201", "๊ฒ€์ƒ‰์–ด๋Š” ์ตœ์†Œ 2๊ธ€์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค"), + DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B202", "ํƒœ๊ทธ ๋ชฉ๋ก์— ์ค‘๋ณต๋œ ๊ฐ’์ด ์žˆ์Šต๋‹ˆ๋‹ค"), + INVALID_TAG_SELECTION(HttpStatus.BAD_REQUEST, "B203", "์นดํ…Œ๊ณ ๋ฆฌ์— ๋งž์ง€ ์•Š๋Š” ํƒœ๊ทธ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค"), + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B204", "ํ—ˆ์šฉ๋˜์ง€ ์•Š๋Š” ์—ญํ•  ๋ณ€๊ฒฝ์ž…๋‹ˆ๋‹ค"), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B205", "๋ณธ์ธ์ด ์ž‘์„ฑํ•œ ๋Œ“๊ธ€๋งŒ ์ˆ˜์ •/์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค"), + INVALID_PLAYLIST_SOURCE(HttpStatus.BAD_REQUEST, "B206", "์žฌ์ƒ๋ชฉ๋ก ์†Œ์Šค(source)๋Š” ํ•„์ˆ˜๊ฐ’์ž…๋‹ˆ๋‹ค"), + + // ========== Business (B) - ๋น„์ฆˆ๋‹ˆ์Šค (๋ฏธ๋””์–ด/ํŒŒ์ผ ์ „์šฉ: 300๋ฒˆ๋Œ€) ========== + UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B301", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ด๋ฏธ์ง€ ํ™•์žฅ์ž์ž…๋‹ˆ๋‹ค"), + UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B302", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋™์˜์ƒ ํ™•์žฅ์ž์ž…๋‹ˆ๋‹ค"), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B303", "ํŒŒ์ผ ํ™•์žฅ์ž๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค"), + + // ========== Business (B) - ๋น„์ฆˆ๋‹ˆ์Šค (ํŠน์ˆ˜ ๋„๋ฉ”์ธ ๊ทœ์น™/ํƒœ๊ทธ/์ˆํผ: 400๋ฒˆ๋Œ€) ========== + INVALID_TAG_CATEGORY(HttpStatus.BAD_REQUEST, "B401", "์œ ํšจํ•˜์ง€ ์•Š์€ ํƒœ๊ทธ ์นดํ…Œ๊ณ ๋ฆฌ์ž…๋‹ˆ๋‹ค."), + SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B402", "์‡ผ์ธ ์˜ ์›๋ณธ ๋ฏธ๋””์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + INVALID_SHORTFORM_TARGET(HttpStatus.BAD_REQUEST, "B403", "seriesId์™€ contentsId ์ค‘ ํ•˜๋‚˜๋งŒ ์ œ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค"), + INVALID_SHORTFORM_CONTENTS_TARGET(HttpStatus.BAD_REQUEST, "B404", "์‹œ๋ฆฌ์ฆˆ์— ์†ํ•œ ์ฝ˜ํ…์ธ ๋Š” ์ˆํผ ์›๋ณธ์œผ๋กœ ์„ ํƒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + INVALID_REQUEST_FOR_SERIES_PLAYLIST(HttpStatus.BAD_REQUEST, "B405", "ํ•ด๋‹น ์ฝ˜ํ…์ธ ๋Š” ์‹œ๋ฆฌ์ฆˆ ์ „์šฉ API๋ฅผ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”"), + + // ========== Server (S) - ์„œ๋ฒ„/์‹œ์Šคํ…œ ========== + STRATEGY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "์ ์ ˆํ•œ ์žฌ์ƒ๋ชฉ๋ก ์ „๋žต์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + private final HttpStatus status; private final String code; diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java index de153a8..0de822c 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java @@ -1,7 +1,9 @@ package com.ott.common.web.exception; import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -74,10 +76,26 @@ protected ResponseEntity handleHttpRequestMethodNotSupportedExcep return ResponseEntity.status(ErrorCode.METHOD_NOT_ALLOWED.getStatus()).body(response); } + // [Exception] ๋ฉ”์†Œ๋“œ์— ์ „๋‹ฌ๋œ ์ธ์ˆ˜๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { + log.error("handleIllegalArgumentException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(Exception.class) protected ResponseEntity handleException(Exception ex) { log.error("Unhandled Exception: {}", ex.getMessage(), ex); ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_ERROR, ex.getMessage()); return ResponseEntity.status(ErrorCode.INTERNAL_ERROR.getStatus()).body(response); } + + @ExceptionHandler(ConstraintViolationException.class) + protected ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { + log.warn("ConstraintViolationException: {}", ex.getMessage()); + // C001 ์—๋Ÿฌ ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ 400 Bad Request ์‘๋‹ต ์ƒ์„ฑ + ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getMessage()); + return ResponseEntity.badRequest().body(response); + } } diff --git a/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java b/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java index 3b98f10..2448114 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java +++ b/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java @@ -8,7 +8,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; - @NoArgsConstructor(access = AccessLevel.PRIVATE) @Getter @Schema(description = "์„ฑ๊ณต Response") diff --git a/modules/domain/build.gradle b/modules/domain/build.gradle index e110483..6882920 100644 --- a/modules/domain/build.gradle +++ b/modules/domain/build.gradle @@ -2,4 +2,10 @@ apply plugin: 'java-library' dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java index 3877f8e..d7ec9b7 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java @@ -1,12 +1,9 @@ package com.ott.domain.bookmark.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Bookmark extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java new file mode 100644 index 0000000..c340ad9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java @@ -0,0 +1,34 @@ +package com.ott.domain.bookmark.repository; + +import com.ott.domain.common.MediaType; +import lombok.Getter; + +@Getter +public class BookmarkMediaProjection { + + private final Long mediaId; + private final MediaType mediaType; + private final String title; + private final String description; + private final String posterUrl; + private final Integer positionSec; // ์ฝ˜ํ…์ธ ๋งŒ, SERIES๋Š” null + private final Integer duration; // ์ฝ˜ํ…์ธ ๋งŒ, SERIES๋Š” null + + public BookmarkMediaProjection( + Long mediaId, + MediaType mediaType, + String title, + String description, + String posterUrl, + Integer positionSec, + Integer duration + ) { + this.mediaId = mediaId; + this.mediaType = mediaType; + this.title = title; + this.description = description; + this.posterUrl = posterUrl; + this.positionSec = positionSec; + this.duration = duration; + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java new file mode 100644 index 0000000..03dd07e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,53 @@ +package com.ott.domain.bookmark.repository; + +import com.ott.domain.common.MediaType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.common.Status; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface BookmarkRepository extends JpaRepository, BookmarkRepositoryCustom { + boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); + + // ์ฝ˜ํ…์ธ  ๋ถ๋งˆํฌ ๋ชฉ๋ก ์กฐํšŒ (CONTENTS, SERIES) + @EntityGraph(attributePaths = {"media"}) + Page findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc( + Long memberId, Status status, List mediaTypes, Pageable pageable); + + // ์ˆํผ ๋ถ๋งˆํฌ ๋ชฉ๋ก (SHORT_FORM) + @EntityGraph(attributePaths = {"media"}) + Page findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( + Long memberId, Status status, MediaType mediaType, Pageable pageable); + + // ํšŒ์›ํƒˆํ‡ด soft delete + @Modifying(clearAutomatically = true) + @Query("UPDATE Bookmark b SET b.status = 'DELETE' WHERE b.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + + // ํšŒ์› ํƒˆํ‡ด ์ „ ACTIVE์ธ ์œ ์ €๊ฐ€ ๋ถ๋งˆํฌ row ์ƒํƒœ ๋ณ€๊ฒฝ + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE media m + JOIN ( + SELECT b.media_id + FROM bookmark b + WHERE b.member_id = :memberId + AND b.status = 'ACTIVE' + ) t ON t.media_id = m.id + SET m.bookmark_count = GREATEST(0, m.bookmark_count - 1) + """, nativeQuery = true) + void decreaseBookmarkCountByMemberId(@Param("memberId") Long memberId); + +} diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java new file mode 100644 index 0000000..e4c998c --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.bookmark.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface BookmarkRepositoryCustom { + + // ๋ถ๋งˆํฌ ์ฝ˜ํ…์ธ /์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ (positionSec, duration ํฌํ•จ) + Page findBookmarkMediaList(Long memberId, Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java new file mode 100644 index 0000000..1fd0e3a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.ott.domain.bookmark.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +import static com.ott.domain.bookmark.domain.QBookmark.bookmark; +import static com.ott.domain.common.MediaType.CONTENTS; +import static com.ott.domain.common.MediaType.SERIES; +import static com.ott.domain.common.Status.ACTIVE; +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.playback.domain.QPlayback.playback; + +@RequiredArgsConstructor +public class BookmarkRepositoryImpl implements BookmarkRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + /** + * ๋ถ๋งˆํฌ ์ฝ˜ํ…์ธ /์‹œ๋ฆฌ์ฆˆ ๋ชฉ๋ก ์กฐํšŒ + * - CONTENTS: playback LEFT JOIN โ†’ positionSec(์—†์œผ๋ฉด 0), contents LEFT JOIN โ†’ duration + * - SERIES: positionSec = null, duration = null + */ + @Override + public Page findBookmarkMediaList(Long memberId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(BookmarkMediaProjection.class, + media.id, + media.mediaType, + media.title, + media.description, + media.posterUrl, + media.mediaType.when(CONTENTS).then(playback.positionSec.coalesce(0)).otherwise(Expressions.nullExpression(Integer.class)), // SERIES๋Š” null, CONTENTS๋งŒ playback ์—†์œผ๋ฉด 0 + contents.duration // SERIES๋ฉด null (LEFT JOIN ๋ฏธ๋งค์นญ) + )) + .from(bookmark) + .join(bookmark.media, media) + // CONTENTS ํƒ€์ž…์ผ ๋•Œ๋งŒ contents, playback ๋งค์นญ๋จ + .leftJoin(contents).on( + contents.media.id.eq(media.id) + ) + .leftJoin(playback).on( + playback.contents.id.eq(contents.id) + .and(playback.member.id.eq(memberId)) + .and(playback.status.eq(ACTIVE)) + ) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(ACTIVE), + media.mediaType.in(CONTENTS, SERIES) + ) + .orderBy(bookmark.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(bookmark.count()) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(ACTIVE), + media.mediaType.in(CONTENTS, SERIES) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java new file mode 100644 index 0000000..895d4f6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -0,0 +1,33 @@ +package com.ott.domain.category.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.ott.domain.category.domain.Category; +import com.ott.domain.common.Status; + +public interface CategoryRepository extends JpaRepository { + + Optional findByNameAndStatus(String name, Status status); + + @Query(""" + SELECT DISTINCT c.name + FROM MediaTag mt + JOIN mt.tag t + JOIN t.category c + WHERE mt.media.id = :mediaId + AND mt.status = :status + AND t.status = :status + AND c.status = :status + """) + List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); + + List findAllByStatus(Status status); + + Optional findByIdAndStatus(Long id, Status status); + + +} diff --git a/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java new file mode 100644 index 0000000..5c756b6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java @@ -0,0 +1,16 @@ +package com.ott.domain.click_event.repository; + +import com.ott.domain.click_event.domain.ClickEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + + +public interface ClickRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("UPDATE ClickEvent c SET c.status = 'DELETE' WHERE c.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + +} diff --git a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java index ef85b1d..3ce8742 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java @@ -43,4 +43,17 @@ public class Comment extends BaseEntity { @Column(name = "is_spoiler", nullable = false) private Boolean isSpoiler; + + + // ๋Œ“๊ธ€ ์ˆ˜์ • + public void update(String content, Boolean isSpoiler) { + + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("๋Œ“๊ธ€ ๋‚ด์šฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + + this.content = content; + this.isSpoiler = isSpoiler; + } } + diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..033f4a7 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -0,0 +1,38 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import java.util.Optional; +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + + @EntityGraph(attributePaths = {"member", "contents", "contents.media"}) + Optional findByIdAndStatus(Long id, Status status); + + @Query(""" + SELECT c FROM Comment c + JOIN FETCH c.member m + WHERE c.contents.id = :contentsId + AND c.status = :status + and (:includeSpoiler = true or c.isSpoiler = false) + """) + Page findByContents_IdAndStatusWithSpoilerCondition( + @Param("contentsId") Long contentsId, + @Param("status") Status status, + @Param("includeSpoiler") boolean includeSpoiler, + Pageable pageable + + ); + + // ํšŒ์› ํƒˆํ‡ด + @Modifying(clearAutomatically = true) + @Query("UPDATE Comment c SET c.status = 'DELETE' WHERE c.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..2c048fc --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CommentRepositoryCustom { + + // ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ (ํŽ˜์ด์ง•, ์ตœ์‹ ์ˆœ) + Page findMyComments(Long memberId, Status status, Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 0000000..e18fc91 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +import static com.ott.domain.comment.domain.QComment.comment; +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMyComments(Long memberId, Status status, Pageable pageable) { + List commentList = queryFactory + .selectFrom(comment) + .join(comment.member, member).fetchJoin() + .join(comment.contents, contents).fetchJoin() + .join(contents.media, media).fetchJoin() + .where( + comment.member.id.eq(memberId), + comment.status.eq(status) + ) + .orderBy(comment.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(comment.count()) + .from(comment) + .where( + comment.member.id.eq(memberId), + comment.status.eq(status) + ); + + return PageableExecutionUtils.getPage(commentList, pageable, countQuery::fetchOne); + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java similarity index 65% rename from modules/domain/src/main/java/com/ott/domain/common/TargetType.java rename to modules/domain/src/main/java/com/ott/domain/common/MediaType.java index 5b8e572..2ca4d5e 100644 --- a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java +++ b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java @@ -5,11 +5,11 @@ @AllArgsConstructor @Getter -public enum TargetType { - SHORT_FORM("SHORT_FORM", "SHORT_FORM"), +public enum MediaType { + SERIES("SERIES", "SERIES"), CONTENTS("CONTENTS", "CONTENTS"), - SERIES("SERIES", "SERIES"); + SHORT_FORM("SHORT_FORM", "SHORT_FORM"); String key; String value; -} \ No newline at end of file +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index a68a151..2f94c6e 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -1,19 +1,17 @@ package com.ott.domain.contents.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -21,6 +19,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -33,48 +33,38 @@ public class Contents extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") private Series series; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - @Column(name = "actors", nullable = false) private String actors; - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } + + public void updateMetadata(Series series, String actors, Integer duration, Integer videoSize) { + this.series = series; + this.actors = actors; + this.duration = duration; + this.videoSize = videoSize; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java new file mode 100644 index 0000000..83b0f96 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -0,0 +1,44 @@ +package com.ott.domain.contents.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; + + +public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { + + @EntityGraph(attributePaths = { "media" }) + Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, + PublicStatus publicStatus, Pageable pageable); + + // ์ข‹์•„์š” ์ฒ˜๋ฆฌ ์‹œ series ์†Œ์† ์—ฌ๋ถ€ ํ™•์ธ์šฉ + @EntityGraph(attributePaths = {"series", "series.media"}) + Optional findByMediaId(Long mediaId); + + // ๋Œ“๊ธ€ ์ž‘์„ฑ ์‹œ ์ฝ˜ํ…์ธ  ์กฐํšŒ + @EntityGraph(attributePaths = {"media"}) + Optional findByIdAndStatus(Long id, Status status); + + @Query(""" + SELECT c FROM Contents c + JOIN FETCH c.media m + WHERE m.id = :mediaId + AND c.status = :status + AND m.publicStatus = :publicStatus + """) + Optional findByMediaIdAndStatusAndMedia_PublicStatus( + @Param("mediaId") Long mediaId, + @Param("status") Status status, + @Param("publicStatus") PublicStatus publicStatus); + + boolean existsByIdAndStatus(Long id, Status status); +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java new file mode 100644 index 0000000..581ce57 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java @@ -0,0 +1,15 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; + +import java.util.List; +import java.util.Optional; + +public interface ContentsRepositoryCustom { + + Optional findWithMediaById(Long contentsId); + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java new file mode 100644 index 0000000..adaa607 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.media.domain.QMedia; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class ContentsRepositoryImpl implements ContentsRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaById(Long contentsId) { + Contents result = queryFactory + .selectFrom(contents) + .join(contents.media, media).fetchJoin() + .where(contents.id.eq(contentsId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(contents) + .where(contents.media.id.in(mediaIdList)) + .fetch(); + } + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia seriesMedia = new QMedia("seriesMedia"); + Contents result = queryFactory + .selectFrom(contents) + .join(contents.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(contents.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java b/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java deleted file mode 100644 index 85a96d7..0000000 --- a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.ott.domain.contents_tag.domain; - -import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.tag.domain.Tag; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Builder -@Getter -@Table(name = "contents_tag") -public class ContentsTag extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private Tag tag; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id", nullable = false) - private Contents contents; -} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java index 1285548..4db15b3 100644 --- a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java @@ -1,8 +1,7 @@ package com.ott.domain.ingest_job.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -33,12 +32,8 @@ public class IngestJob extends BaseEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "short_form_id") - private ShortForm shortForm; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id") - private Contents contents; + @JoinColumn(name = "media_id", nullable = false) + private Media media; @Enumerated(EnumType.STRING) @Column(name = "ingest_status", nullable = false) diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java new file mode 100644 index 0000000..966c8e9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IngestJobRepository extends JpaRepository, IngestJobRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java new file mode 100644 index 0000000..7357101 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface IngestJobRepositoryCustom { + + Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, Long uploaderId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java new file mode 100644 index 0000000..02f694e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ott.domain.ingest_job.domain.QIngestJob.ingestJob; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class IngestJobRepositoryImpl implements IngestJobRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, + Long uploaderId) { + List ingestJobList = queryFactory + .selectFrom(ingestJob) + .join(ingestJob.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .where( + titleContains(searchWord), + uploaderIdEq(uploaderId)) + .orderBy(ingestJob.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(ingestJob.count()) + .from(ingestJob) + .join(ingestJob.media, media) + .where( + titleContains(searchWord), + uploaderIdEq(uploaderId)); + + return PageableExecutionUtils.getPage(ingestJobList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java index eac7832..45dd194 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java @@ -1,12 +1,9 @@ package com.ott.domain.likes.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Likes extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java new file mode 100644 index 0000000..e70235b --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -0,0 +1,53 @@ +package com.ott.domain.likes.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.likes.domain.Likes; +import org.springframework.data.jpa.repository.Modifying; + + +import java.util.Optional; + +import org.springframework.data.domain.Pageable; + +public interface LikesRepository extends JpaRepository { + boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); + + // ์ตœ๊ทผ ์ข‹์•„์š”ํ•œ ๋ฏธ๋””์–ด์˜ ํƒœ๊ทธ ID ์กฐํšŒ + // 1๋‹จ๊ณ„ : ์ตœ๊ทผ ์ข‹์•„์š” ๋ˆ„๋ฅธ ๋ฏธ๋””์–ด 100๊ฐœ ๋จผ์ € ์กฐํšŒ (JOIN ๋ณด๋‹ค LIMIT ๋จผ์ €) + @Query(""" + SELECT l.media.id FROM Likes l + WHERE l.member.id = :memberId AND l.status = :status + ORDER BY l.createdDate DESC + """) + List findRecentLikedMediaIds(@Param("memberId") Long memberId, @Param("status") Status status, + Pageable pageable); + + // ํšŒ์› ํƒˆํ‡ด soft delete + @Modifying(clearAutomatically = true) + @Query("UPDATE Likes l SET l.status = 'DELETE' WHERE l.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + // ํšŒ์› ํƒˆํ‡ด ์‹œ ํ•ด๋‹น ์œ ์ €๊ฐ€ ์ข‹์•„์š”ํ•œ ๋ฏธ๋””์–ด์— ๋Œ€ํ•˜์—ฌ -count + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE media m + JOIN ( + SELECT l.media_id + FROM likes l + WHERE l.member_id = :memberId + AND l.status = 'ACTIVE' + ) t ON t.media_id = m.id + SET m.likes_count = GREATEST(0, m.likes_count - 1) + """, nativeQuery = true) + void decreaseLikesCountByMemberId(@Param("memberId") Long memberId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java new file mode 100644 index 0000000..10ffd22 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -0,0 +1,100 @@ +package com.ott.domain.media.domain; + +import com.ott.domain.common.BaseEntity; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "media") +public class Media extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploader_id", nullable = false) + private Member uploader; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") + private String posterUrl; + + @Column(name = "thumbnail_url", columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(name = "bookmark_count", nullable = false) + private Long bookmarkCount; + + @Column(name = "likes_count", nullable = false) + private Long likesCount; + + @Enumerated(EnumType.STRING) + @Column(name = "media_type", nullable = false) + private MediaType mediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "public_status", nullable = false) + private PublicStatus publicStatus; + + public void updateImageKeys(String posterUrl, String thumbnailUrl) { + this.posterUrl = posterUrl; + this.thumbnailUrl = thumbnailUrl; + } + + public void updateMetadata(String title, String description, PublicStatus publicStatus) { + this.title = title; + this.description = description; + this.publicStatus = publicStatus; + } + + // ๋ถ๋งˆํฌ ์ฆ๊ฐ€ ๋ฉ”์†Œ๋“œ + public void increaseBookmarkCount() { + this.bookmarkCount++; + } + + // ๋ถ๋งˆํฌ ๊ฐ์†Œ ๋ฉ”์†Œ๋“œ + public void decreaseBookmarkCount() { + if (this.bookmarkCount > 0) { + this.bookmarkCount--; + } + } + + public void increaseLikesCount() { + this.likesCount++; + } + + public void decreaseLikesCount() { + if (this.likesCount > 0) { + this.likesCount--; + } + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java new file mode 100644 index 0000000..97ffc04 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.media.domain.Media; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java new file mode 100644 index 0000000..522a5f5 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -0,0 +1,44 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.media.domain.Media; + +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + + +public interface MediaRepositoryCustom { + + Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, + String searchWord, PublicStatus publicStatus); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, + MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + + Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); + + List findRecommendContentsByTagId(Long tagId, int limit); + + // ์ธ๊ธฐ ์ฐจํŠธ ํ†ตํ•ฉ ์กฐํšŒ ๋ฉ”์„œ๋“œ + Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable); + + // ์‹œ์ฒญ ์ด๋ ฅ ์กฐํšŒ (์ตœ๊ทผ ์‹œ์ฒญ ์ˆœ) + Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); + + // ๋ถ๋งˆํฌ ๋ชฉ๋ก ์กฐํšŒ (์ตœ๊ทผ ์ฐœํ•œ ์ˆœ) + Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); + + // ํŠน์ • ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ๋ฏธ๋””์–ด ๋ชฉ๋ก ์กฐํšŒ + Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable); + + + List findMediasByTagId(Long tagId, Long excludeMediaId, int limit , long offset); + + List findRecommendedMedias(Map tagScores, Long excludeMediaId, int limit, long offset); +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java new file mode 100644 index 0000000..a702beb --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -0,0 +1,384 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.media.domain.Media; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; +import static com.ott.domain.playback.domain.QPlayback.playback; +import static com.ott.domain.contents.domain.QContents.contents; +import java.util.List; +import java.util.Map; + +import com.querydsl.jpa.JPAExpressions; + +import static com.ott.domain.common.MediaType.CONTENTS; +import static com.ott.domain.common.MediaType.SERIES; +import static com.ott.domain.common.PublicStatus.PUBLIC; +import static com.ott.domain.common.Status.ACTIVE; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.bookmark.domain.QBookmark.bookmark; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; + + + + +@RequiredArgsConstructor +public class MediaRepositoryImpl implements MediaRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, + String searchWord) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, + String searchWord, PublicStatus publicStatus) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, + MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { + BooleanExpression condition = media.mediaType.in(List.of(SERIES, CONTENTS)) + .and( + JPAExpressions.selectOne() + .from(contents) + .where( + contents.media.id.eq(media.id), + contents.series.isNotNull()) + .notExists()); + + List mediaList = queryFactory + .selectFrom(media) + .where( + condition, + titleContains(searchWord)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where(condition, titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + // ํŠน์ • ํƒœ๊ทธ๋ฅผ ๊ฐ€์ง„ ์˜์ƒ ์กฐํšŒ + @Override + public List findMediasByTagId(Long tagId, Long excludeMediaId, int limit, long offset) { + return queryFactory.selectFrom(media) + .join(mediaTag).on(mediaTag.media.id.eq(media.id)) + .where( + media.status.eq(Status.ACTIVE), + media.publicStatus.eq(PublicStatus.PUBLIC), + mediaTag.tag.id.eq(tagId), + excludeMediaId != null ? media.id.ne(excludeMediaId) : null) + .orderBy(media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } + + // ํŠน์ • ํƒœ๊ทธ์— ์†ํ•˜๋Š” ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ์กฐํšŒ + @Override + public List findRecommendContentsByTagId(Long tagId, int limit) { + return queryFactory + .select(Projections.constructor(TagContentProjection.class, + media.id, + media.posterUrl, + media.mediaType + )) + .from(media) + .join(mediaTag).on( + mediaTag.media.id.eq(media.id), + mediaTag.tag.id.eq(tagId), + mediaTag.status.eq(ACTIVE) + ) + .leftJoin(contents).on( + contents.media.id.eq(media.id), + contents.series.isNull() + ) + .where( + media.status.eq(ACTIVE), + media.publicStatus.eq(PUBLIC), + // ์‹œ๋ฆฌ์ฆˆ ์ž์ฒด OR ๋‹จํŽธ ์ฝ˜ํ…์ธ  (์‹œ๋ฆฌ์ฆˆ ์—ํ”ผ์†Œ๋“œ ์ œ์™ธ) + media.mediaType.eq(SERIES) + .or(media.mediaType.eq(CONTENTS).and(contents.id.isNotNull())) + ) + .orderBy(media.bookmarkCount.desc()) // ๋ถ๋งˆํฌ ๋งŽ์€ ์ˆœ ์ •๋ ฌ + .limit(limit) + .fetch(); + } + + + /* + * ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ์ „๋žตํŒจํ„ด ๊ด€๋ จ ๋กœ์ง + */ + + @Override + public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .selectFrom(media) + .where( + isActiveAndPublic(), // ํ™œ์„ฑ ๋ฐ ๊ณต๊ฐœ ์ƒํƒœ ํ•„ํ„ฐ๋ง + excludeId(excludeMediaId) // ํ˜„์žฌ ๋ฏธ๋””์–ด ์ œ์™ธ (null์ด๋ฉด ๋ฌด์‹œ๋จ) + ) + .orderBy(media.bookmarkCount.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + isActiveAndPublic(), + excludeId(excludeMediaId)); + + // PageableExecutionUtils๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ฒซ ํŽ˜์ด์ง€ ์กฐํšŒ ์‹œ ๋ถˆํ•„์š”ํ•œ ์นด์šดํŠธ ์ฟผ๋ฆฌ ๋ฐฉ์ง€ + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(playback) + .join(playback.contents.media, media) // ์‹œ์ฒญ ๊ธฐ๋ก๊ณผ ๋ฏธ๋””์–ด ์ •๋ณด ์กฐ์ธ + .where( + playback.member.id.eq(memberId), // ํŠน์ • ์‚ฌ์šฉ์ž ํ•„ํ„ฐ๋ง + isActiveAndPublic(), // ํ™œ์„ฑ/๊ณต๊ฐœ ์ƒํƒœ ํ™•์ธ + excludeId(excludeMediaId) // ํ˜„์žฌ ์žฌ์ƒ ์ค‘์ธ ์˜์ƒ ์ œ์™ธ + ) + .orderBy(playback.modifiedDate.desc()) // ์ตœ๊ทผ ์‹œ์ฒญ ์‹œ์  ์ˆœ ์ •๋ ฌ + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(playback.count()) + .from(playback) + .join(playback.contents.media, media) + .where( + playback.member.id.eq(memberId), + isActiveAndPublic(), + excludeId(excludeMediaId)); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + isActiveAndPublic(), + excludeId(excludeMediaId)) + .orderBy(bookmark.createdDate.desc()) // ์ตœ๊ทผ ๋ถ๋งˆํฌํ•œ ์ˆœ์„œ + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(bookmark.count()) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + isActiveAndPublic(), + excludeId(excludeMediaId)); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(mediaTag) + .join(mediaTag.media, media) + .where( + mediaTag.tag.id.eq(tagId), // ์š”์ฒญ๋œ ํƒœ๊ทธ ID ํ•„ํ„ฐ๋ง + isActiveAndPublic(), // ํ™œ์„ฑ/๊ณต๊ฐœ ์ƒํƒœ ํ™•์ธ + excludeId(excludeMediaId) // ํ˜„์žฌ ์žฌ์ƒ ์ค‘์ธ ์˜์ƒ ์ œ์™ธ + ) + .orderBy(media.createdDate.desc()) // ์ตœ์‹  ๋“ฑ๋ก ์ˆœ ์ •๋ ฌ + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(mediaTag.count()) + .from(mediaTag) + .join(mediaTag.media, media) + .where( + mediaTag.tag.id.eq(tagId), + isActiveAndPublic(), + excludeId(excludeMediaId)); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + + // PlaylistPrefereceService ์—์„œ ๋ฐ›์•„์˜จ tagScores ๋กœ ์ถ”์ฒœ ์ข…ํ•ฉ ์ฟผ๋ฆฌ + @Override + public List findRecommendedMedias(Map tagScores, Long excludeMediaId, int limit, long offset) { + // ์„ ํ˜ธํƒœ๊ทธ ์กฐ์ฐจ ๊ณ ๋ฅด์ง€ ์•Š์€ ๋ฐฑ์ง€ ์ƒํƒœ์˜ ์œ ์ € + // ์ด๋•Œ๋Š” ๊ฐ€์žฅ ์ตœ๊ทผ ์‹ ์ž‘ ๋…ธ์ถœ + if (tagScores.isEmpty()) { + return queryFactory.selectFrom(media) + .where( + isActiveAndPublic(), + excludeId(excludeMediaId)) + .orderBy(media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } + + // ๊ฐœ์ธํ™” ์ถ”์ฒœ์„ ์œ„ํ•œ ์ ์ˆ˜ ๊ณ„์‚ฐ๊ธฐ + NumberExpression scoreExpression = new CaseBuilder() + .when(mediaTag.tag.id.isNotNull()).then(0).otherwise(0); + + for (Map.Entry entry : tagScores.entrySet()) { + scoreExpression = scoreExpression.add( + new CaseBuilder() + .when(mediaTag.tag.id.eq(entry.getKey())).then(entry.getValue()) + .otherwise(0)); + } + + return queryFactory.selectFrom(media) + .join(mediaTag).on(mediaTag.media.id.eq(media.id)) + .where( + media.status.eq(Status.ACTIVE), + media.publicStatus.eq(PublicStatus.PUBLIC), + excludeMediaId != null ? media.id.ne(excludeMediaId) : null) + .groupBy(media.id) + .orderBy(scoreExpression.sum().desc(), media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } + + // --- ๋™์  ์ฟผ๋ฆฌ ํ—ฌํผ ๋ฉ”์„œ๋“œ --- + + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } + + private BooleanExpression mediaTypeEq(MediaType mediaType) { + if (mediaType != null) + return media.mediaType.eq(mediaType); + return null; + } + + private BooleanExpression publicStatusEq(PublicStatus publicStatus) { + if (publicStatus != null) + return media.publicStatus.eq(publicStatus); + return null; + } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } + + private BooleanExpression isActiveAndPublic() { + // Status.ACTIVE์™€ PublicStatus.PUBLIC ์กฐ๊ฑด์„ ๊ฒฐํ•ฉ + return media.status.eq(Status.ACTIVE) + .and(media.publicStatus.eq(PublicStatus.PUBLIC)); + } + + private BooleanExpression excludeId(Long excludeMediaId) { + // ์ „๋‹ฌ๋œ ID๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ 'ํ•ด๋‹น ID ์ œ์™ธ' ์กฐ๊ฑด์„ ์ถ”๊ฐ€, ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜ํ•˜์—ฌ ๋ฌด์‹œ + return excludeMediaId != null ? media.id.ne(excludeMediaId) : null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java new file mode 100644 index 0000000..59ccde9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java @@ -0,0 +1,18 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.common.MediaType; +import lombok.Getter; + +@Getter +public class TagContentProjection { + + private final Long mediaId; + private final String posterUrl; + private final MediaType mediaType; + + public TagContentProjection(Long mediaId, String posterUrl, MediaType mediaType) { + this.mediaId = mediaId; + this.posterUrl = posterUrl; + this.mediaType = mediaType; + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java similarity index 78% rename from modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java rename to modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java index ed492da..f6e4e28 100644 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java @@ -1,7 +1,7 @@ -package com.ott.domain.series_tag.domain; +package com.ott.domain.media_tag.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.series.domain.Series; +import com.ott.domain.media.domain.Media; import com.ott.domain.tag.domain.Tag; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -22,8 +22,8 @@ @Entity @Builder @Getter -@Table(name = "series_tag") -public class SeriesTag extends BaseEntity { +@Table(name = "media_tag") +public class MediaTag extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -34,6 +34,6 @@ public class SeriesTag extends BaseEntity { private Tag tag; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "series_id", nullable = false) - private Series series; + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java new file mode 100644 index 0000000..2ee70be --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -0,0 +1,22 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { + + // ๋ฏธ๋””์–ด์˜ ํƒœ๊ทธ ID ์กฐํšŒ (์ข‹์•„์š” & ์‹œ์ฒญ ์ด๋ ฅ์—์„œ ์‚ฌ์šฉ) + // [2๋‹จ๊ณ„] ๋ฏธ๋””์–ด์— ๋Œ€ํ•œ ํƒœ๊ทธ๋“ค์„ ์ „๋ถ€ ๊ฐ€์ ธ์˜ด + @Query(""" + SELECT mt.tag.id FROM MediaTag mt + WHERE mt.media IN :mediaIds + """) + List findTagIdsByMediaIds(@Param("mediaIds") List mediaIds); + + void deleteAllByMedia_Id(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java new file mode 100644 index 0000000..945d74b --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; + +import java.util.List; + +public interface MediaTagRepositoryCustom { + + List findWithTagAndCategoryByMediaIds(List mediaIds); + + List findWithTagAndCategoryByMediaId(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java new file mode 100644 index 0000000..d3522e3 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.tag.domain.QTag.tag; +import static com.ott.domain.category.domain.QCategory.category; + +@RequiredArgsConstructor +public class MediaTagRepositoryImpl implements MediaTagRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findWithTagAndCategoryByMediaIds(List mediaIds) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.in(mediaIds)) + .fetch(); + } + + @Override + public List findWithTagAndCategoryByMediaId(Long mediaId) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.eq(mediaId)) + .fetch(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 0c617d7..cb533ba 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -1,6 +1,7 @@ package com.ott.domain.member.domain; import com.ott.domain.common.BaseEntity; +import com.ott.domain.common.Status; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -49,4 +50,60 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + + @Column(name = "onboarding_completed", nullable = false) + private boolean onboardingCompleted; + + public static Member createKakaoMember(String providerId, String email, String nickname) { + return Member.builder() + .provider(Provider.KAKAO) + .providerId(providerId) + .email(email) + .nickname(nickname) + .role(Role.MEMBER) + .build(); + } + + public void updateKakaoProfile(String email, String nickname) { + this.email = email; + this.nickname = nickname; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void clearRefreshToken() { + this.refreshToken = null; + } + + public void changeRole(Role targetRole) { + if (!this.role.canTransitionTo(targetRole)) + throw new IllegalArgumentException("Invalid role transition: " + this.role + " -> " + targetRole); + + this.role = targetRole; + } + + // ๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + // ์˜จ๋ณด๋”ฉ ์—ฌ๋ถ€ + public void completeOnboarding() { + this.onboardingCompleted = true; + } + + // ํšŒ์› ํƒˆํ‡ด - Soft Delete (refreshToken ์ดˆ๊ธฐํ™” + status DELETE) + public void withdraw() { + this.refreshToken = null; + this.updateStatus(Status.DELETE); + } + + // ํƒˆํ‡ด(DELETE) ์ƒํƒœ์ธ ๊ฒฝ์šฐ์—๋งŒ ACTIVE๋กœ ๋ณต๊ตฌ + public void reactivate() { + if (this.getStatus() == Status.DELETE) { + this.updateStatus(Status.ACTIVE); + } + } } diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java index ca28eac..3e9e8f2 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java @@ -13,4 +13,9 @@ public enum Role { String key; String value; + + public boolean canTransitionTo(Role targetRole) { + return (this == EDITOR && targetRole == SUSPENDED) + || (this == SUSPENDED && targetRole == EDITOR); + } } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..5f58384 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -0,0 +1,20 @@ +package com.ott.domain.member.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import com.ott.domain.common.Status; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; + +public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { + + // ๊ธฐ์กด ํšŒ์›์ธ์ง€ ์‹ ๊ทœ ํšŒ์›์ธ์ง€ DB ์กฐํšŒ + Optional findByProviderAndProviderId(Provider provider, String providerId); + + // ๊ด€๋ฆฌ์ž&์—๋””ํ„ฐ์šฉ ์กฐํšŒ + Optional findByEmailAndProvider(String email, Provider provider); + + // Activeํ•œ ์œ ์ € ์กฐํšŒ + Optional findByIdAndStatus(Long memberId, Status status); + +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java new file mode 100644 index 0000000..ef6eb78 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface MemberRepositoryCustom { + + Page findMemberList(Pageable pageable, String searchWord, Role role); +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java new file mode 100644 index 0000000..5ab8faa --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java @@ -0,0 +1,58 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMemberList(Pageable pageable, String searchWord, Role role) { + List memberList = queryFactory + .selectFrom(member) + .where( + nicknameContains(searchWord), + roleEq(role) + ) + .orderBy(member.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(member.count()) + .from(member) + .where( + nicknameContains(searchWord), + roleEq(role) + ); + + return PageableExecutionUtils.getPage(memberList, pageable, countQuery::fetchOne); + } + + private BooleanExpression nicknameContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return member.nickname.contains(searchWord); + return null; + } + + private BooleanExpression roleEq(Role role) { + if (role != null) + return member.role.eq(role); + return null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java new file mode 100644 index 0000000..2a34e1a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -0,0 +1,33 @@ +package com.ott.domain.playback.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.playback.domain.Playback; +import org.springframework.data.jpa.repository.Modifying; + +public interface PlaybackRepository extends JpaRepository { + + // ํšŒ์› ํƒˆํ‡ด + @Modifying(clearAutomatically = true) + @Query("UPDATE Playback p SET p.status = 'DELETE' WHERE p.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + + // ์ตœ๊ทผ ์‹œ์ฒญํ•œ ๋ฏธ๋””์–ด์˜ ํƒœ๊ทธ ID ์กฐํšŒ + // 1๋‹จ๊ณ„ - ์ตœ๊ทผ ์‹œ์ฒญ ์ด๋ ฅ 100๊ฐœ ๊ฐ€์ ธ์˜ค๊ธฐ (JOIN ๋ณด๋‹ค LIMIT ๋จผ์ €) + @Query(""" + SELECT p.contents.media.id FROM Playback p + WHERE p.member.id = :memberId AND p.status = :status + ORDER BY p.modifiedDate DESC + """) + List findRecentPlayedMediaIds( + @Param("memberId") Long memberId, + @Param("status") Status status, + Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java new file mode 100644 index 0000000..01818ee --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -0,0 +1,50 @@ +package com.ott.domain.preferred_tag.repository; + +import com.ott.domain.common.Status; +import com.ott.domain.member.domain.Member; +import com.ott.domain.preferred_tag.domain.PreferredTag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PreferredTagRepository extends JpaRepository { + boolean existsByMemberId(Long memberId); + + @Query(""" + select pt + from PreferredTag pt + join fetch pt.tag t + join fetch t.category c + where pt.member.id = :memberId + and pt.status = :status + and t.status = :status + and c.status = :status + order by pt.id asc + """) + List findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId") Long memberId, + @Param("status") Status status); + + // ์„ ํ˜ธ ํƒœ๊ทธ ์‚ญ์ œ, ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ ๋“ค์–ด์žˆ๋Š” ๋‚ด์šฉ ์‚ญ์ œ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM PreferredTag pt WHERE pt.member = :member") + void deleteAllByMember(@Param("member") Member member); + + + // ํšŒ์› ํƒˆํ‡ด ์‹œ soft delete ์‚ฌ์šฉ + @Modifying(clearAutomatically = true) + @Query("UPDATE PreferredTag pt SET pt.status = 'DELETE' WHERE pt.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + + // ์‚ฌ์šฉ์ž์˜ ์„ ํ˜ธ ํƒœ๊ทธ ID๋งŒ ์กฐํšŒ + @Query(""" + SELECT pt.tag.id + FROM PreferredTag pt + WHERE pt.member.id = :memberId AND pt.status = :status + """) + List findTagIdsByMemberId(@Param("memberId") Long memberId, @Param("status") Status status); + +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java index 51b3511..69f48c7 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java +++ b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java @@ -1,18 +1,15 @@ package com.ott.domain.series.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -32,32 +29,14 @@ public class Series extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; - - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @Column(name = "actors", nullable = false) private String actors; - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; + public void updateActors(String actors) { + this.actors = actors; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index 6fd166d..53587ea 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -1,11 +1,30 @@ package com.ott.domain.series.repository; -import com.ott.domain.series.domain.Series; -import org.springframework.data.domain.Page; +import java.util.List; +import java.util.Optional; + import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.series.domain.Series; + +public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { + + // Optional findByIdAndStatusAndMedia_PublicStatus(Long id, Status + // status, PublicStatus publicStatus); + // @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE s.id = :id AND s.status = :status AND m.publicStatus = :publicStatus") + // Optional findByIdWithMedia(@Param("id") Long id, + // @Param("status") Status status, + // @Param("publicStatus") PublicStatus publicStatus); -public interface SeriesRepository extends JpaRepository { + @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE m.id = :mediaId AND s.status = :status AND m.publicStatus = :publicStatus") + Optional findByMediaIdAndStatusAndPublicStatus( + @Param("mediaId") Long mediaId, + @Param("status") Status status, + @Param("publicStatus") PublicStatus publicStatus); - Page findByTitleContaining(String keyword, Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java new file mode 100644 index 0000000..1e0bba0 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -0,0 +1,19 @@ +package com.ott.domain.series.repository; + +import com.ott.domain.series.domain.Series; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface SeriesRepositoryCustom { + + Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord); + + Optional findWithMediaById(Long seriesId); + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java new file mode 100644 index 0000000..df8736c --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -0,0 +1,81 @@ +package com.ott.domain.series.repository; + +import com.ott.domain.series.domain.Series; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class SeriesRepositoryImpl implements SeriesRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaById(Long seriesId) { + Series result = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .where(series.id.eq(seriesId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + Series result = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord) { + List seriesList = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .where(titleContains(searchWord)) + .orderBy(series.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(series.count()) + .from(series) + .join(series.media, media) + .where(titleContains(searchWord)); + + return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); + } + + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(series) + .where(series.media.id.in(mediaIdList)) + .fetch(); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java b/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java deleted file mode 100644 index 3eb20ca..0000000 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ott.domain.series_tag.repository; - -import com.ott.domain.series_tag.domain.SeriesTag; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface SeriesTagRepository extends JpaRepository { - - @Query("SELECT st FROM SeriesTag st " - + "JOIN FETCH st.tag t " - + "JOIN FETCH t.category " - + "WHERE st.series.id IN :seriesIds") - List findWithTagAndCategoryBySeriesIds(@Param("seriesIds") List seriesIds); -} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 1d18dba..d136c71 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -1,20 +1,18 @@ package com.ott.domain.short_form.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; import com.ott.domain.contents.domain.Contents; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -22,6 +20,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Optional; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -34,9 +35,9 @@ public class ShortForm extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") @@ -46,31 +47,33 @@ public class ShortForm extends BaseEntity { @JoinColumn(name = "contents_id") private Contents contents; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } + + public void updateMetadata(Series series, Contents contents, Integer duration, Integer videoSize) { + this.series = series; + this.contents = contents; + this.duration = duration; + this.videoSize = videoSize; + } + + public Optional findOriginMedia() { + if (series != null) return Optional.of(series.getMedia()); + if (contents != null) return Optional.of(contents.getMedia()); + return Optional.empty(); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java new file mode 100644 index 0000000..c4f1575 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ShortFormRepository extends JpaRepository, ShortFormRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java new file mode 100644 index 0000000..02c3efe --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; + +import java.util.List; +import java.util.Optional; + +public interface ShortFormRepositoryCustom { + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); + Optional findWithMediaAndUploaderByShortFormId(Long shortFormId); + + List findAllByMediaIdIn(List mediaIdList); +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java new file mode 100644 index 0000000..d6f9a6d --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.media.domain.QMedia; +import com.ott.domain.short_form.domain.ShortForm; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; +import static com.ott.domain.short_form.domain.QShortForm.shortForm; + +@RequiredArgsConstructor +public class ShortFormRepositoryImpl implements ShortFormRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia contentsMedia = new QMedia("contentsMedia"); + QMedia seriesMedia = new QMedia("seriesMedia"); + + ShortForm result = queryFactory + .selectFrom(shortForm) + .join(shortForm.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(shortForm.contents, contents).fetchJoin() + .leftJoin(contents.media, contentsMedia).fetchJoin() + .leftJoin(shortForm.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public Optional findWithMediaAndUploaderByShortFormId(Long shortFormId) { + QMedia contentsMedia = new QMedia("contentsMedia"); + QMedia seriesMedia = new QMedia("seriesMedia"); + + ShortForm result = queryFactory + .selectFrom(shortForm) + .join(shortForm.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(shortForm.contents, contents).fetchJoin() + .leftJoin(contents.media, contentsMedia).fetchJoin() + .leftJoin(shortForm.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(shortForm.id.eq(shortFormId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(shortForm) + .where(shortForm.media.id.in(mediaIdList)) + .fetch(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java new file mode 100644 index 0000000..262cc4b --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -0,0 +1,38 @@ +package com.ott.domain.tag.repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.ott.domain.category.domain.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.tag.domain.Tag; + +public interface TagRepository extends JpaRepository { + + List findAllByCategoryIdAndNameInAndStatus(Long categoryId, Set nameList, Status status); + + // ์‹œ๋ฆฌ์ฆˆ/์ฝ˜ํ…์ธ ์— ์—ฐ๊ฒฐ๋œ ํƒœ๊ทธ ์กฐํšŒ + @Query(""" + SELECT DISTINCT t.name + FROM MediaTag mt + JOIN mt.tag t + WHERE mt.media.id = :mediaId + AND t.status = :status + AND mt.status = :status + """) + List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); + + // ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ์— ์žˆ๋Š” ํƒœ๊ทธ ์กฐํšŒ + List findAllByCategoryAndStatus(Category category, Status status); + + // ACTIVE&&๋ฆฌ์ŠคํŠธ์•ˆ์— ์žˆ๋Š” ํƒœ๊ทธ ์กฐํšŒ + List findAllByIdInAndStatus(List ids, Status status); + + // ACTIVEํ•œ ํŠน์ • ํƒœ๊ทธ ์กฐํšŒ + Optional findByIdAndStatus(Long tagId, Status status); +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java new file mode 100644 index 0000000..ceee54d --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java @@ -0,0 +1,23 @@ +package com.ott.domain.watch_history.repository; + +import com.ott.domain.common.MediaType; +import lombok.Getter; + +@Getter +public class RecentWatchProjection { + + private final Long mediaId; + private final MediaType mediaType; + private final String posterUrl; + private final Integer positionSec; + private final Integer duration; + + public RecentWatchProjection(Long mediaId, MediaType mediaType, String posterUrl, Integer positionSec, Integer duration) { + this.mediaId = mediaId; + this.mediaType = mediaType; + this.posterUrl = posterUrl; + this.positionSec = positionSec; + this.duration = duration; + } + +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java new file mode 100644 index 0000000..99a9513 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java @@ -0,0 +1,17 @@ +package com.ott.domain.watch_history.repository; + +import lombok.Getter; + +@Getter +public class TagRankingProjection { + + private final Long tagId; + private final String tagName; + private final Long count; + + public TagRankingProjection(Long tagId, String tagName, Long count) { + this.tagId = tagId; + this.tagName = tagName; + this.count = count; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java new file mode 100644 index 0000000..7715e0f --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java @@ -0,0 +1,7 @@ +package com.ott.domain.watch_history.repository; + +public record TagViewCountProjection( + String tagName, + Long viewCount +) { +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java new file mode 100644 index 0000000..1e9f1a3 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java @@ -0,0 +1,15 @@ +package com.ott.domain.watch_history.repository; + +import com.ott.domain.watch_history.domain.WatchHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface WatchHistoryRepository extends JpaRepository, WatchHistoryRepositoryCustom { + + // ํšŒ์› ํƒˆํ‡ด + @Modifying(clearAutomatically = true) + @Query("UPDATE WatchHistory w SET w.status = 'DELETE' WHERE w.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java new file mode 100644 index 0000000..50a232d --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java @@ -0,0 +1,21 @@ +package com.ott.domain.watch_history.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +public interface WatchHistoryRepositoryCustom { + + List countByTagAndCategoryIdAndWatchedBetween(Long categoryId, LocalDateTime startDate, LocalDateTime endDate); + + //ํŠน์ • ํšŒ์›์˜ ์ตœ๊ทผ 1๋‹ฌ ์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ์ง‘๊ณ„ (count ๋‚ด๋ฆผ์ฐจ์ˆœ) + List findTopTagsByMemberIdAndWatchedBetween(Long memberId, LocalDateTime startDate, LocalDateTime endDate); + + // ํŠน์ • ํƒœ๊ทธ์˜ 2๋‹ฌ ์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ count ์ง‘๊ณ„ + Long countByMemberIdAndTagIdAndWatchedBetween(Long memberId, Long tagId, LocalDateTime startDate, LocalDateTime endDate); + + // ํŠน์ • ํšŒ์›์˜ ์ „์ฒด ์‹œ์ฒญ์ด๋ ฅ ํŽ˜์ด์ง• ์กฐํšŒ (์ตœ์‹ ์ˆœ) + Page findWatchHistoryByMemberId(Long memberId, Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java new file mode 100644 index 0000000..57caacf --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -0,0 +1,143 @@ +package com.ott.domain.watch_history.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.ott.domain.common.Status.ACTIVE; +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.playback.domain.QPlayback.playback; +import static com.ott.domain.tag.domain.QTag.tag; +import static com.ott.domain.watch_history.domain.QWatchHistory.watchHistory; + +@RequiredArgsConstructor +public class WatchHistoryRepositoryImpl implements WatchHistoryRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List countByTagAndCategoryIdAndWatchedBetween(Long categoryId, LocalDateTime startDate, LocalDateTime endDate) { + return queryFactory + .select(Projections.constructor(TagViewCountProjection.class, + tag.name, + watchHistory.count() + )) + .from(tag) + .join(mediaTag).on(mediaTag.tag.id.eq(tag.id)) + .join(contents).on(contents.media.id.eq(mediaTag.media.id)) + .join(watchHistory).on(watchHistory.contents.id.eq(contents.id)) + .where( + tag.category.id.eq(categoryId), + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .groupBy(tag.id, tag.name) + .fetch(); + } + + // ํŠน์ • ํšŒ์›์˜ 1๋‹ฌ ์‹œ์ฒญ์ด๋ ฅ ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ์ง‘๊ณ„ + @Override + public List findTopTagsByMemberIdAndWatchedBetween( + Long memberId, + LocalDateTime startDate, + LocalDateTime endDate + ) { + return queryFactory + .select(Projections.constructor(TagRankingProjection.class, + tag.id, + tag.name, + watchHistory.count() + )) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .join(mediaTag).on(mediaTag.media.id.eq(contents.media.id) + .and(mediaTag.status.eq(ACTIVE))) + .join(tag).on(tag.id.eq(mediaTag.tag.id) + .and(tag.status.eq(ACTIVE))) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE), // delete ๋œ๊ฑฐ ์กฐํšŒ x + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .groupBy(tag.id, tag.name) + .orderBy(watchHistory.count().desc()) + .fetch(); + } + + // ํŠน์ • ํšŒ์›์˜ ํŠน์ • ํƒœ๊ทธ์— ๋Œ€ํ•œ ๊ธฐ๊ฐ„ ๋‚ด ์‹œ์ฒญ count + @Override + public Long countByMemberIdAndTagIdAndWatchedBetween( + Long memberId, + Long tagId, + LocalDateTime startDate, + LocalDateTime endDate + ) { + Long result = queryFactory + .select(watchHistory.count()) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .join(mediaTag).on(mediaTag.media.id.eq(contents.media.id) + .and(mediaTag.status.eq(ACTIVE))) + .join(tag).on(tag.id.eq(mediaTag.tag.id) + .and(tag.status.eq(ACTIVE))) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE), // delete ๋œ๊ฑฐ ์กฐํšŒ x + tag.id.eq(tagId), + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .fetchOne(); + + return result != null ? result : 0L; + } + + // ํŠน์ • ํšŒ์›์˜ ์ „์ฒด ์‹œ์ฒญ์ด๋ ฅ ํŽ˜์ด์ง• ์กฐํšŒ (์ตœ์‹ ์ˆœ) + @Override + public Page findWatchHistoryByMemberId(Long memberId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(RecentWatchProjection.class, + contents.media.id, + contents.media.mediaType, + contents.media.posterUrl, + playback.positionSec.coalesce(0), + contents.duration + )) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .leftJoin(playback).on( + playback.contents.id.eq(contents.id) + .and(playback.member.id.eq(memberId)) + .and(playback.status.eq(ACTIVE)) + ) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE) // delete ๋œ๊ฑฐ ์กฐํšŒ x + ) + .orderBy(watchHistory.lastWatchedAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(watchHistory.count()) + .from(watchHistory) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + +} diff --git a/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java b/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java new file mode 100644 index 0000000..813e615 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java @@ -0,0 +1,15 @@ +package com.ott.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/modules/infra/build.gradle b/modules/infra-db/build.gradle similarity index 76% rename from modules/infra/build.gradle rename to modules/infra-db/build.gradle index e42dc85..0109ae5 100644 --- a/modules/infra/build.gradle +++ b/modules/infra-db/build.gradle @@ -2,5 +2,4 @@ dependencies { implementation project(':modules:domain') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' - implementation 'software.amazon.awssdk:s3:2.21.0' -} \ No newline at end of file +} diff --git a/modules/infra/src/main/resources/db/migration/V1__init.sql b/modules/infra-db/src/main/resources/db/migration/V1__init.sql similarity index 100% rename from modules/infra/src/main/resources/db/migration/V1__init.sql rename to modules/infra-db/src/main/resources/db/migration/V1__init.sql diff --git a/modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql new file mode 100644 index 0000000..86ab538 --- /dev/null +++ b/modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql @@ -0,0 +1,195 @@ +-- ============================================================ +-- V2: ํด๋ž˜์Šค ํ…Œ์ด๋ธ” ์ƒ์† ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ +-- series, contents, short_form โ†’ media ๊ณตํ†ต ๋ถ€๋ชจ ๋„์ž… +-- ============================================================ + +-- 1. media, media_tag ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE IF NOT EXISTS media +( + id BIGINT AUTO_INCREMENT NOT NULL, + uploader_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + poster_url TEXT NOT NULL, + thumbnail_url TEXT NULL, + + bookmark_count BIGINT NOT NULL DEFAULT 0, + likes_count BIGINT NOT NULL DEFAULT 0, + media_type ENUM ('SERIES','CONTENTS','SHORT_FORM') NOT NULL, + public_status ENUM ('PUBLIC','PRIVATE') NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media PRIMARY KEY (id) +) engine = InnoDB; + +CREATE TABLE IF NOT EXISTS media_tag +( + id BIGINT AUTO_INCREMENT NOT NULL, + tag_id BIGINT NOT NULL, + media_id BIGINT NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media_tag PRIMARY KEY (id) +) engine = InnoDB; + + +-- 2. ์‹ ๊ทœ ํ…Œ์ด๋ธ” FK ์„ค์ • +ALTER TABLE media + ADD CONSTRAINT fk_media_to_member + FOREIGN KEY (uploader_id) + REFERENCES member (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_tag + FOREIGN KEY (tag_id) + REFERENCES tag (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 3. media ์ƒ์„ธ ํ…Œ์ด๋ธ”์— media_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ + ์ œ์•ฝ์กฐ๊ฑด +-- series +ALTER TABLE series + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE series + ADD CONSTRAINT uk_series_media UNIQUE (media_id); + +ALTER TABLE series + ADD CONSTRAINT fk_series_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- contents +ALTER TABLE contents + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE contents + ADD CONSTRAINT uk_contents_media UNIQUE (media_id); + +ALTER TABLE contents + ADD CONSTRAINT fk_contents_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- short_form +ALTER TABLE short_form + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE short_form + ADD CONSTRAINT uk_short_form_media UNIQUE (media_id); + +ALTER TABLE short_form + ADD CONSTRAINT fk_short_form_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 4. bookmark: target_type + target_id โ†’ media_id +ALTER TABLE bookmark + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE bookmark + ADD CONSTRAINT fk_bookmark_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE bookmark + DROP COLUMN target_id; + +ALTER TABLE bookmark + DROP COLUMN target_type; + + +-- 5. likes: target_type + target_id โ†’ media_id +ALTER TABLE likes + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE likes + ADD CONSTRAINT fk_likes_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE likes + DROP COLUMN target_id; + +ALTER TABLE likes + DROP COLUMN target_type; + + +-- 6. ingest_job: contents_id + short_form_id โ†’ media_id +ALTER TABLE ingest_job + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE ingest_job + ADD CONSTRAINT fk_ingest_job_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_contents; + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_short_form; + +ALTER TABLE ingest_job + DROP COLUMN contents_id; + +ALTER TABLE ingest_job + DROP COLUMN short_form_id; + + +-- 7. series_tag + contents_tag โ†’ media_tag ํ†ตํ•ฉ (ํ…Œ์ด๋ธ” ์‚ญ์ œ) +DROP TABLE series_tag; +DROP TABLE contents_tag; + + +-- 8. ์ƒ์„ธ ํ…Œ์ด๋ธ”์—์„œ media๋กœ ์ด๋™ํ•œ ์ปฌ๋Ÿผ ์ œ๊ฑฐ +-- series +ALTER TABLE series + DROP FOREIGN KEY fk_series_to_member_uploader; + +ALTER TABLE series + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- contents +ALTER TABLE contents + DROP FOREIGN KEY fk_contents_to_member_uploader; + +ALTER TABLE contents + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- short_form +ALTER TABLE short_form + DROP FOREIGN KEY fk_short_form_to_member_uploader; + +ALTER TABLE short_form + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN bookmark_count, + DROP COLUMN public_status; diff --git a/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql new file mode 100644 index 0000000..a062f21 --- /dev/null +++ b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql @@ -0,0 +1,6 @@ +ALTER TABLE member + ADD COLUMN onboarding_completed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Existing users should not be forced back into onboarding after deployment. +UPDATE member +SET onboarding_completed = TRUE; diff --git a/modules/infra-s3/build.gradle b/modules/infra-s3/build.gradle new file mode 100644 index 0000000..4ae4833 --- /dev/null +++ b/modules/infra-s3/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'org.springframework:spring-context' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation platform('software.amazon.awssdk:bom:2.42.0') + implementation 'software.amazon.awssdk:s3' +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java new file mode 100644 index 0000000..aac21c9 --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java @@ -0,0 +1,20 @@ +package com.ott.infra.s3.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3PresignerConfig { + + @Bean + public S3Presigner s3Presigner(@Value("${aws.region:ap-northeast-2}") String region) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java new file mode 100644 index 0000000..e8bb15d --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java @@ -0,0 +1,72 @@ +package com.ott.infra.s3.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Service +public class S3PresignService { + + private final S3Presigner s3Presigner; + private final String region; + private final String bucket; + private final String publicBaseUrl; + private final long expireSeconds; + + public S3PresignService( + S3Presigner s3Presigner, + @Value("${aws.region:ap-northeast-2}") String region, + @Value("${aws.s3.bucket:local-bucket}") String bucket, + @Value("${aws.s3.public-base-url:}") String publicBaseUrl, + @Value("${aws.s3.presign-expire-seconds:600}") long expireSeconds) { + this.s3Presigner = s3Presigner; + this.region = region; + this.bucket = bucket; + this.publicBaseUrl = publicBaseUrl; + this.expireSeconds = expireSeconds; + } + + public String createPutPresignedUrl(String objectKey, String contentType) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expireSeconds)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } catch (SdkException ex) { + throw new IllegalStateException("์—…๋กœ๋“œ URL ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", ex); + } + } + + // objectKey๋ฅผ ์‹ค์ œ S3 ๊ฐ์ฒด URL ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + // - ํ•œ๊ธ€/๊ณต๋ฐฑ/ํŠน์ˆ˜๋ฌธ์ž ๊นจ์ง ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด URL ์ธ์ฝ”๋”ฉ + // - ๊ณต๋ฐฑ์€ '+' ๋Œ€์‹  '%20'์œผ๋กœ ์ •๊ทœํ™” + // - ๊ฒฝ๋กœ ๊ตฌ๋ถ„์ž๋Š” ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•ด '%2F'๋ฅผ '/'๋กœ ๋ณต์› + public String toObjectUrl(String objectKey) { + String encodedKey = URLEncoder.encode(objectKey, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%2F", "/"); + + // public-base-url์ด ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉํ•˜๊ณ , ์—†์œผ๋ฉด ๊ธฐ์กด S3 URL ๊ทœ์น™์œผ๋กœ fallbackํ•ฉ๋‹ˆ๋‹ค. + String baseUrl = (publicBaseUrl == null || publicBaseUrl.isBlank()) + ? "https://" + bucket + ".s3." + region + ".amazonaws.com" + : publicBaseUrl.replaceAll("/+$", ""); + return baseUrl + "/" + encodedKey; + } +} diff --git a/modules/infra/src/main/java/com/ott/infra/db/.gitkeep b/modules/infra/src/main/java/com/ott/infra/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep b/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/settings.gradle b/settings.gradle index 10f714a..c86a7f4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,7 +8,8 @@ include( // Modules ":modules:domain", - ":modules:infra", + ":modules:infra-db", + ":modules:infra-s3", ":modules:common-web", ":modules:common-security" -) \ No newline at end of file +)