diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 98f5f62c..2b394047 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -30,7 +30,7 @@ jobs: - name: Create application-dev.yml run: | mkdir -p ./src/main/resources - echo "${{ secrets.APPLICATION_DEV }}" > ./src/main/resources/application.yml + echo "${{ secrets.APPLICATION_DEV }}" > ./src/main/resources/application-dev.yml shell: bash # 4. Firebase Admin Key 생성 @@ -73,6 +73,8 @@ jobs: cat > .env.dev << EOF DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + RABBITMQ_USER=${{ secrets.RABBITMQ_USER }} + RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} SPRING_CRYPTO_SECRET_KEY=${{ secrets.SPRING_CRYPTO_SECRET_KEY }} JWT_ACCESS_TOKEN_SECRET=${{ secrets.JWT_ACCESS_TOKEN_SECRET }} JWT_ACCESS_TOKEN_EXPIRATION=1800000 @@ -81,17 +83,28 @@ jobs: CLOUD_AWS_S3_BUCKET=${{ secrets.CLOUD_AWS_S3_BUCKET }} CLOUD_AWS_CREDENTIALS_ACCESS_KEY=${{ secrets.CLOUD_AWS_CREDENTIALS_ACCESS_KEY }} CLOUD_AWS_CREDENTIALS_SECRET_KEY=${{ secrets.CLOUD_AWS_CREDENTIALS_SECRET_KEY }} + DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL_DEV=${{ secrets.DISCORD_WEBHOOK_URL }} OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN_DEV=${{ secrets.SENTRY_DSN }} ADMIN_IDENTIFIER=${{ secrets.ADMIN_IDENTIFIER }} ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} + GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} + GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_DEV_ROOT_URL=https://ono-dev.seungminki.shop/grafana/ + GRAFANA_DEV_SERVE_FROM_SUB_PATH=true + PROMETHEUS_DEV_EXTERNAL_URL=https://ono-dev.seungminki.shop/prometheus/ + PROMETHEUS_DEV_ROUTE_PREFIX=/prometheus EOF shell: bash # 10. Docker Compose로 배포 (DEVELOPMENT) - name: Deploy with Docker Compose run: | + # 최신 dev 이미지 강제 pull (tag 캐시로 구버전 실행되는 문제 방지) + docker pull ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest + # MySQL과 Redis 시작 (개발 환경 전용) docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d mysql-dev redis-dev @@ -100,6 +113,10 @@ jobs: docker-compose -f docker-compose.dev.yml --env-file .env.dev rm -f app-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d app-dev + # 실제 기동 이미지 확인 + docker inspect ono-app-dev --format='Running Image: {{.Config.Image}}' + docker inspect ono-app-dev --format='Image ID: {{.Image}}' + # 헬스체크 대기 (최대 2분) echo "Waiting for development application to be healthy..." timeout=60 @@ -122,6 +139,56 @@ jobs: docker-compose -f docker-compose.dev.yml --env-file .env.dev logs --tail=50 app-dev shell: bash + # 11. Monitoring Stack 배포 (DEVELOPMENT) + - name: Deploy Monitoring Stack + run: | + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev mysql-exporter-dev || true + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev mysql-exporter-dev || true + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \ + mysql-exporter-dev prometheus-dev grafana-dev + docker-compose -f docker-compose.dev.yml --env-file .env.dev ps \ + mysql-exporter-dev prometheus-dev grafana-dev + shell: bash + + # 11-1. Monitoring 연동 검증 (DEVELOPMENT) + - name: Verify Monitoring Integration + run: | + # app-dev 내부 actuator 메트릭 엔드포인트가 200인지 확인 + echo "Checking app-dev actuator metrics endpoint..." + if ! docker exec ono-app-dev sh -lc 'wget -qO- http://localhost:8081/actuator/prometheus | head -n 5' >/tmp/app_metrics_head.txt; then + echo "ERROR: app-dev actuator/prometheus endpoint is not reachable" + docker logs --tail=200 ono-app-dev || true + exit 1 + fi + + # prometheus 컨테이너 관점에서도 app-dev 메트릭 엔드포인트가 200인지 확인 + echo "Checking app-dev actuator metrics endpoint from prometheus-dev..." + if ! docker exec ono-prometheus-dev sh -lc 'wget -qO- http://app-dev:8081/actuator/prometheus | head -n 5' >/tmp/prom_metrics_head.txt; then + echo "ERROR: prometheus-dev cannot fetch app-dev actuator/prometheus" + docker exec ono-prometheus-dev sh -lc 'wget -S -O- http://app-dev:8081/actuator/prometheus >/dev/null' || true + docker logs --tail=200 ono-app-dev || true + exit 1 + fi + + # Prometheus가 app-dev를 up=1로 스크랩하는지 확인 (최대 60초 대기) + echo "Checking Prometheus scrape status for ono-backend-dev..." + for i in $(seq 1 12); do + result=$(docker exec ono-prometheus-dev sh -lc 'wget -qO- "http://localhost:9090/prometheus/api/v1/query?query=up{job=\"ono-backend-dev\"}"' || true) + if echo "$result" | grep -q '"value":\[.*,"1"\]'; then + echo "Prometheus scrape is UP" + exit 0 + fi + echo "Waiting scrape up... ($i/12)" + sleep 5 + done + + echo "ERROR: Prometheus scrape for ono-backend-dev is still down" + docker exec ono-prometheus-dev sh -lc 'wget -qO- "http://localhost:9090/prometheus/api/v1/targets"' || true + docker logs --tail=200 ono-app-dev || true + docker logs --tail=200 ono-prometheus-dev || true + exit 1 + shell: bash + # 12. 오래된 Docker 이미지 정리 - name: Clean up old Docker images run: | diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 6592fd01..8ce3ab64 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -30,7 +30,7 @@ jobs: - name: Create application-prod.yml run: | mkdir -p ./src/main/resources - echo "${{ secrets.APPLICATION_PROD }}" > ./src/main/resources/application.yml + echo "${{ secrets.APPLICATION_PROD }}" > ./src/main/resources/application-prod.yml shell: bash # 4. Firebase Admin Key 생성 @@ -73,6 +73,8 @@ jobs: cat > .env.prod << EOF DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + RABBITMQ_USER=${{ secrets.RABBITMQ_USER }} + RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} SPRING_CRYPTO_SECRET_KEY=${{ secrets.SPRING_CRYPTO_SECRET_KEY }} JWT_ACCESS_TOKEN_SECRET=${{ secrets.JWT_ACCESS_TOKEN_SECRET }} JWT_ACCESS_TOKEN_EXPIRATION=1800000 @@ -86,12 +88,21 @@ jobs: SENTRY_DSN=${{ secrets.SENTRY_DSN }} ADMIN_IDENTIFIER=${{ secrets.ADMIN_IDENTIFIER }} ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} + GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} + GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_PROD_ROOT_URL=https://ono-prod.seungminki.shop/grafana/ + GRAFANA_PROD_SERVE_FROM_SUB_PATH=true + PROMETHEUS_PROD_EXTERNAL_URL=https://ono-prod.seungminki.shop/prometheus/ + PROMETHEUS_PROD_ROUTE_PREFIX=/prometheus EOF shell: bash # 10. Blue-Green 무중단 배포 - name: Blue-Green Deployment run: | + # 최신 prod 이미지 강제 pull (tag 캐시로 구버전 실행되는 문제 방지) + docker pull ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest + # MySQL과 Redis 시작 (이미 실행 중이면 스킵) docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod @@ -196,6 +207,39 @@ jobs: docker-compose -f docker-compose.prod.yml logs --tail=50 app-prod-$TARGET shell: bash + # 11. Monitoring Stack 배포 (PRODUCTION, Non-blocking) + - name: Deploy Monitoring Stack + run: | + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod mysql-exporter-prod || true + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod mysql-exporter-prod || true + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ + mysql-exporter-prod prometheus-prod grafana-prod + docker-compose -f docker-compose.prod.yml --env-file .env.prod ps \ + mysql-exporter-prod prometheus-prod grafana-prod + shell: bash + + # 11-1. Monitoring 연동 검증 (PRODUCTION, Non-blocking) + - name: Verify Monitoring Integration + continue-on-error: true + run: | + echo "Checking Prometheus scrape status for production jobs..." + + # 최대 60초 동안 active blue/green 중 하나라도 up=1인지 확인 + for i in $(seq 1 12); do + result=$(docker exec ono-prometheus-prod sh -lc 'wget -qO- "http://localhost:9090/prometheus/api/v1/query?query=max(up{job=~\"ono-backend-prod-(blue|green)\"})"' || true) + if echo "$result" | grep -q '"value":\[.*,"1"\]'; then + echo "Prometheus scrape is UP for at least one production target" + exit 0 + fi + echo "Waiting scrape up... ($i/12)" + sleep 5 + done + + echo "WARNING: Prometheus scrape for production targets is still down" + docker exec ono-prometheus-prod sh -lc 'wget -qO- "http://localhost:9090/prometheus/api/v1/targets"' || true + docker logs --tail=200 ono-prometheus-prod || true + shell: bash + # 12. 오래된 Docker 이미지 정리 - name: Clean up old Docker images run: | diff --git a/.github/workflows/restart-monitoring.yml b/.github/workflows/restart-monitoring.yml new file mode 100644 index 00000000..90a7550c --- /dev/null +++ b/.github/workflows/restart-monitoring.yml @@ -0,0 +1,71 @@ +name: Restart Monitoring Stack + +on: + workflow_dispatch: + inputs: + target: + description: "Target environment" + required: true + type: choice + options: + - dev + - prod + default: dev + +permissions: + contents: read + +jobs: + restart-monitoring: + runs-on: self-hosted + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create .env files + run: | + cat > .env.dev << EOF + DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} + MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + RABBITMQ_USER=${{ secrets.RABBITMQ_USER }} + RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} + GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} + GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_DEV_ROOT_URL=https://ono-dev.seungminki.shop/grafana/ + GRAFANA_DEV_SERVE_FROM_SUB_PATH=true + PROMETHEUS_DEV_EXTERNAL_URL=https://ono-dev.seungminki.shop/prometheus/ + PROMETHEUS_DEV_ROUTE_PREFIX=/prometheus + EOF + + cat > .env.prod << EOF + DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} + MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + RABBITMQ_USER=${{ secrets.RABBITMQ_USER }} + RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} + GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} + GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_PROD_ROOT_URL=https://ono-prod.seungminki.shop/grafana/ + GRAFANA_PROD_SERVE_FROM_SUB_PATH=true + PROMETHEUS_PROD_EXTERNAL_URL=https://ono-prod.seungminki.shop/prometheus/ + PROMETHEUS_PROD_ROUTE_PREFIX=/prometheus + EOF + shell: bash + + - name: Restart monitoring stack (dev) + if: ${{ github.event.inputs.target == 'dev' }} + run: | + docker compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \ + mysql-exporter-dev prometheus-dev grafana-dev + docker compose -f docker-compose.dev.yml --env-file .env.dev ps \ + mysql-exporter-dev prometheus-dev grafana-dev + shell: bash + + - name: Restart monitoring stack (prod) + if: ${{ github.event.inputs.target == 'prod' }} + run: | + docker compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ + mysql-exporter-prod prometheus-prod grafana-prod + docker compose -f docker-compose.prod.yml --env-file .env.prod ps \ + mysql-exporter-prod prometheus-prod grafana-prod + shell: bash diff --git a/.gitignore b/.gitignore index 5adef0ac..56e89db9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,12 @@ FirebaseAdminKey.json ### Environment variables .env +.env.dev +monitoring/.env +monitoring/.env.* +monitoring/grafana/provisioning/datasources/*.local.yml +monitoring/grafana/provisioning/dashboards/*.local.yml +monitoring/README.md ### macOS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Dockerfile b/Dockerfile index 5bd075fe..74c94ade 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,4 @@ COPY ${JAR_FILE} backend-0.0.1.SNAPSHOT.jar COPY FirebaseAdminKey.json FirebaseAdminKey.json # 운영 및 개발에서 사용되는 환경 설정을 분리한다. -ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "-Dspring.profiles.active=prod", "backend-0.0.1.SNAPSHOT.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "backend-0.0.1.SNAPSHOT.jar"] diff --git a/build.gradle b/build.gradle index 9bcef57a..ca9d0793 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' // Hibernate Core implementation 'org.hibernate:hibernate-core:6.5.2.Final' @@ -84,4 +85,4 @@ tasks.withType(JavaCompile) { clean { delete file('src/main/generated') -} \ No newline at end of file +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 79244e81..de01a881 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -100,6 +100,9 @@ services: SENTRY_DSN: ${SENTRY_DSN} ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus + MANAGEMENT_ENDPOINT_PROMETHEUS_ENABLED: "true" + MANAGEMENT_SERVER_PORT: 8081 volumes: - app_dev_logs:/app/logs depends_on: @@ -112,12 +115,73 @@ services: networks: - ono-network healthcheck: - test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"] + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8081/actuator/health || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 60s + # Prometheus - DEVELOPMENT (Profile: monitoring) + prometheus-dev: + image: prom/prometheus:latest + container_name: ono-prometheus-dev + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.external-url=${PROMETHEUS_DEV_EXTERNAL_URL:-http://localhost:9091} + - --web.route-prefix=${PROMETHEUS_DEV_ROUTE_PREFIX:-/} + ports: + - "9091:9090" + volumes: + - ./monitoring/prometheus.dev.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/alerts:/etc/prometheus/alerts:ro + - prometheus_dev_data:/prometheus + networks: + - ono-network + depends_on: + - app-dev + - mysql-exporter-dev + profiles: + - monitoring + + mysql-exporter-dev: + image: prom/mysqld-exporter:latest + container_name: ono-mysql-exporter-dev + restart: unless-stopped + environment: + DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-dev:3306)/ + depends_on: + mysql-dev: + condition: service_healthy + networks: + - ono-network + profiles: + - monitoring + + # Grafana - DEVELOPMENT (Profile: monitoring) + grafana-dev: + image: grafana/grafana:latest + container_name: ono-grafana-dev + restart: unless-stopped + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + GF_SERVER_ROOT_URL: ${GRAFANA_DEV_ROOT_URL:-http://localhost:3001} + GF_SERVER_SERVE_FROM_SUB_PATH: ${GRAFANA_DEV_SERVE_FROM_SUB_PATH:-false} + volumes: + - grafana_dev_data:/var/lib/grafana + - ./monitoring/grafana/provisioning/dev:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro + depends_on: + - prometheus-dev + networks: + - ono-network + profiles: + - monitoring + networks: ono-network: driver: bridge @@ -131,4 +195,8 @@ volumes: rabbitmq_dev_data: driver: local app_dev_logs: - driver: local \ No newline at end of file + driver: local + prometheus_dev_data: + driver: local + grafana_dev_data: + driver: local diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8ebec021..c7d403a7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -99,6 +99,9 @@ services: SENTRY_DSN: ${SENTRY_DSN} ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus + MANAGEMENT_ENDPOINT_PROMETHEUS_ENABLED: "true" + MANAGEMENT_SERVER_PORT: 8081 volumes: - app_prod_logs:/app/logs depends_on: @@ -111,7 +114,7 @@ services: networks: - ono-network healthcheck: - test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"] + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8081/actuator/health || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -150,6 +153,9 @@ services: SENTRY_DSN: ${SENTRY_DSN} ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus + MANAGEMENT_ENDPOINT_PROMETHEUS_ENABLED: "true" + MANAGEMENT_SERVER_PORT: 8081 volumes: - app_prod_logs:/app/logs depends_on: @@ -162,7 +168,7 @@ services: networks: - ono-network healthcheck: - test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"] + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8081/actuator/health || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -170,6 +176,66 @@ services: profiles: - green # 기본적으로는 비활성화, 배포 시에만 활성화 + # Prometheus - PRODUCTION (Profile: monitoring) + prometheus-prod: + image: prom/prometheus:latest + container_name: ono-prometheus-prod + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.external-url=${PROMETHEUS_PROD_EXTERNAL_URL:-http://localhost:9090} + - --web.route-prefix=${PROMETHEUS_PROD_ROUTE_PREFIX:-/} + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/alerts:/etc/prometheus/alerts:ro + - prometheus_prod_data:/prometheus + depends_on: + - mysql-exporter-prod + networks: + - ono-network + profiles: + - monitoring + + mysql-exporter-prod: + image: prom/mysqld-exporter:latest + container_name: ono-mysql-exporter-prod + restart: unless-stopped + environment: + DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-prod:3306)/ + depends_on: + mysql-prod: + condition: service_healthy + networks: + - ono-network + profiles: + - monitoring + + # Grafana - PRODUCTION (Profile: monitoring) + grafana-prod: + image: grafana/grafana:latest + container_name: ono-grafana-prod + restart: unless-stopped + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + GF_SERVER_ROOT_URL: ${GRAFANA_PROD_ROOT_URL:-http://localhost:3000} + GF_SERVER_SERVE_FROM_SUB_PATH: ${GRAFANA_PROD_SERVE_FROM_SUB_PATH:-false} + volumes: + - grafana_prod_data:/var/lib/grafana + - ./monitoring/grafana/provisioning/prod:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro + depends_on: + - prometheus-prod + networks: + - ono-network + profiles: + - monitoring + networks: ono-network: driver: bridge @@ -182,4 +248,8 @@ volumes: rabbitmq_prod_data: driver: local app_prod_logs: - driver: local \ No newline at end of file + driver: local + prometheus_prod_data: + driver: local + grafana_prod_data: + driver: local diff --git a/monitoring/alerts/ono-alerts.yml b/monitoring/alerts/ono-alerts.yml new file mode 100644 index 00000000..f445fc15 --- /dev/null +++ b/monitoring/alerts/ono-alerts.yml @@ -0,0 +1,64 @@ +groups: + - name: ono-backend-alerts + rules: + - alert: OnOBackendDownDev + expr: up{job="ono-backend-dev"} == 0 + for: 3m + labels: + severity: critical + service: ono-backend + env: dev + annotations: + summary: "OnO backend dev target is down" + description: "Prometheus cannot scrape ono-backend-dev for more than 3 minutes." + + - alert: OnOBackendDownProd + expr: max(up{job=~"ono-backend-prod-(blue|green)"}) == 0 + for: 3m + labels: + severity: critical + service: ono-backend + env: prod + annotations: + summary: "OnO backend prod targets are down" + description: "Both blue/green prod targets are unavailable for more than 3 minutes." + + - alert: OnOBackendHighCPU + expr: system_cpu_usage{job=~"ono-backend-.*"} > 0.85 + for: 5m + labels: + severity: warning + service: ono-backend + annotations: + summary: "High CPU usage on {{ $labels.job }}" + description: "system_cpu_usage is above 85% for 5 minutes." + + - alert: OnOBackendHighJvmHeap + expr: (sum by (job) (jvm_memory_used_bytes{job=~"ono-backend-.*",area="heap"}) / clamp_min(sum by (job) (jvm_memory_max_bytes{job=~"ono-backend-.*",area="heap"}), 1)) > 0.85 + for: 10m + labels: + severity: warning + service: ono-backend + annotations: + summary: "High JVM heap usage on {{ $labels.job }}" + description: "Heap usage ratio is above 85% for 10 minutes." + + - alert: OnOBackendHighApiP95 + expr: histogram_quantile(0.95, sum by (job, le) (rate(http_server_requests_seconds_bucket{job=~"ono-backend-.*",uri!~"/actuator.*"}[5m]))) > 1.5 + for: 10m + labels: + severity: warning + service: ono-backend + annotations: + summary: "High API p95 latency on {{ $labels.job }}" + description: "HTTP API p95 latency is above 1.5s for 10 minutes." + + - alert: OnOBackendHigh5xxRatio + expr: (sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5.."}[5m])) / clamp_min(sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*"}[5m])), 1e-9)) > 0.05 + for: 10m + labels: + severity: warning + service: ono-backend + annotations: + summary: "High 5xx ratio on {{ $labels.job }}" + description: "5xx ratio is above 5% for 10 minutes." diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 00000000..229a83bf --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + prometheus: + image: prom/prometheus:latest + container_name: ono-prometheus + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + + grafana: + image: grafana/grafana:latest + container_name: ono-grafana + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + +volumes: + prometheus_data: + grafana_data: diff --git a/monitoring/grafana/dashboards/ono-backend-overview.json b/monitoring/grafana/dashboards/ono-backend-overview.json new file mode 100644 index 00000000..9eadff30 --- /dev/null +++ b/monitoring/grafana/dashboards/ono-backend-overview.json @@ -0,0 +1,676 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) or vector(0)", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate (RPS)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "1000 * (sum(rate(http_server_requests_seconds_sum{job=~\"$job\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m])))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "title": "Avg Response Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "title": "5xx Error Ratio", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.7 + }, + { + "color": "red", + "value": 0.9 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "system_cpu_usage{job=~\"$job\"}", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "title": "System CPU Usage", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))", + "legendFormat": "{{method}} {{uri}}", + "range": true, + "refId": "A" + } + ], + "title": "Traffic by URI", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", + "legendFormat": "p95", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", + "legendFormat": "p99", + "range": true, + "refId": "B" + } + ], + "title": "Latency (p95/p99)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"})", + "legendFormat": "Heap Used", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(jvm_memory_max_bytes{job=~\"$job\",area=\"heap\"})", + "legendFormat": "Heap Max", + "range": true, + "refId": "B" + } + ], + "title": "JVM Heap", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "hikaricp_connections_active{job=~\"$job\"}", + "legendFormat": "Active", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "hikaricp_connections_idle{job=~\"$job\"}", + "legendFormat": "Idle", + "range": true, + "refId": "B" + }, + { + "editorMode": "code", + "expr": "hikaricp_connections_pending{job=~\"$job\"}", + "legendFormat": "Pending", + "range": true, + "refId": "C" + } + ], + "title": "HikariCP Connections", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 23 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=~\"$job\"}", + "legendFormat": "Process RSS", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"}", + "legendFormat": "JVM Heap Used", + "range": true, + "refId": "B" + } + ], + "title": "Server/Process Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 23 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])), 1e-9)))", + "legendFormat": "{{method}} {{uri}}", + "range": true, + "refId": "A" + } + ], + "title": "API Avg Latency by URI (Top20)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 31 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "topk(20, histogram_quantile(0.95, sum by (le, uri, method) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000)", + "legendFormat": "p95 {{method}} {{uri}}", + "range": true, + "refId": "A" + } + ], + "title": "API p95 Latency by URI (Top20)", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "ono", + "spring-boot" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(up, job)", + "hide": 0, + "includeAll": true, + "label": "job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(up, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "OnO Backend Overview", + "uid": "ono-backend-overview", + "version": 4, + "weekStart": "" +} diff --git a/monitoring/grafana/dashboards/ono-dependency-overview.json b/monitoring/grafana/dashboards/ono-dependency-overview.json new file mode 100644 index 00000000..032174e4 --- /dev/null +++ b/monitoring/grafana/dashboards/ono-dependency-overview.json @@ -0,0 +1,550 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "orange", "value": 0.7}, + {"color": "red", "value": 0.9} + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 0, "y": 0}, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(jvm_memory_used_bytes{job=~\"$backend_job\",area=\"heap\"}) / clamp_min(sum(jvm_memory_max_bytes{job=~\"$backend_job\",area=\"heap\"}), 1)", + "legendFormat": "Heap Usage", + "range": true, + "refId": "A" + } + ], + "title": "Heap Usage Ratio", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 6, "y": 0}, + "id": 2, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(jvm_memory_used_bytes{job=~\"$backend_job\",id=\"Metaspace\"})", + "legendFormat": "Metaspace", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(jvm_memory_used_bytes{job=~\"$backend_job\",area=\"nonheap\"})", + "legendFormat": "Non-heap", + "range": true, + "refId": "B" + } + ], + "title": "JVM Non-Heap", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "orange", "value": 0.7}, + {"color": "red", "value": 0.9} + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 12, "y": 0}, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(hikaricp_connections_active{job=~\"$backend_job\"}) / clamp_min(sum(hikaricp_connections_max{job=~\"$backend_job\"}), 1)", + "legendFormat": "Pool Utilization", + "range": true, + "refId": "A" + } + ], + "title": "DB Pool Utilization", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 18, "y": 0}, + "id": 4, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(hikaricp_connections_active{job=~\"$backend_job\"})", + "legendFormat": "Active", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(hikaricp_connections_idle{job=~\"$backend_job\"})", + "legendFormat": "Idle", + "range": true, + "refId": "B" + }, + { + "editorMode": "code", + "expr": "sum(hikaricp_connections_pending{job=~\"$backend_job\"})", + "legendFormat": "Pending", + "range": true, + "refId": "C" + } + ], + "title": "DB Connection Pool", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "orange", "value": 0.6}, + {"color": "green", "value": 0.9} + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 0, "y": 7}, + "id": 5, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(redis_keyspace_hits_total{job=~\"$redis_job\"}[5m])) / clamp_min(sum(rate(redis_keyspace_hits_total{job=~\"$redis_job\"}[5m])) + sum(rate(redis_keyspace_misses_total{job=~\"$redis_job\"}[5m])), 1e-9)", + "legendFormat": "Hit Ratio", + "range": true, + "refId": "A" + } + ], + "title": "Redis Hit Ratio", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": {"color": {"mode": "palette-classic"}, "unit": "bytes"}, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 6, "y": 7}, + "id": 6, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(redis_memory_used_bytes{job=~\"$redis_job\"})", + "legendFormat": "Used", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(redis_memory_max_bytes{job=~\"$redis_job\"})", + "legendFormat": "Max", + "range": true, + "refId": "B" + } + ], + "title": "Redis Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 12, "y": 7}, + "id": 7, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rabbitmq_queue_messages_ready{job=~\"$rabbit_job\"})", + "legendFormat": "Ready", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(rabbitmq_queue_messages_unacked{job=~\"$rabbit_job\"})", + "legendFormat": "Unacked", + "range": true, + "refId": "B" + } + ], + "title": "RabbitMQ Queue Depth", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 6, "x": 18, "y": 7}, + "id": 8, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rabbitmq_queue_consumers{job=~\"$rabbit_job\"})", + "legendFormat": "Consumers", + "range": true, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(rabbitmq_up{job=~\"$rabbit_job\"})", + "legendFormat": "Exporter Up", + "range": true, + "refId": "B" + } + ], + "title": "RabbitMQ Consumers & Up", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "orange", "value": 0.95}, + {"color": "green", "value": 0.99} + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 8, "x": 0, "y": 14}, + "id": 9, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\",outcome=\"success\"}[5m])) / clamp_min(sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])), 1e-9)", + "legendFormat": "Success Rate", + "range": true, + "refId": "A" + } + ], + "title": "External Dependency Success Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 8, "x": 8, "y": 14}, + "id": 10, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(ono_external_requests_seconds_bucket{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])) by (le, dependency))", + "legendFormat": "{{dependency}} p95", + "range": true, + "refId": "A" + } + ], + "title": "External Dependency Latency p95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": {"color": {"mode": "palette-classic"}, "unit": "reqps"}, + "overrides": [] + }, + "gridPos": {"h": 7, "w": 8, "x": 16, "y": 14}, + "id": 11, + "options": { + "legend": {"displayMode": "list", "placement": "bottom", "showLegend": true}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (dependency, operation, outcome) (rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m]))", + "legendFormat": "{{dependency}} {{operation}} {{outcome}}", + "range": true, + "refId": "A" + } + ], + "title": "External Dependency Call Rate", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "style": "dark", + "tags": ["ono", "dependencies", "infra"], + "templating": { + "list": [ + { + "current": {"selected": true, "text": "All", "value": "$__all"}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "definition": "label_values(up{job=~\"ono-backend-.*\"}, job)", + "hide": 0, + "includeAll": true, + "label": "backend_job", + "multi": false, + "name": "backend_job", + "options": [], + "query": { + "query": "label_values(up{job=~\"ono-backend-.*\"}, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {"selected": true, "text": "All", "value": "$__all"}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "definition": "label_values(up{job=~\"ono-redis-.*\"}, job)", + "hide": 0, + "includeAll": true, + "label": "redis_job", + "multi": false, + "name": "redis_job", + "options": [], + "query": { + "query": "label_values(up{job=~\"ono-redis-.*\"}, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {"selected": true, "text": "All", "value": "$__all"}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "definition": "label_values(up{job=~\"ono-rabbitmq-.*\"}, job)", + "hide": 0, + "includeAll": true, + "label": "rabbit_job", + "multi": false, + "name": "rabbit_job", + "options": [], + "query": { + "query": "label_values(up{job=~\"ono-rabbitmq-.*\"}, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {"selected": true, "text": "All", "value": "$__all"}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "definition": "label_values(ono_external_requests_seconds_count, dependency)", + "hide": 0, + "includeAll": true, + "label": "dependency", + "multi": false, + "name": "dependency", + "options": [], + "query": { + "query": "label_values(ono_external_requests_seconds_count, dependency)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "OnO Dependency & Infra Overview", + "uid": "ono-dependency-overview", + "version": 1, + "weekStart": "" +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..010d4bd6 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'OnO Dashboards' + orgId: 1 + folder: 'OnO' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..6a6fddcc --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + uid: prometheus diff --git a/monitoring/grafana/provisioning/dev/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dev/dashboards/dashboards.yml new file mode 100644 index 00000000..d443f7e5 --- /dev/null +++ b/monitoring/grafana/provisioning/dev/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'OnO Dashboards Dev' + orgId: 1 + folder: 'OnO Dev' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards diff --git a/monitoring/grafana/provisioning/dev/datasources/datasource.yml b/monitoring/grafana/provisioning/dev/datasources/datasource.yml new file mode 100644 index 00000000..4d13d158 --- /dev/null +++ b/monitoring/grafana/provisioning/dev/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus-dev:9090/prometheus + isDefault: true + editable: true diff --git a/monitoring/grafana/provisioning/prod/dashboards/dashboards.yml b/monitoring/grafana/provisioning/prod/dashboards/dashboards.yml new file mode 100644 index 00000000..6d68da57 --- /dev/null +++ b/monitoring/grafana/provisioning/prod/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'OnO Dashboards Prod' + orgId: 1 + folder: 'OnO Prod' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards diff --git a/monitoring/grafana/provisioning/prod/datasources/datasource.yml b/monitoring/grafana/provisioning/prod/datasources/datasource.yml new file mode 100644 index 00000000..f552df23 --- /dev/null +++ b/monitoring/grafana/provisioning/prod/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus-prod:9090/prometheus + isDefault: true + editable: true diff --git a/monitoring/prometheus.dev.yml b/monitoring/prometheus.dev.yml new file mode 100644 index 00000000..eec31420 --- /dev/null +++ b/monitoring/prometheus.dev.yml @@ -0,0 +1,16 @@ +global: + scrape_interval: 60s + evaluation_interval: 60s + +rule_files: + - /etc/prometheus/alerts/ono-alerts.yml + +scrape_configs: + - job_name: "ono-backend-dev" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-dev:8081"] + + - job_name: "ono-mysql-dev" + static_configs: + - targets: ["mysql-exporter-dev:9104"] diff --git a/monitoring/prometheus.prod.yml b/monitoring/prometheus.prod.yml new file mode 100644 index 00000000..423980b4 --- /dev/null +++ b/monitoring/prometheus.prod.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: 60s + evaluation_interval: 60s + +rule_files: + - /etc/prometheus/alerts/ono-alerts.yml + +scrape_configs: + - job_name: "ono-backend-prod-blue" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-prod-blue:8081"] + + - job_name: "ono-backend-prod-green" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-prod-green:8081"] + + - job_name: "ono-mysql-prod" + static_configs: + - targets: ["mysql-exporter-prod:9104"] diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..d386ce48 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "ono-backend-local" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["host.docker.internal:8080"] + + # 앱을 docker-compose.dev.yml(app-dev:8082)로 띄우는 경우 사용 + - job_name: "ono-backend-dev-port" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["host.docker.internal:8082"] diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java index 869e39b3..44bcbb3f 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java +++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java @@ -8,11 +8,14 @@ import com.aisip.OnO.backend.common.auth.JwtTokenFilter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -54,6 +57,21 @@ public AuthenticationEntryPoint authenticationEntryPoint() { } @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers("/actuator/**"); + } + + @Bean + @Order(0) + public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception { + http.securityMatcher(EndpointRequest.toAnyEndpoint()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + @Bean + @Order(1) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.cors(cors -> cors.configurationSource(corsConfigurationSource())); @@ -61,7 +79,24 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.authorizeHttpRequests(authorizeRequests -> authorizeRequests - .requestMatchers("/", "/robots.txt", "/home","/images/**", "/login", "/css/**", "/js/**", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**").permitAll() + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + .requestMatchers(request -> request.getRequestURI() != null && request.getRequestURI().contains("/actuator/")).permitAll() + .requestMatchers( + "/", + "/robots.txt", + "/home", + "/images/**", + "/login", + "/css/**", + "/js/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/actuator/**", + "/grafana", + "/grafana/**", + "/prometheus", + "/prometheus/**" + ).permitAll() .requestMatchers("/api/auth/logout").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") @@ -121,4 +156,4 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java index a9eacd95..57b009c7 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java @@ -15,7 +15,11 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { String requestURI = request.getRequestURI(); - if(requestURI.startsWith("/actuator/") || + if(requestURI.contains("/actuator/") || + requestURI.equals("/grafana") || + requestURI.startsWith("/grafana/") || + requestURI.equals("/prometheus") || + requestURI.startsWith("/prometheus/") || requestURI.startsWith("/api/auth") || requestURI.equals("/") || requestURI.equals("/robots.txt") || diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java index bff09f35..fe5c874a 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java @@ -36,7 +36,11 @@ protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); // JWT 필터를 건너뛸 경로들 - return path.startsWith("/actuator/") || + return path.contains("/actuator/") || + path.equals("/grafana") || + path.startsWith("/grafana/") || + path.equals("/prometheus") || + path.startsWith("/prometheus/") || path.equals("/") || path.equals("/robots.txt") || path.equals("/home") || @@ -89,4 +93,4 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java index a1be1c6b..16d7b18e 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java @@ -9,6 +9,8 @@ import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; @@ -29,6 +31,7 @@ public class FcmNotificationConsumer { private final FcmTokenRepository fcmTokenRepository; private final FirebaseMessaging firebaseMessaging; private final DiscordWebhookNotificationService discordWebhookNotificationService; + private final MeterRegistry meterRegistry; /** * FCM 푸시 알림 메시지 수신 및 처리 @@ -85,6 +88,7 @@ public void handleNotificationMessage(FcmNotificationMessage message) { * 특정 디바이스로 FCM 전송 */ private void sendToDevice(String token, FcmNotificationMessage message) throws FirebaseMessagingException { + Timer.Sample sample = Timer.start(meterRegistry); Message fcmMessage = Message.builder() .setToken(token) .setNotification(Notification.builder() @@ -94,8 +98,14 @@ private void sendToDevice(String token, FcmNotificationMessage message) throws F .putAllData(message.getData()) .build(); - String messageId = firebaseMessaging.send(fcmMessage); - log.debug("[FCM Notification Consumer] FCM 전송 성공 - messageId: {}", messageId); + try { + String messageId = firebaseMessaging.send(fcmMessage); + recordExternalCall("firebase", "send_notification_async", "success", sample); + log.debug("[FCM Notification Consumer] FCM 전송 성공 - messageId: {}", messageId); + } catch (FirebaseMessagingException e) { + recordExternalCall("firebase", "send_notification_async", "failure", sample); + throw e; + } } /** @@ -131,4 +141,16 @@ public void handleNotificationDLQ(FcmNotificationMessage message) { log.error("[FCM Notification DLQ] Discord 알림 전송 실패: {}", e.getMessage()); } } -} \ No newline at end of file + + private void recordExternalCall(String dependency, String operation, String outcome, Timer.Sample sample) { + sample.stop( + Timer.builder("ono.external.requests") + .description("External dependency call latency") + .publishPercentileHistogram() + .tag("dependency", dependency) + .tag("operation", operation) + .tag("outcome", outcome) + .register(meterRegistry) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java b/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java new file mode 100644 index 00000000..bfa58b76 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java @@ -0,0 +1,32 @@ +package com.aisip.OnO.backend.learningreport.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.learningreport.service.LearningReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.context.SecurityContextHolder; +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; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/learning-reports") +public class LearningReportController { + + private final LearningReportService learningReportService; + + @GetMapping("") + public CommonResponse getLearningReport( + @RequestParam(value = "baseDate", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate baseDate + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + LearningReportResponseDto report = learningReportService.getLearningReport(userId, baseDate); + return CommonResponse.success(report); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningComparison.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningComparison.java new file mode 100644 index 00000000..49ad22fd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningComparison.java @@ -0,0 +1,14 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +@Builder +public record LearningComparison( + String basePeriod, + String compareTo, + Double reviewCountChangeRate, + Double averageAccuracyChangeRate, + Double consecutiveLearningDaysChangeRate, + Double averageStudyTimeChangeRate +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningPeriodReport.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningPeriodReport.java new file mode 100644 index 00000000..1c306254 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningPeriodReport.java @@ -0,0 +1,22 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record LearningPeriodReport( + String periodLabel, + LocalDate startDate, + LocalDate endDate, + Long reviewCount, + Long noteWriteCount, + Long notePracticeCount, + Double averageAccuracy, + Integer consecutiveLearningDays, + Double averageStudyTimeMinutes, + List trend, + List weakAreas +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningRecommendations.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningRecommendations.java new file mode 100644 index 00000000..0b4999cb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningRecommendations.java @@ -0,0 +1,15 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record LearningRecommendations( + List strengths, + List gaps, + List actions, + String nextWeekGoal, + Double confidence +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportResponseDto.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportResponseDto.java new file mode 100644 index 00000000..2467f8ae --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportResponseDto.java @@ -0,0 +1,13 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +@Builder +public record LearningReportResponseDto( + LearningPeriodReport weekly, + LearningPeriodReport monthly, + LearningPeriodReport total, + LearningComparison weeklyComparison, + LearningComparison monthlyComparison, + LearningRecommendations recommendations +) {} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningTrendPoint.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningTrendPoint.java new file mode 100644 index 00000000..e05f9f0a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningTrendPoint.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +@Builder +public record LearningTrendPoint( + String label, + Long reviewCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningWeakArea.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningWeakArea.java new file mode 100644 index 00000000..1089b9d4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningWeakArea.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +@Builder +public record LearningWeakArea( + String topic, + Long wrongCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/repository/LearningReportQueryRepository.java b/src/main/java/com/aisip/OnO/backend/learningreport/repository/LearningReportQueryRepository.java new file mode 100644 index 00000000..27e69277 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/repository/LearningReportQueryRepository.java @@ -0,0 +1,238 @@ +package com.aisip.OnO.backend.learningreport.repository; + +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.DateExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.aisip.OnO.backend.problem.entity.QProblem.problem; +import static com.aisip.OnO.backend.problem.entity.QProblemAnalysis.problemAnalysis; +import static com.aisip.OnO.backend.problemsolve.entity.QProblemSolve.problemSolve; +import static com.aisip.OnO.backend.mission.entity.QMissionLog.missionLog; + +@Repository +public class LearningReportQueryRepository { + + private final JPAQueryFactory queryFactory; + + public LearningReportQueryRepository(EntityManager entityManager) { + this.queryFactory = new JPAQueryFactory(entityManager); + } + + public Long countReviewsInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return queryFactory + .select(problemSolve.count()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .fetchOne(); + } + + public Long countReviewsTotal(Long userId) { + return queryFactory + .select(problemSolve.count()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId)) + .fetchOne(); + } + + public Long countNoteWritesInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return queryFactory + .select(problem.count()) + .from(problem) + .where(problem.userId.eq(userId) + .and(problem.createdAt.between(start, end))) + .fetchOne(); + } + + public Long countNoteWritesTotal(Long userId) { + return queryFactory + .select(problem.count()) + .from(problem) + .where(problem.userId.eq(userId)) + .fetchOne(); + } + + public Long countNotePracticesInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return queryFactory + .select(missionLog.count()) + .from(missionLog) + .where(missionLog.user.id.eq(userId) + .and(missionLog.missionType.eq(MissionType.NOTE_PRACTICE)) + .and(missionLog.createdAt.between(start, end))) + .fetchOne(); + } + + public Long countNotePracticesTotal(Long userId) { + return queryFactory + .select(missionLog.count()) + .from(missionLog) + .where(missionLog.user.id.eq(userId) + .and(missionLog.missionType.eq(MissionType.NOTE_PRACTICE))) + .fetchOne(); + } + + public Double averageAccuracyInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return queryFactory + .select(accuracyScore().avg()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .fetchOne(); + } + + public Double averageAccuracyTotal(Long userId) { + return queryFactory + .select(accuracyScore().avg()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId)) + .fetchOne(); + } + + public Double averageStudyTimeInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return queryFactory + .select(problemSolve.timeSpentSeconds.avg()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end)) + .and(problemSolve.timeSpentSeconds.isNotNull()) + .and(problemSolve.timeSpentSeconds.gt(0))) + .fetchOne(); + } + + public Double averageStudyTimeTotal(Long userId) { + return queryFactory + .select(problemSolve.timeSpentSeconds.avg()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.timeSpentSeconds.isNotNull()) + .and(problemSolve.timeSpentSeconds.gt(0))) + .fetchOne(); + } + + public List findDailySolveCounts(Long userId, LocalDateTime start, LocalDateTime end) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + + List rows = queryFactory + .select(practicedDate, problemSolve.count()) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .groupBy(practicedDate) + .orderBy(practicedDate.asc()) + .fetch(); + + return rows.stream() + .map(row -> new DailySolveCount( + row.get(practicedDate).toLocalDate(), + row.get(problemSolve.count()) + )) + .toList(); + } + + public List findDistinctPracticeDatesInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + + return queryFactory + .select(practicedDate) + .distinct() + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .orderBy(practicedDate.asc()) + .fetch() + .stream() + .map(Date::toLocalDate) + .toList(); + } + + public List findDistinctPracticeDatesTotal(Long userId) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + + return queryFactory + .select(practicedDate) + .distinct() + .from(problemSolve) + .where(problemSolve.userId.eq(userId)) + .orderBy(practicedDate.asc()) + .fetch() + .stream() + .map(Date::toLocalDate) + .toList(); + } + + public List findTopWeakAreasInPeriod(Long userId, LocalDateTime start, LocalDateTime end, int limit) { + List rows = queryFactory + .select(problemAnalysis.problemType, problemSolve.count()) + .from(problemSolve) + .join(problemSolve.problem, problem) + .join(problem.problemAnalysis, problemAnalysis) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.answerStatus.eq(AnswerStatus.WRONG)) + .and(problemSolve.practicedAt.between(start, end)) + .and(problemAnalysis.problemType.isNotNull())) + .groupBy(problemAnalysis.problemType) + .orderBy(problemSolve.count().desc()) + .limit(limit) + .fetch(); + + return rows.stream() + .map(row -> new WeakAreaCount( + row.get(problemAnalysis.problemType), + row.get(problemSolve.count()) + )) + .toList(); + } + + public List findTopWeakAreasTotal(Long userId, int limit) { + List rows = queryFactory + .select(problemAnalysis.problemType, problemSolve.count()) + .from(problemSolve) + .join(problemSolve.problem, problem) + .join(problem.problemAnalysis, problemAnalysis) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.answerStatus.eq(AnswerStatus.WRONG)) + .and(problemAnalysis.problemType.isNotNull())) + .groupBy(problemAnalysis.problemType) + .orderBy(problemSolve.count().desc()) + .limit(limit) + .fetch(); + + return rows.stream() + .map(row -> new WeakAreaCount( + row.get(problemAnalysis.problemType), + row.get(problemSolve.count()) + )) + .toList(); + } + + private NumberExpression accuracyScore() { + return new CaseBuilder() + .when(problemSolve.answerStatus.eq(AnswerStatus.CORRECT)).then(1.0) + .when(problemSolve.answerStatus.eq(AnswerStatus.PARTIAL)).then(0.5) + .otherwise(0.0); + } + + public record DailySolveCount(LocalDate practicedDate, Long solveCount) { + } + + public record WeakAreaCount(String topic, Long wrongCount) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java new file mode 100644 index 00000000..a37e2621 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java @@ -0,0 +1,461 @@ +package com.aisip.OnO.backend.learningreport.service; + +import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.learningreport.dto.LearningComparison; +import com.aisip.OnO.backend.learningreport.dto.LearningPeriodReport; +import com.aisip.OnO.backend.learningreport.dto.LearningRecommendations; +import com.aisip.OnO.backend.learningreport.dto.LearningTrendPoint; +import com.aisip.OnO.backend.learningreport.dto.LearningWeakArea; +import com.aisip.OnO.backend.learningreport.repository.LearningReportQueryRepository; +import com.aisip.OnO.backend.util.ai.OpenAIClient; +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LearningReportService { + + private static final int TOP_WEAK_AREAS_LIMIT = 3; + private static final String CACHE_KEY_PREFIX = "LEARNING_REPORT"; + + private final LearningReportQueryRepository reportRepository; + private final OpenAIClient openAIClient; + private final RedisSingleDataService redisSingleDataService; + private final ObjectMapper objectMapper; + + public LearningReportResponseDto getLearningReport(Long userId, LocalDate baseDate) { + LocalDate targetDate = resolveTargetDate(baseDate); + String cacheKey = buildCacheKey(userId, LocalDate.now(), targetDate); + + LearningReportResponseDto cached = readCache(cacheKey); + if (cached != null) { + return cached; + } + + DateRange weekRange = weekRange(targetDate); + DateRange previousWeekRange = previousWeekRange(weekRange); + DateRange monthRange = monthRange(targetDate); + DateRange previousMonthRange = previousMonthRange(monthRange); + + LearningPeriodReport weekly = buildPeriodReport(userId, "WEEKLY", weekRange, TrendType.DAILY); + LearningPeriodReport previousWeekly = buildPeriodReport(userId, "PREVIOUS_WEEKLY", previousWeekRange, TrendType.DAILY); + LearningPeriodReport monthly = buildPeriodReport(userId, "MONTHLY", monthRange, TrendType.WEEKLY); + LearningPeriodReport previousMonthly = buildPeriodReport(userId, "PREVIOUS_MONTHLY", previousMonthRange, TrendType.WEEKLY); + LearningPeriodReport total = buildTotalReport(userId, targetDate); + LearningComparison weeklyComparison = buildComparison("WEEKLY", "PREVIOUS_WEEKLY", weekly, previousWeekly); + LearningComparison monthlyComparison = buildComparison("MONTHLY", "PREVIOUS_MONTHLY", monthly, previousMonthly); + LearningRecommendations recommendations = buildRecommendations( + userId, weekly, monthly, total, weeklyComparison, monthlyComparison + ); + + LearningReportResponseDto report = LearningReportResponseDto.builder() + .weekly(weekly) + .monthly(monthly) + .total(total) + .weeklyComparison(weeklyComparison) + .monthlyComparison(monthlyComparison) + .recommendations(recommendations) + .build(); + + writeCache(cacheKey, report); + return report; + } + + private LearningPeriodReport buildPeriodReport( + Long userId, String label, DateRange range, TrendType trendType + ) { + LocalDateTime start = range.start().atStartOfDay(); + LocalDateTime end = range.end().atTime(23, 59, 59); + + Long reviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, start, end)); + Long noteWriteCount = defaultLong(reportRepository.countNoteWritesInPeriod(userId, start, end)); + Long notePracticeCount = defaultLong(reportRepository.countNotePracticesInPeriod(userId, start, end)); + Double avgAccuracy = toPercent(reportRepository.averageAccuracyInPeriod(userId, start, end)); + Double avgStudyTime = secondsToMinutes(reportRepository.averageStudyTimeInPeriod(userId, start, end)); + List practiceDates = reportRepository.findDistinctPracticeDatesInPeriod(userId, start, end); + + return LearningPeriodReport.builder() + .periodLabel(label) + .startDate(range.start()) + .endDate(range.end()) + .reviewCount(reviewCount) + .noteWriteCount(noteWriteCount) + .notePracticeCount(notePracticeCount) + .averageAccuracy(avgAccuracy) + .consecutiveLearningDays(calculateLongestStreak(practiceDates)) + .averageStudyTimeMinutes(avgStudyTime) + .trend(buildTrend(userId, range.start(), range.end(), trendType)) + .weakAreas(buildWeakAreasInPeriod(userId, start, end)) + .build(); + } + + private LearningPeriodReport buildTotalReport(Long userId, LocalDate baseDate) { + Long reviewCount = defaultLong(reportRepository.countReviewsTotal(userId)); + Long noteWriteCount = defaultLong(reportRepository.countNoteWritesTotal(userId)); + Long notePracticeCount = defaultLong(reportRepository.countNotePracticesTotal(userId)); + Double avgAccuracy = toPercent(reportRepository.averageAccuracyTotal(userId)); + Double avgStudyTime = secondsToMinutes(reportRepository.averageStudyTimeTotal(userId)); + List practiceDates = reportRepository.findDistinctPracticeDatesTotal(userId); + + LocalDate trendStart = baseDate.minusMonths(5).withDayOfMonth(1); + LocalDate trendEnd = YearMonth.from(baseDate).atEndOfMonth(); + + return LearningPeriodReport.builder() + .periodLabel("TOTAL") + .startDate(null) + .endDate(baseDate) + .reviewCount(reviewCount) + .noteWriteCount(noteWriteCount) + .notePracticeCount(notePracticeCount) + .averageAccuracy(avgAccuracy) + .consecutiveLearningDays(calculateLongestStreak(practiceDates)) + .averageStudyTimeMinutes(avgStudyTime) + .trend(buildTrend(userId, trendStart, trendEnd, TrendType.MONTHLY)) + .weakAreas(buildWeakAreasTotal(userId)) + .build(); + } + + private LearningComparison buildComparison( + String basePeriod, + String compareTo, + LearningPeriodReport current, + LearningPeriodReport previous + ) { + return LearningComparison.builder() + .basePeriod(basePeriod) + .compareTo(compareTo) + .reviewCountChangeRate(changeRate(current.reviewCount().doubleValue(), previous.reviewCount().doubleValue())) + .averageAccuracyChangeRate(changeRate(current.averageAccuracy(), previous.averageAccuracy())) + .consecutiveLearningDaysChangeRate(changeRate( + current.consecutiveLearningDays().doubleValue(), + previous.consecutiveLearningDays().doubleValue() + )) + .averageStudyTimeChangeRate(changeRate(current.averageStudyTimeMinutes(), previous.averageStudyTimeMinutes())) + .build(); + } + + private List buildTrend(Long userId, LocalDate startDate, LocalDate endDate, TrendType trendType) { + LocalDateTime start = startDate.atStartOfDay(); + LocalDateTime end = endDate.atTime(23, 59, 59); + + Map bucket = initializeTrendBuckets(startDate, endDate, trendType); + + List rows = + reportRepository.findDailySolveCounts(userId, start, end); + for (LearningReportQueryRepository.DailySolveCount row : rows) { + String key = trendKey(row.practicedDate(), endDate, trendType); + bucket.computeIfPresent(key, (k, v) -> v + defaultLong(row.solveCount())); + } + + return bucket.entrySet().stream() + .map(e -> LearningTrendPoint.builder() + .label(e.getKey()) + .reviewCount(e.getValue()) + .build()) + .toList(); + } + + private Map initializeTrendBuckets(LocalDate startDate, LocalDate endDate, TrendType trendType) { + Map buckets = new LinkedHashMap<>(); + if (trendType == TrendType.DAILY) { + LocalDate cursor = startDate; + while (!cursor.isAfter(endDate)) { + buckets.put(cursor.toString(), 0L); + cursor = cursor.plusDays(1); + } + return buckets; + } + + if (trendType == TrendType.WEEKLY) { + // 최근 4주(지난 4주 -> 지난 1주) 버킷 고정 + for (int week = 4; week >= 1; week--) { + buckets.put(weekLabel(week), 0L); + } + return buckets; + } + + LocalDate cursor = startDate.withDayOfMonth(1); + while (!cursor.isAfter(endDate)) { + buckets.put(yearMonthLabel(cursor), 0L); + cursor = cursor.plusMonths(1); + } + return buckets; + } + + private String trendKey(LocalDate date, LocalDate rangeEndDate, TrendType trendType) { + if (trendType == TrendType.DAILY) { + return date.toString(); + } + if (trendType == TrendType.WEEKLY) { + return weekLabel(weekOfRollingRange(date, rangeEndDate)); + } + return yearMonthLabel(date); + } + + private String yearMonthLabel(LocalDate date) { + return date.getYear() + "-" + String.format("%02d", date.getMonthValue()); + } + + private int weekOfRollingRange(LocalDate date, LocalDate rangeEndDate) { + long diffDays = ChronoUnit.DAYS.between(date, rangeEndDate); + if (diffDays < 0) { + return 1; + } + return (int) (diffDays / 7) + 1; + } + + private String weekLabel(int week) { + return "지난 " + week + "주"; + } + + private List buildWeakAreasInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return reportRepository.findTopWeakAreasInPeriod(userId, start, end, TOP_WEAK_AREAS_LIMIT) + .stream() + .map(w -> LearningWeakArea.builder() + .topic(w.topic()) + .wrongCount(defaultLong(w.wrongCount())) + .build()) + .toList(); + } + + private List buildWeakAreasTotal(Long userId) { + return reportRepository.findTopWeakAreasTotal(userId, TOP_WEAK_AREAS_LIMIT) + .stream() + .map(w -> LearningWeakArea.builder() + .topic(w.topic()) + .wrongCount(defaultLong(w.wrongCount())) + .build()) + .toList(); + } + + private int calculateLongestStreak(List sortedDates) { + if (sortedDates.isEmpty()) { + return 0; + } + int maxStreak = 1; + int currentStreak = 1; + for (int i = 1; i < sortedDates.size(); i++) { + LocalDate prev = sortedDates.get(i - 1); + LocalDate cur = sortedDates.get(i); + if (cur.equals(prev.plusDays(1))) { + currentStreak++; + } else { + currentStreak = 1; + } + maxStreak = Math.max(maxStreak, currentStreak); + } + return maxStreak; + } + + private Double changeRate(Double current, Double previous) { + double cur = current == null ? 0.0 : current; + double prev = previous == null ? 0.0 : previous; + if (prev == 0.0) { + return cur == 0.0 ? 0.0 : 100.0; + } + return ((cur - prev) / prev) * 100.0; + } + + private Double toPercent(Double rawAccuracy) { + return rawAccuracy == null ? 0.0 : rawAccuracy * 100.0; + } + + private Double secondsToMinutes(Double seconds) { + return seconds == null ? 0.0 : seconds / 60.0; + } + + private Long defaultLong(Long value) { + return value == null ? 0L : value; + } + + private LearningRecommendations buildRecommendations( + Long userId, + LearningPeriodReport weekly, + LearningPeriodReport monthly, + LearningPeriodReport total, + LearningComparison weeklyComparison, + LearningComparison monthlyComparison + ) { + LearningRecommendations fallback = buildRuleBasedRecommendations(weekly, monthly, total, weeklyComparison, monthlyComparison); + + Map summaryPayload = new LinkedHashMap<>(); + summaryPayload.put("userId", userId); + summaryPayload.put("weekly", weekly); + summaryPayload.put("monthly", monthly); + summaryPayload.put("total", total); + summaryPayload.put("weeklyComparison", weeklyComparison); + summaryPayload.put("monthlyComparison", monthlyComparison); + summaryPayload.put("ruleBasedRecommendations", fallback); + + return openAIClient.recommendLearningReport(summaryPayload) + .map(ai -> mergeRecommendations(fallback, ai)) + .orElse(fallback); + } + + private LearningRecommendations mergeRecommendations(LearningRecommendations fallback, LearningRecommendations ai) { + return LearningRecommendations.builder() + .strengths(safeList(ai.strengths(), fallback.strengths())) + .gaps(safeList(ai.gaps(), fallback.gaps())) + .actions(safeList(ai.actions(), fallback.actions())) + .nextWeekGoal(safeString(ai.nextWeekGoal(), fallback.nextWeekGoal())) + .confidence(ai.confidence() == null ? fallback.confidence() : ai.confidence()) + .build(); + } + + private LearningRecommendations buildRuleBasedRecommendations( + LearningPeriodReport weekly, + LearningPeriodReport monthly, + LearningPeriodReport total, + LearningComparison weeklyComparison, + LearningComparison monthlyComparison + ) { + List strengths = new java.util.ArrayList<>(); + List gaps = new java.util.ArrayList<>(); + List actions = new java.util.ArrayList<>(); + + if (weekly.consecutiveLearningDays() >= 3) { + strengths.add("최근 주차에 연속 학습 흐름이 안정적으로 유지되고 있습니다."); + } + if (weeklyComparison.averageAccuracyChangeRate() > 0) { + strengths.add("이전 주 대비 정답률이 상승했습니다."); + } + if (monthlyComparison.reviewCountChangeRate() > 0) { + strengths.add("이전 달 대비 복습량이 증가해 학습 루틴이 강화되고 있습니다."); + } + if (strengths.isEmpty()) { + strengths.add("기록이 누적되고 있어 개인화 분석의 정확도가 점점 좋아지고 있습니다."); + } + + if (weekly.averageAccuracy() < 50.0) { + gaps.add("최근 주간 정답률이 낮아 취약 유형에 대한 집중 복습이 필요합니다."); + } + if (weekly.averageStudyTimeMinutes() < 5.0) { + gaps.add("문제당 학습 시간이 짧아 오답 원인 점검이 충분하지 않을 수 있습니다."); + } + if (!weekly.weakAreas().isEmpty()) { + gaps.add("오답이 반복된 유형이 있어 개념 복습 우선순위 조정이 필요합니다."); + } + if (gaps.isEmpty()) { + gaps.add("큰 약점은 없지만 학습량 변동을 줄이면 성과를 더 안정화할 수 있습니다."); + } + + String topWeakArea = weekly.weakAreas().isEmpty() ? "최근 오답 유형" : weekly.weakAreas().get(0).topic(); + actions.add(topWeakArea + " 유형 문제를 다음 주에 3문제 이상 재풀이하세요."); + actions.add("오답 문제를 푼 뒤 5분 동안 틀린 이유와 개선점을 1문장씩 기록하세요."); + actions.add("연속 학습일 목표를 최소 " + Math.max(3, weekly.consecutiveLearningDays()) + "일로 설정하세요."); + + int nextWeekReviewTarget = Math.max(weekly.reviewCount().intValue() + 2, 5); + String nextWeekGoal = "다음 주에는 복습 " + nextWeekReviewTarget + "회, 평균 정답률 " + + Math.max(60, weekly.averageAccuracy().intValue()) + "%를 목표로 하세요."; + + Double confidence = total.reviewCount() >= 20 ? 85.0 : 70.0; + + return LearningRecommendations.builder() + .strengths(limitSize(strengths, 3)) + .gaps(limitSize(gaps, 3)) + .actions(limitSize(actions, 3)) + .nextWeekGoal(nextWeekGoal) + .confidence(confidence) + .build(); + } + + private List safeList(List target, List fallback) { + if (target == null || target.isEmpty()) { + return fallback; + } + return limitSize(target, 3); + } + + private String safeString(String target, String fallback) { + if (target == null || target.isBlank()) { + return fallback; + } + return target; + } + + private List limitSize(List source, int limit) { + if (source.size() <= limit) { + return source; + } + return source.subList(0, limit); + } + + private enum TrendType { + DAILY, + WEEKLY, + MONTHLY + } + + private DateRange weekRange(LocalDate targetDate) { + return new DateRange(targetDate.minusDays(6), targetDate); + } + + private DateRange previousWeekRange(DateRange weekRange) { + LocalDate end = weekRange.start().minusDays(1); + return new DateRange(end.minusDays(6), end); + } + + private DateRange monthRange(LocalDate targetDate) { + return new DateRange(targetDate.minusDays(27), targetDate); + } + + private DateRange previousMonthRange(DateRange monthRange) { + LocalDate end = monthRange.start().minusDays(1); + return new DateRange(end.minusDays(27), end); + } + + private record DateRange(LocalDate start, LocalDate end) { + } + + private LocalDate resolveTargetDate(LocalDate baseDate) { + return baseDate == null ? LocalDate.now().minusDays(1) : baseDate; + } + + private String buildCacheKey(Long userId, LocalDate requestDate, LocalDate targetDate) { + return CACHE_KEY_PREFIX + ":" + requestDate + ":" + userId + ":" + targetDate; + } + + private LearningReportResponseDto readCache(String cacheKey) { + try { + String cachedJson = redisSingleDataService.getSingleData(cacheKey); + if (cachedJson == null || cachedJson.isBlank()) { + return null; + } + return objectMapper.readValue(cachedJson, LearningReportResponseDto.class); + } catch (Exception e) { + log.warn("Failed to read learning report cache. key={}, reason={}", cacheKey, e.getMessage()); + return null; + } + } + + private void writeCache(String cacheKey, LearningReportResponseDto report) { + try { + String json = objectMapper.writeValueAsString(report); + redisSingleDataService.setSingleData(cacheKey, json, ttlUntilNextMidnight()); + } catch (Exception e) { + log.warn("Failed to write learning report cache. key={}, reason={}", cacheKey, e.getMessage()); + } + } + + private Duration ttlUntilNextMidnight() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(); + Duration ttl = Duration.between(now, nextMidnight); + return ttl.isNegative() || ttl.isZero() ? Duration.ofSeconds(1) : ttl; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index bbecbc50..80e6a354 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -4,8 +4,8 @@ import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; -import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import com.aisip.OnO.backend.problem.service.ProblemAnalysisService; import com.aisip.OnO.backend.problem.service.ProblemService; @@ -86,6 +86,15 @@ public CommonResponse getProblemAnalysis(@PathVariab return CommonResponse.success(analysisResponseDto); } + // ✅ 문제 분석 요청 (비동기 트리거) + @PostMapping("/{problemId}/analysis") + public CommonResponse requestProblemAnalysis(@PathVariable("problemId") Long problemId) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + problemService.analysisProblem(problemId); + + return CommonResponse.success("문제 분석 요청이 접수되었습니다."); + } + // ✅ 문제 등록 @PostMapping("") public CommonResponse registerProblem(@RequestBody ProblemRegisterDto problemRegisterDto) { @@ -96,6 +105,15 @@ public CommonResponse registerProblem(@RequestBody ProblemRegisterDto prob return CommonResponse.success(problemId); } + // ✅ 문제 등록 v2 (이미지 URL 동시 저장) + @PostMapping("/v2") + public CommonResponse registerProblemV2(@RequestBody ProblemRegisterV2Dto problemRegisterV2Dto) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + Long problemId = problemService.registerProblemV2(problemRegisterV2Dto, userId); + return CommonResponse.success(problemId); + } + // ✅ 문제 이미지 비동기 업로드 @PostMapping("/{problemId}/imageData") public CommonResponse uploadProblemImages( diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java new file mode 100644 index 00000000..2385e2c2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.problem.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record ProblemRegisterV2Dto( + Long problemId, + String memo, + String reference, + Long folderId, + LocalDateTime solvedAt, + List problemImageUrls, + List answerImageUrls +) { +} + diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java index ecd57435..6afcbfdd 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java @@ -22,7 +22,7 @@ @SQLRestriction("deleted_at IS NULL") @Table(name = "problem", indexes = { @Index(name = "idx_problem_folder_id", columnList = "folder_id"), - @Index(name = "idx_problem_user_id", columnList = "userId") + @Index(name = "idx_problem_user_created", columnList = "user_id, created_at") }) public class Problem extends BaseEntity { @@ -110,4 +110,4 @@ public void removePracticeMappingFromProblem(ProblemPracticeNoteMapping problemP public void updateProblemAnalysis(ProblemAnalysis analysis) { this.problemAnalysis = analysis; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 8851dd17..3ba9eff8 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -9,6 +9,7 @@ import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.problem.entity.ProblemImageData; import com.aisip.OnO.backend.folder.exception.FolderErrorCase; @@ -21,9 +22,7 @@ import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Collection; @@ -137,6 +136,59 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) return problem.getId(); } + /** + * 문제 등록 v2 + * - 문제 등록 + 이미지 URL 기반 이미지 엔티티 생성 + 빈 분석 객체 생성 + */ + @Transactional + public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long userId) { + Folder folder = folderRepository.findById(problemRegisterV2Dto.folderId()) + .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); + + ProblemRegisterDto baseDto = new ProblemRegisterDto( + problemRegisterV2Dto.problemId(), + problemRegisterV2Dto.memo(), + problemRegisterV2Dto.reference(), + problemRegisterV2Dto.folderId(), + problemRegisterV2Dto.solvedAt() + ); + + Problem problem = Problem.from(baseDto, userId); + problem.updateFolder(folder); + problemRepository.save(problem); + + if (problemRegisterV2Dto.problemImageUrls() != null) { + problemRegisterV2Dto.problemImageUrls().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(url -> !url.isEmpty()) + .forEach(url -> { + ProblemImageData imageData = ProblemImageData.from( + new ProblemImageDataRegisterDto(problem.getId(), url, ProblemImageType.PROBLEM_IMAGE)); + imageData.updateProblem(problem); + problemImageDataRepository.save(imageData); + }); + } + + if (problemRegisterV2Dto.answerImageUrls() != null) { + problemRegisterV2Dto.answerImageUrls().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(url -> !url.isEmpty()) + .forEach(url -> { + ProblemImageData imageData = ProblemImageData.from( + new ProblemImageDataRegisterDto(problem.getId(), url, ProblemImageType.ANSWER_IMAGE)); + imageData.updateProblem(problem); + problemImageDataRepository.save(imageData); + }); + } + analysisService.createSkippedAnalysis(problem.getId()); + missionLogService.registerProblemWriteMission(userId); + + log.info("userId: {} register problem(v2) problemId: {}", userId, problem.getId()); + return problem.getId(); + } + /** * 문제 이미지 비동기 업로드 및 AI 분석 트리거 */ @@ -322,4 +374,4 @@ public CursorPageResponse findProblemsByFolderWithCursor(Lon log.info("folderId: {} find problems with cursor: {}, size: {}, hasNext: {}", folderId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java index 5b9d8222..9888daa9 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java @@ -12,7 +12,9 @@ import org.hibernate.annotations.SQLRestriction; @Entity -@Table(name = "problem_solve") +@Table(name = "problem_solve", indexes = { + @Index(name = "idx_problem_solve_user_practiced_at", columnList = "user_id, practiced_at") +}) @Getter @Builder(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/aisip/OnO/backend/util/ai/OpenAIClient.java b/src/main/java/com/aisip/OnO/backend/util/ai/OpenAIClient.java index 63f8eecf..d485bcbc 100644 --- a/src/main/java/com/aisip/OnO/backend/util/ai/OpenAIClient.java +++ b/src/main/java/com/aisip/OnO/backend/util/ai/OpenAIClient.java @@ -1,9 +1,13 @@ package com.aisip.OnO.backend.util.ai; +import com.aisip.OnO.backend.learningreport.dto.LearningRecommendations; import com.aisip.OnO.backend.util.ai.dto.*; import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -18,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; @Slf4j @Component @@ -26,6 +31,7 @@ public class OpenAIClient { private final RestTemplate restTemplate; private final ObjectMapper objectMapper; + private final MeterRegistry meterRegistry; private static final ObjectMapper LENIENT_OBJECT_MAPPER = JsonMapper.builder() .enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER) .build(); @@ -39,10 +45,14 @@ public class OpenAIClient { @Value("${openai.api.url}") private String apiUrl; + @Value("${learning-report.ai.enabled:true}") + private boolean learningReportAiEnabled; + /** * 여러 이미지 URL을 분석하여 문제에 대한 정보를 추출합니다. */ public ProblemAnalysisResult analyzeImages(List imageUrls) { + Timer.Sample sample = Timer.start(meterRegistry); try { log.info("Starting image analysis for {} images", imageUrls.size()); @@ -81,9 +91,11 @@ public ProblemAnalysisResult analyzeImages(List imageUrls) { log.info("Received response from OpenAI: {}", content); // 6. JSON 응답 파싱 + recordExternalCall("openai", "analyze_images", "success", sample); return parseResponse(content); } catch (HttpClientErrorException e) { + recordExternalCall("openai", "analyze_images", "failure", sample); String responseBody = e.getResponseBodyAsString(); if (isNonRetryableQuotaError(e, responseBody)) { throw new NonRetryableAnalysisException("OpenAI 할당량이 초과되어 분석을 진행할 수 없습니다.", e); @@ -91,9 +103,11 @@ public ProblemAnalysisResult analyzeImages(List imageUrls) { log.error("Error analyzing images: {}", e.getMessage(), e); throw new RuntimeException("AI 이미지 분석 중 오류가 발생했습니다: " + e.getMessage(), e); } catch (NonRetryableAnalysisException e) { + recordExternalCall("openai", "analyze_images", "failure", sample); log.warn("Non-retryable image analysis failure: {}", e.getMessage()); throw e; } catch (Exception e) { + recordExternalCall("openai", "analyze_images", "failure", sample); log.error("Error analyzing images: {}", e.getMessage(), e); throw new RuntimeException("AI 이미지 분석 중 오류가 발생했습니다: " + e.getMessage(), e); } @@ -106,6 +120,69 @@ public ProblemAnalysisResult analyzeImage(String imageUrl) { return analyzeImages(List.of(imageUrl)); } + public Optional recommendLearningReport(Map summaryPayload) { + if (!learningReportAiEnabled) { + return Optional.empty(); + } + + Timer.Sample sample = Timer.start(meterRegistry); + try { + String summaryJson = objectMapper.writeValueAsString(summaryPayload); + List messages = List.of( + Message.builder() + .role("system") + .content(createLearningReportSystemPrompt()) + .build(), + Message.builder() + .role("user") + .content("학습 요약 데이터: " + summaryJson) + .build() + ); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model(model) + .messages(messages) + .maxTokens(700) + .temperature(0.3) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + ResponseEntity response = restTemplate.postForEntity( + apiUrl, + new HttpEntity<>(request, headers), + ChatCompletionResponse.class + ); + + String content = response.getBody() + .getChoices() + .get(0) + .getMessage() + .getContent(); + + recordExternalCall("openai", "recommend_learning_report", "success", sample); + return parseLearningRecommendation(content); + } catch (Exception e) { + recordExternalCall("openai", "recommend_learning_report", "failure", sample); + log.warn("AI learning recommendation failed, fallback to rule-based: {}", e.getMessage()); + return Optional.empty(); + } + } + + private void recordExternalCall(String dependency, String operation, String outcome, Timer.Sample sample) { + sample.stop( + Timer.builder("ono.external.requests") + .description("External dependency call latency") + .publishPercentileHistogram() + .tag("dependency", dependency) + .tag("operation", operation) + .tag("outcome", outcome) + .register(meterRegistry) + ); + } + /** * Vision API용 메시지 생성 */ @@ -188,6 +265,23 @@ private String createTextPrompt(int imageCount) { } } + private String createLearningReportSystemPrompt() { + return """ + 너는 학습 리포트 코치다. + 입력으로 받은 요약 지표를 바탕으로 학습 피드백을 JSON으로만 반환해라. + 반드시 아래 스키마를 지켜라. + { + "strengths": ["문장", "문장"], + "gaps": ["문장", "문장"], + "actions": ["실행 가능한 액션", "실행 가능한 액션", "실행 가능한 액션"], + "nextWeekGoal": "수치가 포함된 1문장 목표", + "confidence": 0~100 사이 숫자 + } + 한국어로 작성하고, 각 배열은 2~3개로 제한해라. + JSON 외 텍스트를 출력하지 마라. + """; + } + private ProblemAnalysisResult parseResponse(String response) { try { // JSON 형식이 아닌 경우 처리 @@ -238,6 +332,47 @@ private String extractJsonFromResponse(String response) { return response.trim(); } + private Optional parseLearningRecommendation(String response) { + try { + String jsonContent = extractJsonFromResponse(response); + JsonNode root = objectMapper.readTree(jsonContent); + + LearningRecommendations recommendations = LearningRecommendations.builder() + .strengths(readStringArray(root, "strengths")) + .gaps(readStringArray(root, "gaps")) + .actions(readStringArray(root, "actions")) + .nextWeekGoal(readString(root, "nextWeekGoal", "다음 주 목표를 설정해 3회 이상 복습해보세요.")) + .confidence(readDouble(root, "confidence", 70.0)) + .build(); + + return Optional.of(recommendations); + } catch (Exception e) { + log.warn("AI learning recommendation parsing failed: {}", e.getMessage()); + return Optional.empty(); + } + } + + private List readStringArray(JsonNode root, String field) { + JsonNode node = root.path(field); + if (!node.isArray()) { + return List.of(); + } + return java.util.stream.StreamSupport.stream(node.spliterator(), false) + .filter(JsonNode::isTextual) + .map(JsonNode::asText) + .toList(); + } + + private String readString(JsonNode root, String field, String defaultValue) { + JsonNode node = root.path(field); + return node.isTextual() ? node.asText() : defaultValue; + } + + private Double readDouble(JsonNode root, String field, Double defaultValue) { + JsonNode node = root.path(field); + return node.isNumber() ? node.asDouble() : defaultValue; + } + private boolean looksLikeJsonObject(String content) { return content.startsWith("{") && content.endsWith("}"); } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index a3b9d197..1d0f2901 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -12,6 +12,8 @@ import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +31,7 @@ public class FcmService { private final FcmTokenRepository fcmTokenRepository; private final FirebaseMessaging firebaseMessaging; + private final MeterRegistry meterRegistry; private final FcmNotificationProducer fcmNotificationProducer; @@ -52,6 +55,7 @@ public List findUserTokens(Long userId) { } public void sendNotification(NotificationRequestDto dto) { + Timer.Sample sample = Timer.start(meterRegistry); Message msg = Message.builder() .setToken(dto.token()) .setNotification(Notification.builder() @@ -63,8 +67,10 @@ public void sendNotification(NotificationRequestDto dto) { try { String messageId = firebaseMessaging.send(msg); + recordExternalCall("firebase", "send_notification_sync", "success", sample); log.info("FCM 메시지 전송 성공: {}", messageId); } catch (FirebaseMessagingException e) { + recordExternalCall("firebase", "send_notification_sync", "failure", sample); log.error("FCM 전송 실패", e); throw new ApplicationException(FcmErrorCase.FCM_SEND_FAILED); } @@ -87,6 +93,18 @@ public void sendNotificationToAllUserDevice(Long userId, NotificationRequestDto log.info("FCM 알림 메시지 큐 전송 완료 - userId: {}, title: {}", userId, notificationRequestDto.title()); } + private void recordExternalCall(String dependency, String operation, String outcome, Timer.Sample sample) { + sample.stop( + Timer.builder("ono.external.requests") + .description("External dependency call latency") + .publishPercentileHistogram() + .tag("dependency", dependency) + .tag("operation", operation) + .tag("outcome", outcome) + .register(meterRegistry) + ); + } + /** * [DEPRECATED] 동기 방식 알림 전송 (테스트용으로만 사용) * @deprecated RabbitMQ 방식(sendNotificationToAllUserDevice)을 사용하세요 diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java index 2e4d7a6b..ce766964 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java @@ -5,6 +5,8 @@ import com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.model.DeleteObjectRequest; import com.amazonaws.services.s3.model.ObjectMetadata; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,10 +23,12 @@ @RequiredArgsConstructor public class FileUploadService { private final AmazonS3Client amazonS3Client; + private final MeterRegistry meterRegistry; @Value("${cloud.aws.s3.bucket}") private String bucket; public String uploadFileToS3(MultipartFile file) { + Timer.Sample sample = Timer.start(meterRegistry); String fileName = createFileName(file); String fileUrl = getFileUrl(fileName); @@ -36,20 +40,29 @@ public String uploadFileToS3(MultipartFile file) { try { amazonS3Client.putObject(bucket, fileName, file.getInputStream(), objectMetadata); } catch (IOException e) { + recordExternalCall("s3", "upload", "failure", sample); throw new ApplicationException(FileUploadErrorCase.FILE_UPLOAD_FAILED); } + recordExternalCall("s3", "upload", "success", sample); log.info("file url : " + fileUrl + " has upload to S3"); return fileUrl; } public void deleteImageFileFromS3(String imageUrl) { + Timer.Sample sample = Timer.start(meterRegistry); String splitStr = ".com/"; String fileName = imageUrl.substring(imageUrl.lastIndexOf(splitStr) + splitStr.length()); log.info("file url : " + imageUrl + " has removed from S3"); - amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileName)); + try { + amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileName)); + recordExternalCall("s3", "delete", "success", sample); + } catch (RuntimeException e) { + recordExternalCall("s3", "delete", "failure", sample); + throw e; + } } private String createFileName(MultipartFile file) { @@ -67,4 +80,16 @@ private String createFileName(MultipartFile file) { private String getFileUrl(String fileName) { return "https://" + bucket + ".s3.ap-northeast-2.amazonaws.com/" + fileName; } + + private void recordExternalCall(String dependency, String operation, String outcome, Timer.Sample sample) { + sample.stop( + Timer.builder("ono.external.requests") + .description("External dependency call latency") + .publishPercentileHistogram() + .tag("dependency", dependency) + .tag("operation", operation) + .tag("outcome", outcome) + .register(meterRegistry) + ); + } } diff --git a/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java b/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java index ca3bc38b..f49a1d28 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java @@ -238,42 +238,41 @@ void findAllUserFolders() { } @Test - @DisplayName("루트 폴더 생성 로직 테스트") - void createRootFolder() { - //when - FolderResponseDto rootFolder = folderService.createRootFolder(userId); - - assertThat(rootFolder.folderName()).isEqualTo("책장"); - assertThat(rootFolder.parentFolder()).isNull(); - assertThat(rootFolder.subFolderList()).hasSize(1); - assertThat(rootFolder.subFolderList().get(0).folderName()).isEqualTo("시작하기"); - } - - @Test - @DisplayName("온보딩 폴더 보장 - 루트가 없으면 루트와 기본 하위 폴더 생성") - void ensureOnboardingFolders_CreateRootAndDefaultSubFolder() { + @DisplayName("초기 폴더 보장 - 루트가 없으면 루트와 기본 하위 폴더 생성") + void initializeDefaultFoldersIfAbsent_CreateRootAndDefaultSubFolder() { Long newUserId = 999L; - folderService.ensureOnboardingFolders(newUserId); + folderService.initializeDefaultFoldersIfAbsent(newUserId); Optional optionalRoot = folderRepository.findRootFolder(newUserId); assertThat(optionalRoot).isPresent(); assertThat(optionalRoot.get().getName()).isEqualTo("책장"); assertThat(optionalRoot.get().getSubFolderList()).hasSize(1); - assertThat(optionalRoot.get().getSubFolderList().get(0).getName()).isEqualTo("시작하기"); + assertThat(optionalRoot.get().getSubFolderList().get(0).getName()).isEqualTo("공책"); } @Test - @DisplayName("온보딩 폴더 보장 - 이미 하위 폴더가 있으면 중복 생성하지 않음") - void ensureOnboardingFolders_NoDuplicateSubFolder() { - FolderResponseDto root = folderService.createRootFolder(userId); + @DisplayName("초기 폴더 보장 - 이미 기본 하위 폴더가 있으면 중복 생성하지 않음") + void initializeDefaultFoldersIfAbsent_NoDuplicateSubFolder() { + Long newUserId = 1000L; + Folder root = folderRepository.save(Folder.from( + new FolderRegisterDto("책장", null, null), + newUserId + )); + Folder defaultSubFolder = Folder.from( + new FolderRegisterDto("공책", null, root.getId()), + newUserId + ); + defaultSubFolder.updateParentFolder(root); + folderRepository.save(defaultSubFolder); - folderService.ensureOnboardingFolders(userId); - folderService.ensureOnboardingFolders(userId); + folderService.initializeDefaultFoldersIfAbsent(newUserId); + folderService.initializeDefaultFoldersIfAbsent(newUserId); - Optional optionalRoot = folderRepository.findFolderWithDetailsByFolderId(root.folderId()); + Optional optionalRoot = folderRepository.findFolderWithDetailsByFolderId(root.getId()); assertThat(optionalRoot).isPresent(); assertThat(optionalRoot.get().getSubFolderList()).hasSize(1); + assertThat(optionalRoot.get().getSubFolderList().get(0).getName()).isEqualTo("공책"); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java new file mode 100644 index 00000000..aa5d1338 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java @@ -0,0 +1,130 @@ +package com.aisip.OnO.backend.learningreport.service; + +import com.aisip.OnO.backend.learningreport.dto.LearningRecommendations; +import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.ai.OpenAIClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("local") +@Transactional +class LearningReportServiceAiTest { + + @Autowired + private LearningReportService learningReportService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemAnalysisRepository problemAnalysisRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private OpenAIClient openAIClient; + + @Test + @DisplayName("학습 리포트 조회 - AI 추천 결과가 recommendations에 반영된다") + void getLearningReport_appliesAiRecommendations() { + User user = createUser("ai-user"); + Problem problem = createProblemWithAnalysis(user.getId(), "대수"); + createSolve(problem, user.getId(), LocalDateTime.of(2026, 2, 21, 10, 0), AnswerStatus.WRONG, 300); + + LearningRecommendations aiRecommendations = LearningRecommendations.builder() + .strengths(java.util.List.of("복습 루틴을 유지하고 있습니다.")) + .gaps(java.util.List.of("대수 유형 오답이 반복됩니다.")) + .actions(java.util.List.of("대수 유형 3문제를 다시 풀어보세요.")) + .nextWeekGoal("다음 주 복습 5회를 목표로 하세요.") + .confidence(88.0) + .build(); + + when(openAIClient.recommendLearningReport(anyMap())).thenReturn(Optional.of(aiRecommendations)); + + LearningReportResponseDto report = learningReportService.getLearningReport(user.getId(), LocalDate.of(2026, 2, 21)); + System.out.println("[AI Recommendation Result]"); + try { + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(report.recommendations())); + } catch (Exception e) { + System.out.println(report.recommendations()); + } + + assertThat(report.recommendations()).isNotNull(); + assertThat(report.recommendations().strengths()).containsExactly("복습 루틴을 유지하고 있습니다."); + assertThat(report.recommendations().gaps()).containsExactly("대수 유형 오답이 반복됩니다."); + assertThat(report.recommendations().actions()).containsExactly("대수 유형 3문제를 다시 풀어보세요."); + assertThat(report.recommendations().nextWeekGoal()).isEqualTo("다음 주 복습 5회를 목표로 하세요."); + assertThat(report.recommendations().confidence()).isEqualTo(88.0); + + verify(openAIClient).recommendLearningReport(anyMap()); + } + + private User createUser(String identifier) { + return userRepository.save(User.from(UserRegisterDto.builder() + .email(identifier + "@test.com") + .name(identifier) + .identifier(identifier) + .platform("GOOGLE") + .password("password") + .build())); + } + + private Problem createProblemWithAnalysis(Long userId, String problemType) { + Problem problemEntity = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo-" + problemType, "ref-" + problemType, null, LocalDateTime.now()), + userId + )); + + ProblemAnalysis analysis = ProblemAnalysis.createProcessing(problemEntity); + analysis.updateWithSuccess("수학", problemType, "[]", "solution", "mistake", "tip"); + problemAnalysisRepository.save(analysis); + return problemEntity; + } + + private void createSolve(Problem problemEntity, Long userId, LocalDateTime practicedAt, AnswerStatus status, Integer seconds) { + problemSolveRepository.save(ProblemSolve.create( + problemEntity, + userId, + practicedAt, + status, + null, + null, + seconds + )); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceTest.java b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceTest.java new file mode 100644 index 00000000..7ba5ae33 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceTest.java @@ -0,0 +1,252 @@ +package com.aisip.OnO.backend.learningreport.service; + +import com.aisip.OnO.backend.learningreport.dto.LearningComparison; +import com.aisip.OnO.backend.learningreport.dto.LearningPeriodReport; +import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import jakarta.persistence.EntityManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = "learning-report.ai.enabled=false") +@ActiveProfiles("local") +@Transactional +class LearningReportServiceTest { + + @Autowired + private LearningReportService learningReportService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemAnalysisRepository problemAnalysisRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + @Autowired + private MissionLogRepository missionLogRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("학습 리포트 조회 - 주간/월간/누적 집계가 올바르게 계산된다") + void getLearningReport_success() { + User targetUser = createUser("target-user"); + User otherUser = createUser("other-user"); + Long userId = targetUser.getId(); + + Problem algebraProblem = createProblemWithAnalysis(userId, "대수", LocalDateTime.of(2026, 2, 16, 8, 0)); + Problem geometryProblem = createProblemWithAnalysis(userId, "기하", LocalDateTime.of(2026, 2, 1, 8, 0)); + Problem trigProblem = createProblemWithAnalysis(userId, "삼각함수", LocalDateTime.of(2026, 1, 11, 8, 0)); + createProblemWrite(userId, LocalDateTime.of(2026, 2, 20, 8, 0)); + createProblemWrite(userId, LocalDateTime.of(2026, 1, 10, 8, 0)); + + createSolve(algebraProblem, userId, LocalDateTime.of(2026, 2, 16, 9, 0), AnswerStatus.CORRECT, 600); + createSolve(algebraProblem, userId, LocalDateTime.of(2026, 2, 17, 9, 0), AnswerStatus.WRONG, 300); + createSolve(geometryProblem, userId, LocalDateTime.of(2026, 2, 18, 9, 0), AnswerStatus.PARTIAL, 120); + createSolve(geometryProblem, userId, LocalDateTime.of(2026, 2, 20, 9, 0), AnswerStatus.WRONG, 180); + createSolve(algebraProblem, userId, LocalDateTime.of(2026, 2, 10, 9, 0), AnswerStatus.WRONG, 60); + createSolve(trigProblem, userId, LocalDateTime.of(2026, 2, 1, 9, 0), AnswerStatus.CORRECT, 120); + createSolve(trigProblem, userId, LocalDateTime.of(2026, 1, 10, 9, 0), AnswerStatus.CORRECT, 120); + createSolve(algebraProblem, userId, LocalDateTime.of(2026, 1, 11, 9, 0), AnswerStatus.WRONG, 120); + + createNotePracticeMission(targetUser, 101L, LocalDateTime.of(2026, 2, 17, 11, 0)); + createNotePracticeMission(targetUser, 102L, LocalDateTime.of(2026, 2, 21, 11, 0)); + createNotePracticeMission(targetUser, 103L, LocalDateTime.of(2026, 2, 3, 11, 0)); + createNotePracticeMission(targetUser, 104L, LocalDateTime.of(2026, 1, 10, 11, 0)); + + Problem otherUserProblem = createProblemWithAnalysis(otherUser.getId(), "확률", LocalDateTime.of(2026, 2, 17, 8, 0)); + createSolve(otherUserProblem, otherUser.getId(), LocalDateTime.of(2026, 2, 17, 9, 0), AnswerStatus.WRONG, 999); + createNotePracticeMission(otherUser, 201L, LocalDateTime.of(2026, 2, 18, 11, 0)); + + LearningReportResponseDto report = learningReportService.getLearningReport(userId, LocalDate.of(2026, 2, 21)); + + LearningPeriodReport weekly = report.weekly(); + assertThat(weekly.reviewCount()).isEqualTo(4L); + assertThat(weekly.noteWriteCount()).isEqualTo(2L); + assertThat(weekly.notePracticeCount()).isEqualTo(2L); + assertThat(weekly.averageAccuracy()).isEqualTo(37.5); + assertThat(weekly.consecutiveLearningDays()).isEqualTo(3); + assertThat(weekly.averageStudyTimeMinutes()).isEqualTo(5.0); + + Map weeklyTrend = weekly.trend().stream() + .collect(Collectors.toMap(t -> t.label(), t -> t.reviewCount())); + assertThat(weeklyTrend) + .containsEntry("2026-02-15", 0L) + .containsEntry("2026-02-16", 1L) + .containsEntry("2026-02-17", 1L) + .containsEntry("2026-02-18", 1L) + .containsEntry("2026-02-19", 0L) + .containsEntry("2026-02-20", 1L) + .containsEntry("2026-02-21", 0L) + .doesNotContainKey("2026-02-22"); + + Map weeklyWeakAreaMap = weekly.weakAreas().stream() + .collect(Collectors.toMap(w -> w.topic(), w -> w.wrongCount())); + assertThat(weeklyWeakAreaMap).containsEntry("대수", 1L).containsEntry("기하", 1L); + + LearningPeriodReport monthly = report.monthly(); + assertThat(monthly.reviewCount()).isEqualTo(6L); + assertThat(monthly.noteWriteCount()).isEqualTo(3L); + assertThat(monthly.notePracticeCount()).isEqualTo(3L); + assertThat(monthly.averageAccuracy()).isCloseTo(41.666666, within(0.001)); + assertThat(monthly.consecutiveLearningDays()).isEqualTo(3); + assertThat(monthly.averageStudyTimeMinutes()).isCloseTo(3.833333, within(0.0001)); + Map monthlyTrend = monthly.trend().stream() + .collect(Collectors.toMap(t -> t.label(), t -> t.reviewCount())); + assertThat(monthlyTrend) + .containsEntry("지난 4주", 0L) + .containsEntry("지난 3주", 1L) + .containsEntry("지난 2주", 1L) + .containsEntry("지난 1주", 4L) + .doesNotContainKey("지난 5주"); + + LearningPeriodReport total = report.total(); + assertThat(total.reviewCount()).isEqualTo(8L); + assertThat(total.noteWriteCount()).isEqualTo(5L); + assertThat(total.notePracticeCount()).isEqualTo(4L); + assertThat(total.averageAccuracy()).isCloseTo(43.75, within(0.0001)); + + Map totalTrend = total.trend().stream() + .collect(Collectors.toMap(t -> t.label(), t -> t.reviewCount())); + assertThat(totalTrend).containsEntry("2026-01", 2L).containsEntry("2026-02", 6L); + + Map totalWeakAreaMap = total.weakAreas().stream() + .collect(Collectors.toMap(w -> w.topic(), w -> w.wrongCount())); + assertThat(totalWeakAreaMap).containsEntry("대수", 3L).containsEntry("기하", 1L); + + LearningComparison weeklyComparison = report.weeklyComparison(); + assertThat(weeklyComparison.reviewCountChangeRate()).isEqualTo(300.0); + assertThat(weeklyComparison.averageAccuracyChangeRate()).isEqualTo(100.0); + assertThat(weeklyComparison.consecutiveLearningDaysChangeRate()).isEqualTo(200.0); + assertThat(weeklyComparison.averageStudyTimeChangeRate()).isEqualTo(400.0); + assertThat(report.recommendations()).isNotNull(); + assertThat(report.recommendations().actions()).hasSize(3); + } + + @Test + @DisplayName("학습 리포트 조회 - 데이터가 없으면 0 기반 결과를 반환한다") + void getLearningReport_emptyData() { + User user = createUser("empty-user"); + + LearningReportResponseDto report = learningReportService.getLearningReport(user.getId(), LocalDate.of(2026, 2, 21)); + + assertThat(report.weekly().reviewCount()).isEqualTo(0L); + assertThat(report.weekly().noteWriteCount()).isEqualTo(0L); + assertThat(report.weekly().notePracticeCount()).isEqualTo(0L); + assertThat(report.monthly().reviewCount()).isEqualTo(0L); + assertThat(report.monthly().noteWriteCount()).isEqualTo(0L); + assertThat(report.monthly().notePracticeCount()).isEqualTo(0L); + assertThat(report.total().reviewCount()).isEqualTo(0L); + assertThat(report.total().noteWriteCount()).isEqualTo(0L); + assertThat(report.total().notePracticeCount()).isEqualTo(0L); + assertThat(report.weekly().averageAccuracy()).isEqualTo(0.0); + assertThat(report.monthly().averageStudyTimeMinutes()).isEqualTo(0.0); + assertThat(report.total().weakAreas()).isEmpty(); + assertThat(report.weekly().trend()).hasSize(7); + } + + private User createUser(String identifier) { + return userRepository.save(User.from(UserRegisterDto.builder() + .email(identifier + "@test.com") + .name(identifier) + .identifier(identifier) + .platform("GOOGLE") + .password("password") + .build())); + } + + private Problem createProblemWithAnalysis(Long userId, String problemType, LocalDateTime createdAt) { + Problem problemEntity = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo-" + problemType, "ref-" + problemType, null, LocalDateTime.now()), + userId + )); + updateProblemCreatedAt(problemEntity.getId(), createdAt); + + ProblemAnalysis analysis = ProblemAnalysis.createProcessing(problemEntity); + analysis.updateWithSuccess("수학", problemType, "[]", "solution", "mistake", "tip"); + problemAnalysisRepository.save(analysis); + return problemEntity; + } + + private void createSolve(Problem problemEntity, Long userId, LocalDateTime practicedAt, AnswerStatus status, Integer seconds) { + problemSolveRepository.save(ProblemSolve.create( + problemEntity, + userId, + practicedAt, + status, + null, + null, + seconds + )); + } + + private void createProblemWrite(Long userId, LocalDateTime createdAt) { + Problem problemEntity = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo-write-" + createdAt, "ref-write-" + createdAt, null, LocalDateTime.now()), + userId + )); + updateProblemCreatedAt(problemEntity.getId(), createdAt); + } + + private void updateProblemCreatedAt(Long problemId, LocalDateTime createdAt) { + entityManager.flush(); + entityManager.createNativeQuery("UPDATE problem SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", problemId) + .executeUpdate(); + } + + private void createNotePracticeMission(User user, Long referenceId, LocalDateTime createdAt) { + MissionLog missionLog = missionLogRepository.save(MissionLog.from( + MissionRegisterDto.builder() + .userId(user.getId()) + .missionType(MissionType.NOTE_PRACTICE) + .referenceId(referenceId) + .build(), + user + )); + + entityManager.flush(); + entityManager.createNativeQuery("UPDATE mission_log SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", missionLog.getId()) + .executeUpdate(); + } + + private static Offset within(double value) { + return Offset.offset(value); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index c1ca0636..c506bf0d 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -21,11 +21,13 @@ import java.util.List; import java.util.Optional; +import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.springframework.test.util.ReflectionTestUtils.setField; @ExtendWith(MockitoExtension.class) class UserServiceTest { @@ -120,7 +122,8 @@ void registerGuestUser() { assertThat(response.email()).contains("guest"); verify(userRepository, times(1)).save(any(User.class)); - verify(folderService, times(1)).ensureOnboardingFolders(any(Long.class)); + verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any(Long.class)); + verify(practiceNoteService, times(1)).registerDefaultPractice(any(Long.class)); } @Test @@ -142,7 +145,8 @@ void registerMemberUser() { // save()가 반드시 한 번 호출되어야 함 verify(userRepository, times(1)).save(any(User.class)); - verify(folderService, times(1)).ensureOnboardingFolders(any(Long.class)); + verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any(Long.class)); + verify(practiceNoteService, times(1)).registerDefaultPractice(any(Long.class)); } @Test @@ -162,7 +166,8 @@ void registerMemberUserAlreadyExist() { // 기존 유저가 존재하면 save()가 호출되지 않아야 함 verify(userRepository, never()).save(any(User.class)); - verify(folderService, times(1)).ensureOnboardingFolders(any(Long.class)); + verify(folderService, never()).initializeDefaultFoldersIfAbsent(any(Long.class)); + verify(practiceNoteService, never()).registerDefaultPractice(any(Long.class)); } @Test @@ -189,6 +194,8 @@ void findAllUsers() { // Given User user1 = User.from(new UserRegisterDto("user1@example.com", "user1", "id1", "MEMBER", null)); User user2 = User.from(new UserRegisterDto("user2@example.com", "user2", "id2", "MEMBER", null)); + setField(user1, "createdAt", LocalDateTime.of(2026, 2, 1, 10, 0)); + setField(user2, "createdAt", LocalDateTime.of(2026, 2, 2, 10, 0)); when(userRepository.findAll()).thenReturn(List.of(user1, user2)); @@ -197,8 +204,8 @@ void findAllUsers() { // Then assertThat(users).hasSize(2); - assertThat(users.get(0).name()).isEqualTo("user1"); - assertThat(users.get(1).name()).isEqualTo("user2"); + assertThat(users.get(0).name()).isEqualTo("user2"); + assertThat(users.get(1).name()).isEqualTo("user1"); verify(userRepository, times(1)).findAll(); } @@ -237,6 +244,10 @@ void deleteUserById() { userService.deleteUserById(userId); // Then + verify(practiceNoteService, times(1)).deleteAllPracticesByUser(userId); + verify(problemService, times(1)).deleteAllUserProblems(userId); + verify(folderService, times(1)).deleteAllUserFolders(userId); verify(userRepository, times(1)).deleteById(userId); + verify(userRepository, times(1)).flush(); } }