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 ๋ณ๋ชฉ์ ๋ฐฉ์งํ๊ธฐ ์ํด ๋ค์ด๋ ํธ ์
๋ก๋ ๋ฐ ๋น๋๊ธฐ ํ์ ๋ฐฉ์์ ์ ์ฉํ์ต๋๋ค.
+
+
+
+
+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