diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index c35e9636..7798b948 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -159,13 +159,13 @@ jobs: - name: Deploy Monitoring Stack run: | docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring pull \ - mysql-exporter-dev prometheus-dev grafana-dev - 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 + mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev loki-dev promtail-dev mysql-exporter-dev || true + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev loki-dev promtail-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 + mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev docker-compose -f docker-compose.dev.yml --env-file .env.dev ps \ - mysql-exporter-dev prometheus-dev grafana-dev + mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev shell: bash - name: Verify Monitoring Integration @@ -203,6 +203,43 @@ jobs: exit 1 shell: bash + - name: Verify Loki Log Collection + run: | + echo "Checking Loki readiness..." + for i in $(seq 1 12); do + if curl -fsS http://localhost:3101/ready | grep -q ready; then + echo "Loki is ready" + break + fi + echo "Waiting Loki ready... ($i/12)" + sleep 5 + done + + if ! curl -fsS http://localhost:3101/ready | grep -q ready; then + echo "ERROR: Loki did not become ready" + docker logs --tail=200 ono-loki-dev || true + docker logs --tail=200 ono-promtail-dev || true + exit 1 + fi + + echo "Checking Loki received dev logs..." + for i in $(seq 1 12); do + result=$(curl -fsG "http://localhost:3101/loki/api/v1/label/container/values" || true) + if echo "$result" | grep -Eq '"ono-(app|mysql|redis|rabbitmq|prometheus|grafana|loki|promtail|mysql-exporter)-dev"'; then + echo "Loki received dev log labels" + exit 0 + fi + echo "Waiting Loki log labels... ($i/12)" + sleep 5 + done + + echo "ERROR: Loki did not receive expected dev log labels" + curl -fsG "http://localhost:3101/loki/api/v1/labels" || true + docker logs --tail=200 ono-loki-dev || true + docker logs --tail=200 ono-promtail-dev || true + exit 1 + shell: bash + - name: Clean up old Docker images run: | docker image prune -f --filter "until=24h" diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 7314c9ee..77fee4a7 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -219,13 +219,13 @@ jobs: - name: Deploy Monitoring Stack run: | docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring pull \ - mysql-exporter-prod prometheus-prod grafana-prod - 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 + mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod loki-prod promtail-prod mysql-exporter-prod || true + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod loki-prod promtail-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 + mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod docker-compose -f docker-compose.prod.yml --env-file .env.prod ps \ - mysql-exporter-prod prometheus-prod grafana-prod + mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash - name: Verify Monitoring Integration @@ -248,6 +248,44 @@ jobs: docker logs --tail=200 ono-prometheus-prod || true shell: bash + - name: Verify Loki Log Collection + continue-on-error: true + run: | + echo "Checking Loki readiness..." + for i in $(seq 1 12); do + if curl -fsS http://localhost:3100/ready | grep -q ready; then + echo "Loki is ready" + break + fi + echo "Waiting Loki ready... ($i/12)" + sleep 5 + done + + if ! curl -fsS http://localhost:3100/ready | grep -q ready; then + echo "WARNING: Loki did not become ready" + docker logs --tail=200 ono-loki-prod || true + docker logs --tail=200 ono-promtail-prod || true + exit 1 + fi + + echo "Checking Loki received production logs..." + for i in $(seq 1 12); do + result=$(curl -fsG "http://localhost:3100/loki/api/v1/label/container/values" || true) + if echo "$result" | grep -Eq '"ono-(app-prod-blue|app-prod-green|mysql-prod|redis-prod|rabbitmq-prod|prometheus-prod|grafana-prod|loki-prod|promtail-prod|mysql-exporter-prod)"'; then + echo "Loki received production log labels" + exit 0 + fi + echo "Waiting Loki production log labels... ($i/12)" + sleep 5 + done + + echo "WARNING: Loki did not receive expected production log labels" + curl -fsG "http://localhost:3100/loki/api/v1/labels" || true + docker logs --tail=200 ono-loki-prod || true + docker logs --tail=200 ono-promtail-prod || true + exit 1 + shell: bash + - name: Clean up old Docker images run: | docker image prune -f --filter "until=24h" diff --git a/.github/workflows/restart-monitoring.yml b/.github/workflows/restart-monitoring.yml index 90a7550c..3ee6a7aa 100644 --- a/.github/workflows/restart-monitoring.yml +++ b/.github/workflows/restart-monitoring.yml @@ -56,16 +56,16 @@ jobs: 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 + mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev docker compose -f docker-compose.dev.yml --env-file .env.dev ps \ - mysql-exporter-dev prometheus-dev grafana-dev + mysql-exporter-dev prometheus-dev loki-dev promtail-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 + mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod docker compose -f docker-compose.prod.yml --env-file .env.prod ps \ - mysql-exporter-prod prometheus-prod grafana-prod + mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash diff --git a/.gitignore b/.gitignore index 56e89db9..8df78b67 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,13 @@ application-prod.properties application-local.yml application-dev.yml application-prod.yml +application-test.yml FirebaseAdminKey.json -**/db/migration/ +**/db/migration/* +!**/db/migration/ +!**/db/migration/README.md +!**/db/migration/V*.sql ### Environment variables .env @@ -61,3 +65,7 @@ monitoring/README.md ### macOS .DS_Store + +### Local agent guidance +/AGENTS.md +.claude.md diff --git a/build.gradle b/build.gradle index ca9d0793..efead293 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ jar { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.1.0' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' implementation 'com.google.api-client:google-api-client:2.4.0' @@ -55,6 +56,8 @@ 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' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' // Hibernate Core @@ -77,6 +80,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() + systemProperty 'spring.profiles.active', 'test' } tasks.withType(JavaCompile) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e3f1c120..c19aba8b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -145,6 +145,48 @@ services: profiles: - monitoring + # Loki - DEVELOPMENT (Profile: monitoring) + loki-dev: + image: grafana/loki:2.9.8 + container_name: ono-loki-dev + restart: unless-stopped + command: + - -config.file=/etc/loki/loki.yml + ports: + - "127.0.0.1:3101:3100" + volumes: + - ./monitoring/loki.dev.yml:/etc/loki/loki.yml:ro + - loki_dev_data:/loki + mem_limit: 512m + cpus: "0.50" + networks: + - ono-network + profiles: + - monitoring + + # Promtail - DEVELOPMENT (Profile: monitoring) + promtail-dev: + image: grafana/promtail:3.6.10 + container_name: ono-promtail-dev + restart: unless-stopped + environment: + DOCKER_API_VERSION: "1.44" + command: + - -config.file=/etc/promtail/promtail.yml + volumes: + - ./monitoring/promtail.dev.yml:/etc/promtail/promtail.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - promtail_dev_positions:/tmp + mem_limit: 256m + cpus: "0.25" + depends_on: + - loki-dev + networks: + - ono-network + profiles: + - monitoring + mysql-exporter-dev: image: prom/mysqld-exporter:v0.15.1 container_name: ono-mysql-exporter-dev @@ -180,6 +222,7 @@ services: - ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro depends_on: - prometheus-dev + - loki-dev networks: - ono-network profiles: @@ -201,5 +244,9 @@ volumes: driver: local prometheus_dev_data: driver: local + loki_dev_data: + driver: local + promtail_dev_positions: + driver: local grafana_dev_data: driver: local diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6df7a3b0..edab90d4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -199,6 +199,48 @@ services: profiles: - monitoring + # Loki - PRODUCTION (Profile: monitoring) + loki-prod: + image: grafana/loki:2.9.8 + container_name: ono-loki-prod + restart: unless-stopped + command: + - -config.file=/etc/loki/loki.yml + ports: + - "127.0.0.1:3100:3100" + volumes: + - ./monitoring/loki.prod.yml:/etc/loki/loki.yml:ro + - loki_prod_data:/loki + mem_limit: 768m + cpus: "0.75" + networks: + - ono-network + profiles: + - monitoring + + # Promtail - PRODUCTION (Profile: monitoring) + promtail-prod: + image: grafana/promtail:3.6.10 + container_name: ono-promtail-prod + restart: unless-stopped + environment: + DOCKER_API_VERSION: "1.44" + command: + - -config.file=/etc/promtail/promtail.yml + volumes: + - ./monitoring/promtail.prod.yml:/etc/promtail/promtail.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - promtail_prod_positions:/tmp + mem_limit: 256m + cpus: "0.25" + depends_on: + - loki-prod + networks: + - ono-network + profiles: + - monitoring + mysql-exporter-prod: image: prom/mysqld-exporter:v0.15.1 container_name: ono-mysql-exporter-prod @@ -234,6 +276,7 @@ services: - ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro depends_on: - prometheus-prod + - loki-prod networks: - ono-network profiles: @@ -254,5 +297,9 @@ volumes: driver: local prometheus_prod_data: driver: local + loki_prod_data: + driver: local + promtail_prod_positions: + driver: local grafana_prod_data: driver: local diff --git a/monitoring/grafana/dashboards/ono-error-logs.json b/monitoring/grafana/dashboards/ono-error-logs.json new file mode 100644 index 00000000..b3a3cbc7 --- /dev/null +++ b/monitoring/grafana/dashboards/ono-error-logs.json @@ -0,0 +1,296 @@ +{ + "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": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({environment=~\"$environment\", level=\"ERROR\"}[$__range]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Logs", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 18, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (container) (count_over_time({environment=~\"$environment\", level=\"ERROR\"}[$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Errors by Container", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{environment=~\"$environment\", level=\"ERROR\"} |~ \"$search\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Log Stream", + "type": "logs" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "ono", + "logs", + "errors", + "loki" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "dev", + "prod" + ], + "value": [ + "dev", + "prod" + ] + }, + "hide": 0, + "includeAll": true, + "label": "Environment", + "multi": true, + "name": "environment", + "options": [ + { + "selected": true, + "text": "dev", + "value": "dev" + }, + { + "selected": true, + "text": "prod", + "value": "prod" + } + ], + "query": "dev,prod", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + }, + { + "current": { + "selected": false, + "text": ".+", + "value": ".+" + }, + "hide": 0, + "label": "Search", + "name": "search", + "options": [ + { + "selected": true, + "text": ".+", + "value": ".+" + } + ], + "query": ".+", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "OnO Error Logs", + "uid": "ono-error-logs", + "version": 1, + "weekStart": "" +} diff --git a/monitoring/grafana/provisioning/dev/datasources/datasource.yml b/monitoring/grafana/provisioning/dev/datasources/datasource.yml index 4d13d158..d1662c47 100644 --- a/monitoring/grafana/provisioning/dev/datasources/datasource.yml +++ b/monitoring/grafana/provisioning/dev/datasources/datasource.yml @@ -8,3 +8,10 @@ datasources: url: http://prometheus-dev:9090/prometheus isDefault: true editable: true + + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki-dev:3100 + editable: true diff --git a/monitoring/grafana/provisioning/prod/datasources/datasource.yml b/monitoring/grafana/provisioning/prod/datasources/datasource.yml index f552df23..947d0d82 100644 --- a/monitoring/grafana/provisioning/prod/datasources/datasource.yml +++ b/monitoring/grafana/provisioning/prod/datasources/datasource.yml @@ -8,3 +8,10 @@ datasources: url: http://prometheus-prod:9090/prometheus isDefault: true editable: true + + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki-prod:3100 + editable: true diff --git a/monitoring/loki.dev.yml b/monitoring/loki.dev.yml new file mode 100644 index 00000000..cc5c1b99 --- /dev/null +++ b/monitoring/loki.dev.yml @@ -0,0 +1,53 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h + allow_structured_metadata: false + ingestion_rate_mb: 4 + ingestion_burst_size_mb: 8 + per_stream_rate_limit: 1MB + per_stream_rate_limit_burst: 2MB + reject_old_samples: true + reject_old_samples_max_age: 168h + +compactor: + working_directory: /loki/compactor + retention_enabled: true + delete_request_store: filesystem + +ruler: + storage: + type: local + local: + directory: /loki/rules + rule_path: /loki/rules-temp + alertmanager_url: http://localhost:9093 + ring: + kvstore: + store: inmemory + enable_api: true diff --git a/monitoring/loki.prod.yml b/monitoring/loki.prod.yml new file mode 100644 index 00000000..66b6caa2 --- /dev/null +++ b/monitoring/loki.prod.yml @@ -0,0 +1,53 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 720h + allow_structured_metadata: false + ingestion_rate_mb: 4 + ingestion_burst_size_mb: 8 + per_stream_rate_limit: 1MB + per_stream_rate_limit_burst: 2MB + reject_old_samples: true + reject_old_samples_max_age: 720h + +compactor: + working_directory: /loki/compactor + retention_enabled: true + delete_request_store: filesystem + +ruler: + storage: + type: local + local: + directory: /loki/rules + rule_path: /loki/rules-temp + alertmanager_url: http://localhost:9093 + ring: + kvstore: + store: inmemory + enable_api: true diff --git a/monitoring/promtail.dev.yml b/monitoring/promtail.dev.yml new file mode 100644 index 00000000..7fb01b99 --- /dev/null +++ b/monitoring/promtail.dev.yml @@ -0,0 +1,52 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yml + +clients: + - url: http://loki-dev:3100/loki/api/v1/push + +scrape_configs: + - job_name: ono-dev-docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: name + values: + - ono-app-dev + - ono-mysql-dev + - ono-redis-dev + - ono-rabbitmq-dev + - ono-prometheus-dev + - ono-grafana-dev + - ono-loki-dev + - ono-promtail-dev + - ono-mysql-exporter-dev + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: '/(.*)' + target_label: container + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: compose_service + - source_labels: [__meta_docker_container_label_com_docker_compose_project] + target_label: compose_project + - source_labels: [__meta_docker_container_id] + target_label: __path__ + replacement: /var/lib/docker/containers/$1/$1-json.log + - target_label: job + replacement: ono-service + - target_label: environment + replacement: dev + - target_label: source + replacement: docker-json + pipeline_stages: + - docker: {} + - drop: + older_than: 168h + - regex: + expression: '(^|[[:space:]])(?PTRACE|DEBUG|INFO|WARN|ERROR|FATAL)([[:space:]]|$)' + - labels: + level: diff --git a/monitoring/promtail.prod.yml b/monitoring/promtail.prod.yml new file mode 100644 index 00000000..1083fcfa --- /dev/null +++ b/monitoring/promtail.prod.yml @@ -0,0 +1,64 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yml + +clients: + - url: http://loki-prod:3100/loki/api/v1/push + +scrape_configs: + - job_name: ono-prod-docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: name + values: + - ono-app-prod-blue + - ono-app-prod-green + - ono-mysql-prod + - ono-redis-prod + - ono-rabbitmq-prod + - ono-prometheus-prod + - ono-grafana-prod + - ono-loki-prod + - ono-promtail-prod + - ono-mysql-exporter-prod + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: '/(.*)' + target_label: container + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: compose_service + - source_labels: [__meta_docker_container_label_com_docker_compose_project] + target_label: compose_project + - source_labels: [__meta_docker_container_id] + target_label: __path__ + replacement: /var/lib/docker/containers/$1/$1-json.log + - target_label: job + replacement: ono-service + - target_label: environment + replacement: prod + - target_label: source + replacement: docker-json + pipeline_stages: + - docker: {} + - drop: + older_than: 720h + - json: + expressions: + level: level + service: service + traceId: traceId + userId: userId + method: method + uri: uri + status: status + latencyMs: latencyMs + errorCode: errorCode + exceptionType: exceptionType + - labels: + level: + service: diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java index 24952228..aa566a11 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.admin.controller; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.entity.User; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestParam; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -28,33 +31,113 @@ public class AdminAnalysisController { private final UserService userService; private final ProblemService problemService; private final MissionLogService missionLogService; + private final PracticeNoteService practiceNoteService; @GetMapping("/analysis") - public String getAllAnalysis(Model model) { - int allUserCount = userService.findAllUsers().size(); - int allProblemCount = problemService.findAllProblems().size(); - - // 최근 30일간 날짜별 출석 유저 수 및 신규 가입자 수 - Map dailyActiveUsers = missionLogService.getDailyActiveUsersCount(30); - Map dailyNewUsers = userService.getDailyNewUsersCount(30); - - // 최근 30일 신규 가입자 총합 + public String getAllAnalysis( + @RequestParam(name = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(name = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Model model + ) { + long allUserCount = userService.countAllUsers(); + long allProblemCount = problemService.countAllProblems(); + long allPracticeNoteCount = practiceNoteService.countAllPracticeNotes(); + long allPracticeLogCount = missionLogService.countNotePracticeLogs(); + long allProblemAnalysisCount = problemService.countAllProblemAnalyses(); + Map allAnalysisStatusCounts = problemService.countProblemAnalysesByStatus(); + + LocalDate today = LocalDate.now(); + LocalDate selectedStartDate = startDate != null ? startDate : today.minusDays(29); + LocalDate selectedEndDate = endDate != null ? endDate : today; + + if (selectedStartDate.isAfter(selectedEndDate)) { + LocalDate temp = selectedStartDate; + selectedStartDate = selectedEndDate; + selectedEndDate = temp; + } + + // 선택 기간 날짜별 출석 유저 수 및 신규 가입자 수 + Map dailyActiveUsers = missionLogService.getDailyActiveUsersCount(selectedStartDate, selectedEndDate); + Map dailyVisits = missionLogService.getDailyVisitCount(selectedStartDate, selectedEndDate); + Map dailyNewUsers = userService.getDailyNewUsersCount(selectedStartDate, selectedEndDate); + Map dailyPracticeNotes = practiceNoteService.getDailyPracticeNotesCount(selectedStartDate, selectedEndDate); + Map dailyPracticeLogs = missionLogService.getDailyNotePracticeLogsCount(selectedStartDate, selectedEndDate); + Map dailyProblems = problemService.getDailyProblemsCount(selectedStartDate, selectedEndDate); + Map periodAnalysisStatusCounts = problemService.countProblemAnalysesByStatus(selectedStartDate, selectedEndDate); + + // 선택 기간 신규 가입자 총합 long recentNewUsersCount = dailyNewUsers.values().stream() .mapToLong(Long::longValue) .sum(); - - // 하루 평균 방문자 수 (최근 30일) - double averageDailyVisitors = dailyActiveUsers.values().stream() + long periodVisitCount = dailyVisits.values().stream() + .mapToLong(Long::longValue) + .sum(); + long periodActiveUserCount = dailyActiveUsers.values().stream() + .mapToLong(Long::longValue) + .sum(); + long periodUniqueVisitorCount = missionLogService.countUniqueVisitors(selectedStartDate, selectedEndDate); + long periodPracticeNoteCount = dailyPracticeNotes.values().stream() + .mapToLong(Long::longValue) + .sum(); + long periodPracticeLogCount = dailyPracticeLogs.values().stream() .mapToLong(Long::longValue) - .average() - .orElse(0.0); + .sum(); + long periodProblemCount = dailyProblems.values().stream() + .mapToLong(Long::longValue) + .sum(); + long periodCompletedAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.COMPLETED, 0L); + long periodFailedAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.FAILED, 0L); + long periodProcessingAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L); + long periodNotStartedAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L); + long periodNoImageAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L); + long periodFinishedAnalysisCount = periodCompletedAnalysisCount + periodFailedAnalysisCount; + double periodAnalysisFailureRate = periodFinishedAnalysisCount == 0 + ? 0.0 + : (double) periodFailedAnalysisCount * 100 / periodFinishedAnalysisCount; + + long selectedDays = ChronoUnit.DAYS.between(selectedStartDate, selectedEndDate) + 1; + double averageDailyVisitors = selectedDays > 0 + ? (double) periodActiveUserCount / selectedDays + : 0.0; model.addAttribute("allUserCount", allUserCount); model.addAttribute("allProblemCount", allProblemCount); + model.addAttribute("allPracticeNoteCount", allPracticeNoteCount); + model.addAttribute("allPracticeLogCount", allPracticeLogCount); + model.addAttribute("allProblemAnalysisCount", allProblemAnalysisCount); + model.addAttribute("allCompletedAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.COMPLETED, 0L)); + model.addAttribute("allFailedAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.FAILED, 0L)); + model.addAttribute("allProcessingAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L)); + model.addAttribute("allNotStartedAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L)); + model.addAttribute("allNoImageAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L)); model.addAttribute("dailyActiveUsers", dailyActiveUsers); + model.addAttribute("dailyVisits", dailyVisits); model.addAttribute("dailyNewUsers", dailyNewUsers); + model.addAttribute("dailyPracticeNotes", dailyPracticeNotes); + model.addAttribute("dailyPracticeLogs", dailyPracticeLogs); + model.addAttribute("dailyProblems", dailyProblems); model.addAttribute("recentNewUsersCount", recentNewUsersCount); + model.addAttribute("periodVisitCount", periodVisitCount); + model.addAttribute("periodActiveUserCount", periodActiveUserCount); + model.addAttribute("periodUniqueVisitorCount", periodUniqueVisitorCount); + model.addAttribute("periodPracticeNoteCount", periodPracticeNoteCount); + model.addAttribute("periodPracticeLogCount", periodPracticeLogCount); + model.addAttribute("periodProblemCount", periodProblemCount); + model.addAttribute("periodCompletedAnalysisCount", periodCompletedAnalysisCount); + model.addAttribute("periodFailedAnalysisCount", periodFailedAnalysisCount); + model.addAttribute("periodProcessingAnalysisCount", periodProcessingAnalysisCount); + model.addAttribute("periodNotStartedAnalysisCount", periodNotStartedAnalysisCount); + model.addAttribute("periodNoImageAnalysisCount", periodNoImageAnalysisCount); + model.addAttribute("periodAnalysisFailureRate", periodAnalysisFailureRate); model.addAttribute("averageDailyVisitors", averageDailyVisitors); + model.addAttribute("startDate", selectedStartDate); + model.addAttribute("endDate", selectedEndDate); + model.addAttribute("quickStart7Days", today.minusDays(6)); + model.addAttribute("quickStart30Days", today.minusDays(29)); + model.addAttribute("quickStart90Days", today.minusDays(89)); + model.addAttribute("today", today); return "analysis"; } @@ -91,4 +174,4 @@ public String getDailyActiveUsers( return "daily-users"; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteController.java new file mode 100644 index 00000000..87fbed99 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteController.java @@ -0,0 +1,78 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; +import com.aisip.OnO.backend.admin.dto.AdminPracticeNoteResponseDto; +import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Slf4j +@RequiredArgsConstructor +@Controller +@RequestMapping("/admin") +public class AdminPracticeNoteController { + + private final PracticeNoteService practiceNoteService; + private final MissionLogService missionLogService; + + @GetMapping("/practice-notes") + public String getPracticeNotes( + @RequestParam(defaultValue = "0", name = "notePage") int notePage, + @RequestParam(defaultValue = "20", name = "size") int size, + Model model + ) { + int selectedNotePage = Math.max(notePage, 0); + int selectedSize = Math.max(size, 1); + + Page practiceNotes = practiceNoteService.findAdminPracticeNotes(selectedNotePage, selectedSize); + int noteTotalPages = practiceNotes.getTotalPages(); + int notePageBlockStart = (selectedNotePage / 10) * 10; + int notePageBlockEnd = Math.min(notePageBlockStart + 9, Math.max(noteTotalPages - 1, 0)); + + model.addAttribute("practiceNotes", practiceNotes.getContent()); + model.addAttribute("notePage", selectedNotePage); + model.addAttribute("noteTotalPages", noteTotalPages); + model.addAttribute("notePageBlockStart", notePageBlockStart); + model.addAttribute("notePageBlockEnd", notePageBlockEnd); + model.addAttribute("hasPreviousNoteBlock", notePageBlockStart > 0); + model.addAttribute("hasNextNoteBlock", notePageBlockEnd < noteTotalPages - 1); + model.addAttribute("totalPracticeNotes", practiceNotes.getTotalElements()); + model.addAttribute("size", selectedSize); + + return "practice-notes"; + } + + @GetMapping("/practice-logs") + public String getPracticeLogs( + @RequestParam(defaultValue = "0", name = "logPage") int logPage, + @RequestParam(defaultValue = "20", name = "size") int size, + Model model + ) { + int selectedLogPage = Math.max(logPage, 0); + int selectedSize = Math.max(size, 1); + + Page practiceLogs = missionLogService.findAdminPracticeLogs(selectedLogPage, selectedSize); + int logTotalPages = practiceLogs.getTotalPages(); + int logPageBlockStart = (selectedLogPage / 10) * 10; + int logPageBlockEnd = Math.min(logPageBlockStart + 9, Math.max(logTotalPages - 1, 0)); + + model.addAttribute("practiceLogs", practiceLogs.getContent()); + model.addAttribute("logPage", selectedLogPage); + model.addAttribute("logTotalPages", logTotalPages); + model.addAttribute("logPageBlockStart", logPageBlockStart); + model.addAttribute("logPageBlockEnd", logPageBlockEnd); + model.addAttribute("hasPreviousLogBlock", logPageBlockStart > 0); + model.addAttribute("hasNextLogBlock", logPageBlockEnd < logTotalPages - 1); + model.addAttribute("totalPracticeLogs", practiceLogs.getTotalElements()); + model.addAttribute("size", selectedSize); + + return "practice-logs"; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminProblemController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminProblemController.java index f2d60dc3..2ef6b3e6 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminProblemController.java @@ -1,14 +1,18 @@ package com.aisip.OnO.backend.admin.controller; +import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; import com.aisip.OnO.backend.folder.dto.FolderResponseDto; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +31,7 @@ public class AdminProblemController { private final ProblemService problemService; private final UserService userService; private final FolderService folderService; + private final ProblemSolveService problemSolveService; @GetMapping("/problems") public String getAllProblems( @@ -34,21 +39,31 @@ public String getAllProblems( @RequestParam(defaultValue = "20", name = "size") int size, Model model ) { - List allProblems = problemService.findAllProblems(); + int selectedPage = Math.max(page, 0); + int selectedSize = Math.max(size, 1); + Page problemPage = problemService.findAdminProblems(selectedPage, selectedSize); + int totalPages = problemPage.getTotalPages(); - // 페이징 계산 - int totalProblems = allProblems.size(); - int totalPages = (int) Math.ceil((double) totalProblems / size); - int startIndex = page * size; - int endIndex = Math.min(startIndex + size, totalProblems); + if (totalPages > 0 && selectedPage >= totalPages) { + selectedPage = totalPages - 1; + problemPage = problemService.findAdminProblems(selectedPage, selectedSize); + totalPages = problemPage.getTotalPages(); + } - List pagedProblems = allProblems.subList(startIndex, endIndex); + int pageBlockStart = (selectedPage / 10) * 10; + int pageBlockEnd = Math.min(pageBlockStart + 9, Math.max(totalPages - 1, 0)); - model.addAttribute("problems", pagedProblems); - model.addAttribute("currentPage", page); + model.addAttribute("problems", problemPage.getContent()); + model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", totalPages); - model.addAttribute("totalProblems", totalProblems); - model.addAttribute("size", size); + model.addAttribute("totalProblems", problemPage.getTotalElements()); + model.addAttribute("size", selectedSize); + model.addAttribute("pageStartItem", problemPage.isEmpty() ? 0 : selectedPage * selectedSize + 1); + model.addAttribute("pageEndItem", selectedPage * selectedSize + problemPage.getNumberOfElements()); + model.addAttribute("pageBlockStart", pageBlockStart); + model.addAttribute("pageBlockEnd", pageBlockEnd); + model.addAttribute("hasPreviousBlock", pageBlockStart > 0); + model.addAttribute("hasNextBlock", pageBlockEnd < totalPages - 1); return "problems"; } @@ -61,9 +76,12 @@ public String getProblemDetail(@PathVariable(name = "problemId") Long problemId, // 폴더 및 작성자 정보 조회 FolderResponseDto folder = folderService.findFolder(problem.folderId()); UserResponseDto user = userService.findUser(folder.userId()); + List problemSolves = problemSolveService.getAdminProblemSolvesByProblemId(problemId); model.addAttribute("folder", folder); model.addAttribute("user", user); + model.addAttribute("problemSolves", problemSolves); + model.addAttribute("problemSolveCount", problemSolves.size()); return "problem"; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminUserController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminUserController.java index bfd197c1..b2eebcb0 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminUserController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminUserController.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.admin.controller; +import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; import com.aisip.OnO.backend.folder.dto.FolderResponseDto; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.mission.entity.MissionLog; @@ -14,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; @@ -36,29 +38,30 @@ public class AdminUserController { public String getAllUsers( @RequestParam(defaultValue = "0", name = "page") int page, @RequestParam(defaultValue = "20", name = "size") int size, + @RequestParam(defaultValue = "createdAt", name = "sortBy") String sortBy, + @RequestParam(defaultValue = "desc", name = "direction") String direction, Model model ) { - List allUsers = userService.findAllUsers(); - - // 페이징 계산 - int totalUsers = allUsers.size(); - int totalPages = (int) Math.ceil((double) totalUsers / size); - int startIndex = page * size; - int endIndex = Math.min(startIndex + size, totalUsers); - - List pagedUsers = allUsers.subList(startIndex, endIndex); - - // 각 유저의 문제 개수 계산 - List problemCounts = pagedUsers.stream() - .map(user -> problemService.findProblemCountByUser(user.userId())) - .toList(); - - model.addAttribute("users", pagedUsers); - model.addAttribute("problemCounts", problemCounts); - model.addAttribute("currentPage", page); + int selectedPage = Math.max(page, 0); + int selectedSize = Math.max(size, 1); + Page userPage = userService.findAdminUsers(selectedPage, selectedSize, sortBy, direction); + int totalPages = userPage.getTotalPages(); + int pageBlockStart = (selectedPage / 10) * 10; + int pageBlockEnd = Math.min(pageBlockStart + 9, Math.max(totalPages - 1, 0)); + + model.addAttribute("users", userPage.getContent()); + model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", totalPages); - model.addAttribute("totalUsers", totalUsers); - model.addAttribute("size", size); + model.addAttribute("totalUsers", userPage.getTotalElements()); + model.addAttribute("size", selectedSize); + model.addAttribute("pageStartItem", userPage.isEmpty() ? 0 : selectedPage * selectedSize + 1); + model.addAttribute("pageEndItem", selectedPage * selectedSize + userPage.getNumberOfElements()); + model.addAttribute("sortBy", sortBy); + model.addAttribute("direction", direction); + model.addAttribute("pageBlockStart", pageBlockStart); + model.addAttribute("pageBlockEnd", pageBlockEnd); + model.addAttribute("hasPreviousBlock", pageBlockStart > 0); + model.addAttribute("hasNextBlock", pageBlockEnd < totalPages - 1); return "users"; } @@ -117,4 +120,4 @@ public String updateUserLevel( public void deleteUserInfo(@PathVariable(name = "userId") Long userId) { userService.deleteUserById(userId); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java new file mode 100644 index 00000000..03d2b5ac --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java @@ -0,0 +1,29 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import java.time.LocalDateTime; + +public record AdminPracticeLogResponseDto( + Long missionLogId, + Long userId, + String userName, + String userEmail, + Long practiceNoteId, + String practiceTitle, + Long point, + LocalDateTime createdAt +) { + public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNote practiceNote) { + return new AdminPracticeLogResponseDto( + missionLog.getId(), + missionLog.getUser().getId(), + missionLog.getUser().getName(), + missionLog.getUser().getEmail(), + missionLog.getReferenceId(), + practiceNote != null ? practiceNote.getTitle() : "-", + missionLog.getPoint(), + missionLog.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeNoteResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeNoteResponseDto.java new file mode 100644 index 00000000..1a1423cf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeNoteResponseDto.java @@ -0,0 +1,31 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.user.entity.User; +import java.time.LocalDateTime; + +public record AdminPracticeNoteResponseDto( + Long practiceNoteId, + Long userId, + String userName, + String userEmail, + String practiceTitle, + Long problemCount, + Long practiceCount, + LocalDateTime lastSolvedAt, + LocalDateTime createdAt +) { + public static AdminPracticeNoteResponseDto from(PracticeNote practiceNote, User user, Long problemCount) { + return new AdminPracticeNoteResponseDto( + practiceNote.getId(), + practiceNote.getUserId(), + user != null ? user.getName() : "-", + user != null ? user.getEmail() : "-", + practiceNote.getTitle(), + problemCount, + practiceNote.getPracticeCount(), + practiceNote.getLastSolvedAt(), + practiceNote.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminProblemResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminProblemResponseDto.java new file mode 100644 index 00000000..d29d7459 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminProblemResponseDto.java @@ -0,0 +1,14 @@ +package com.aisip.OnO.backend.admin.dto; + +import java.time.LocalDateTime; + +public record AdminProblemResponseDto( + Long problemId, + Long folderId, + String memo, + String reference, + String analysisStatus, + LocalDateTime solvedAt, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserResponseDto.java new file mode 100644 index 00000000..41a92f85 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserResponseDto.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.user.entity.User; + +import java.time.LocalDateTime; + +public record AdminUserResponseDto( + Long userId, + String name, + String email, + Long totalStudyLevel, + Long totalStudyCurrentPoint, + Long problemCount, + LocalDateTime createdAt +) { + public static AdminUserResponseDto from(User user, Long problemCount) { + return new AdminUserResponseDto( + user.getId(), + user.getName(), + user.getEmail(), + user.getUserMissionStatus().getTotalStudyLevel(), + user.getUserMissionStatus().getTotalStudyPoint(), + problemCount != null ? problemCount : 0L, + user.getCreatedAt() + ); + } +} 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 44bcbb3f..7643715b 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 @@ -147,7 +147,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of("*")); // ✅ 모든 도메인에서 접근 가능 - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); // ✅ 허용할 HTTP 메서드 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); // ✅ 허용할 HTTP 메서드 config.setAllowedHeaders(List.of("*")); // ✅ 모든 헤더 허용 config.setAllowCredentials(true); // ✅ 인증 정보 포함 요청 허용 (JWT 포함) diff --git a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java index 808e5645..f5e508d1 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java @@ -16,9 +16,15 @@ public enum AuthErrorCase implements ErrorCase { REFRESH_TOKEN_NOT_EQUAL(400, 1004, "리프레시 토큰이 일치하지 않습니다."), - ACCESS_TOKEN_EXPIRED(400, 1005, "엑세스 토큰이 만료되었습니다."), + ACCESS_TOKEN_EXPIRED(401, 1005, "엑세스 토큰이 만료되었습니다."), - REFRESH_TOKEN_EXPIRED(401, 1006, "리프레시 토큰이 만료되었습니다."); + REFRESH_TOKEN_EXPIRED(401, 1006, "리프레시 토큰이 만료되었습니다."), + + AUTHENTICATION_FAILED(401, 1007, "인증이 실패했습니다."), + + ACCESS_DENIED(403, 1008, "접근 권한이 없습니다."), + + INVALID_ACCESS_TOKEN(401, 1009, "유효하지 않은 엑세스 토큰입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java index f29b12d1..0b1a61fa 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java @@ -77,7 +77,7 @@ public void validateAccessToken(String token) { try{ Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token); } catch (Exception e) { - log.error("엑세스 토큰 검증 실패: {} / token={}", e.getMessage(), token); + log.warn("엑세스 토큰 검증 실패: {}", e.getMessage()); throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); } } @@ -89,7 +89,7 @@ public void validateRefreshToken(String token) { log.warn("리프레시 토큰 만료: {}", e.getMessage()); throw new ApplicationException(AuthErrorCase.REFRESH_TOKEN_EXPIRED); } catch (Exception e) { - log.error("리프레시 토큰 검증 실패: {} / token={}", e.getMessage(), token); + log.warn("리프레시 토큰 검증 실패: {}", e.getMessage()); throw new ApplicationException(AuthErrorCase.INVALID_REFRESH_TOKEN); } } diff --git a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java index 1010757b..bd638103 100644 --- a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java @@ -1,54 +1,32 @@ package com.aisip.OnO.backend.common.aop; +import com.aisip.OnO.backend.common.exception.ApplicationException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.AfterThrowing; import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Before; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.stereotype.Component; @Aspect @Component @Slf4j class LoggingAspect { - private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class); - // 예외 발생 시 로깅 @AfterThrowing(pointcut = "execution(* com.aisip..service..*(..))", throwing = "ex") public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { - String methodName = joinPoint.getSignature().getName(); - String className = joinPoint.getSignature().getDeclaringTypeName(); - String layer = getLayerName(className); - logger.error("[{}] [Exception] {}.{}(): {}", layer, className, methodName, ex.getMessage()); - } - - // 메서드 실행 이전 로깅 - @Before("execution(* com.aisip..service..*(..))") - public void logBefore(JoinPoint joinPoint) { - String methodName = joinPoint.getSignature().getName(); - String className = joinPoint.getSignature().getDeclaringTypeName(); - String layer = getLayerName(className); - logger.info("[{}] [Executing] {}.{}()", layer, className, methodName); - } - - // 메서드 실행 이후 로깅 - @After("execution(* com.aisip..service..*(..))") - public void logAfter(JoinPoint joinPoint) { - String methodName = joinPoint.getSignature().getName(); - String className = joinPoint.getSignature().getDeclaringTypeName(); - String layer = getLayerName(className); - logger.info("[{}] [Completed] {}.{}()", layer, className, methodName); - } + if (ex instanceof ApplicationException) { + return; + } - // 클래스 이름으로부터 계층(layer) 이름 추출 - private String getLayerName(String className) { - if (className.contains("service")) { - return "Service"; + if (MDC.get("traceId") != null) { + log.debug("Service exception propagated to request handler - method: {}, exceptionType: {}", + joinPoint.getSignature().toShortString(), + ex.getClass().getSimpleName()); + return; } - return "Unknown"; + + log.error("Unhandled service exception - method: {}", joinPoint.getSignature().toShortString(), ex); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAccessDeniedHandler.java b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAccessDeniedHandler.java index 9c6325a1..0f0b70b2 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAccessDeniedHandler.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAccessDeniedHandler.java @@ -1,7 +1,11 @@ package com.aisip.OnO.backend.common.auth; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; @@ -9,13 +13,15 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 Forbidden 상태 코드 + response.setStatus(AuthErrorCase.ACCESS_DENIED.getHttpStatusCode()); response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write("{\"errorCode\":\"403\",\n\"message\":\"접근 권한이 없습니다.\"}"); // 사용자 정의 메시지 + objectMapper.writeValue(response.getWriter(), CommonResponse.error(AuthErrorCase.ACCESS_DENIED)); } } - 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 57b009c7..0832e53e 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 @@ -1,7 +1,12 @@ package com.aisip.OnO.backend.common.auth; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ErrorCase; +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -9,8 +14,11 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { @@ -30,14 +38,18 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A ) { return; } - String errorMessage = (String) request.getAttribute("errorMessage"); - - if(errorMessage == null) - errorMessage = "인증이 실패했습니다."; + ErrorCase errorCase = resolveErrorCase(request); response.setContentType("application/json;charset=UTF-8"); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 UNAUTHORIZED 상태 코드 - response.getWriter().write("{\"errorCode\":\"401\",\n\"message\":\"" + errorMessage + "\"}"); + response.setStatus(errorCase.getHttpStatusCode()); + objectMapper.writeValue(response.getWriter(), CommonResponse.error(errorCase)); } + private ErrorCase resolveErrorCase(HttpServletRequest request) { + Object errorCase = request.getAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE); + if (errorCase instanceof ErrorCase resolvedErrorCase) { + return resolvedErrorCase; + } + return AuthErrorCase.AUTHENTICATION_FAILED; + } } 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 fe5c874a..87f1a053 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 @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.common.auth; import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.service.JwtTokenizer; import com.aisip.OnO.backend.util.redis.RedisTokenService; import io.jsonwebtoken.Claims; @@ -10,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.slf4j.MDC; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -27,6 +29,7 @@ public class JwtTokenFilter extends OncePerRequestFilter { public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String AUTH_ERROR_CASE_ATTRIBUTE = "authErrorCase"; private final JwtTokenizer jwtTokenizer; private final RedisTokenService redisTokenService; @@ -68,7 +71,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 2. 블랙리스트 체크 (로그아웃된 토큰인지 확인) if (redisTokenService.isBlacklisted(accessToken)) { - request.setAttribute("errorMessage", "로그아웃된 토큰입니다."); + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.INVALID_ACCESS_TOKEN); filterChain.doFilter(request, response); return; } @@ -84,10 +87,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); + MDC.put("userId", String.valueOf(userId)); } catch (ExpiredJwtException e) { - request.setAttribute("errorMessage", "토큰이 만료되었습니다."); + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); } catch (Exception e) { - request.setAttribute("errorMessage", "인증이 실패했습니다."); + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.AUTHENTICATION_FAILED); } } diff --git a/src/main/java/com/aisip/OnO/backend/common/config/LoggingFilterConfig.java b/src/main/java/com/aisip/OnO/backend/common/config/LoggingFilterConfig.java index 38c87540..3c0e3d91 100644 --- a/src/main/java/com/aisip/OnO/backend/common/config/LoggingFilterConfig.java +++ b/src/main/java/com/aisip/OnO/backend/common/config/LoggingFilterConfig.java @@ -12,9 +12,8 @@ public CommonsRequestLoggingFilter requestLoggingFilter() { CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter(); filter.setIncludeClientInfo(true); filter.setIncludeQueryString(true); - filter.setIncludePayload(true); + filter.setIncludePayload(false); filter.setIncludeHeaders(false); - filter.setMaxPayloadLength(10000); return filter; } } diff --git a/src/main/java/com/aisip/OnO/backend/common/config/RequestLoggingMdcFilter.java b/src/main/java/com/aisip/OnO/backend/common/config/RequestLoggingMdcFilter.java new file mode 100644 index 00000000..912ea357 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/config/RequestLoggingMdcFilter.java @@ -0,0 +1,144 @@ +package com.aisip.OnO.backend.common.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestLoggingMdcFilter extends OncePerRequestFilter { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + private static final String REAL_IP_HEADER = "X-Real-IP"; + + @Value("${logging.http.slow-request-threshold-ms:1000}") + private long slowRequestThresholdMs; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.contains("/actuator/") + || path.equals("/robots.txt") + || path.startsWith("/images/") + || path.startsWith("/css/") + || path.startsWith("/js/") + || path.startsWith("/swagger-ui/") + || path.startsWith("/v3/api-docs/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long startedAt = System.currentTimeMillis(); + String traceId = resolveTraceId(request); + + MDC.put("traceId", traceId); + MDC.put("method", request.getMethod()); + MDC.put("uri", request.getRequestURI()); + MDC.put("clientIp", resolveClientIp(request)); + response.setHeader(REQUEST_ID_HEADER, traceId); + + Exception failure = null; + try { + filterChain.doFilter(request, response); + } catch (ServletException | IOException | RuntimeException ex) { + failure = ex; + MDC.put("exceptionType", ex.getClass().getSimpleName()); + throw ex; + } finally { + long latencyMs = System.currentTimeMillis() - startedAt; + int status = failure == null ? response.getStatus() : resolveFailureStatus(response); + + MDC.put("status", String.valueOf(status)); + MDC.put("latencyMs", String.valueOf(latencyMs)); + MDC.put("uri", resolveUriPattern(request)); + + logRequestCompleted(status, latencyMs); + clearMdc(); + } + } + + private String resolveTraceId(HttpServletRequest request) { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + return UUID.randomUUID().toString(); + } + return requestId; + } + + private int resolveFailureStatus(HttpServletResponse response) { + int status = response.getStatus(); + if (status < HttpServletResponse.SC_BAD_REQUEST) { + return HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + } + return status; + } + + private String resolveUriPattern(HttpServletRequest request) { + Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + if (pattern instanceof String matchedPattern && !matchedPattern.isBlank()) { + return matchedPattern; + } + return request.getRequestURI(); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",", 2)[0].trim(); + } + + String realIp = request.getHeader(REAL_IP_HEADER); + if (realIp != null && !realIp.isBlank()) { + return realIp; + } + + return request.getRemoteAddr(); + } + + private void logRequestCompleted(int status, long latencyMs) { + if (status >= HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { + log.error("HTTP request completed"); + return; + } + + if (status >= HttpServletResponse.SC_BAD_REQUEST) { + log.warn("HTTP request completed"); + return; + } + + if (latencyMs >= slowRequestThresholdMs) { + log.info("HTTP slow request completed"); + return; + } + + log.debug("HTTP request completed"); + } + + private void clearMdc() { + MDC.remove("traceId"); + MDC.remove("userId"); + MDC.remove("method"); + MDC.remove("uri"); + MDC.remove("clientIp"); + MDC.remove("status"); + MDC.remove("latencyMs"); + MDC.remove("errorCode"); + MDC.remove("exceptionType"); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java index 21a02196..b03db3f8 100644 --- a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java @@ -2,31 +2,41 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.MDC; import org.springframework.http.*; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; +import org.springframework.web.ErrorResponse; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @ControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { - private final ObjectMapper objectMapper; private final DiscordWebhookNotificationService discordWebhookNotificationService; @ExceptionHandler(ApplicationException.class) public ResponseEntity handleApplicationException(ApplicationException e, WebRequest request) { CommonResponse commonResponse = CommonResponse.error(e.getErrorCase()); - HttpStatus status = HttpStatus.valueOf(e.getErrorCase().getHttpStatusCode()); - sendToDiscord(e, request, status); + HttpStatusCode status = HttpStatusCode.valueOf(e.getErrorCase().getHttpStatusCode()); + putErrorMdc(e.getErrorCase().getErrorCode(), e); + logByStatus(status, "Application exception handled", e.getMessage(), e); + notifyIfServerError(e, request, status); return ResponseEntity .status(e.getErrorCase().getHttpStatusCode()) @@ -40,14 +50,76 @@ public ResponseEntity handleValidException(BindingResult binding String message = bindingResult.getAllErrors().get(0).getDefaultMessage(); CommonResponse commonResponse = CommonResponse.error(400, message); - sendToDiscord(ex, request, HttpStatus.BAD_REQUEST); + putErrorMdc(400, ex); + logByStatus(HttpStatus.BAD_REQUEST, "Validation exception handled", message, ex); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(commonResponse); } - private void sendToDiscord(Exception ex, WebRequest request, HttpStatus status) { + @ExceptionHandler(ErrorResponseException.class) + public ResponseEntity handleErrorResponseException(ErrorResponseException ex, WebRequest request) { + return handleSpringStatusException(ex, request, ex.getStatusCode(), resolveErrorMessage(ex)); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException ex, WebRequest request) { + return handleSpringStatusException(ex, request, ex.getStatusCode(), "요청한 리소스를 찾을 수 없습니다."); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class, + MissingRequestHeaderException.class, + BindException.class, + ConstraintViolationException.class + }) + public ResponseEntity handleBadRequestException(Exception ex, WebRequest request) { + return handleSpringStatusException(ex, request, HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex, WebRequest request) { + if (ex instanceof ErrorResponse errorResponse) { + return handleSpringStatusException(ex, request, errorResponse.getStatusCode(), resolveErrorMessage(errorResponse)); + } + + CommonResponse commonResponse = CommonResponse.error(500, "서버 내부 오류가 발생했습니다."); + + putErrorMdc(500, ex); + logByStatus(HttpStatus.INTERNAL_SERVER_ERROR, "Unhandled exception handled", ex.getMessage(), ex); + notifyIfServerError(ex, request, HttpStatus.INTERNAL_SERVER_ERROR); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(commonResponse); + } + + private ResponseEntity handleSpringStatusException(Exception ex, + WebRequest request, + HttpStatusCode status, + String message) { + CommonResponse commonResponse = CommonResponse.error(status.value(), message); + + putErrorMdc(status.value(), ex); + logByStatus(status, "Spring MVC exception handled", ex.getMessage(), ex); + notifyIfServerError(ex, request, status); + + return ResponseEntity + .status(status) + .body(commonResponse); + } + + private void notifyIfServerError(Exception ex, WebRequest request, HttpStatusCode status) { + if (!status.is5xxServerError()) { + return; + } + sendToDiscord(ex, request, status); + } + + private void sendToDiscord(Exception ex, WebRequest request, HttpStatusCode status) { String path = ((ServletWebRequest) request).getRequest().getRequestURI(); String errorMessage = ex.getMessage(); String exceptionType = ex.getClass().getSimpleName(); @@ -59,5 +131,43 @@ private void sendToDiscord(Exception ex, WebRequest request, HttpStatus status) exceptionType ); } -} + private void putErrorMdc(Integer errorCode, Exception ex) { + MDC.put("errorCode", String.valueOf(errorCode)); + MDC.put("exceptionType", ex.getClass().getSimpleName()); + } + + private void logByStatus(HttpStatusCode status, String event, String detail, Exception ex) { + String exceptionType = ex.getClass().getSimpleName(); + if (status.is5xxServerError()) { + log.error("{} - status: {}, exceptionType: {}, detail: {}", + event, + status.value(), + exceptionType, + detail, + ex); + return; + } + log.warn("{} - status: {}, exceptionType: {}, detail: {}", + event, + status.value(), + exceptionType, + detail); + } + + private String resolveErrorMessage(ErrorResponseException ex) { + return resolveErrorMessage((ErrorResponse) ex); + } + + private String resolveErrorMessage(ErrorResponse errorResponse) { + if (errorResponse.getBody() == null) { + return "잘못된 요청입니다."; + } + + String detail = errorResponse.getBody().getDetail(); + if (detail == null || detail.isBlank()) { + return "잘못된 요청입니다."; + } + return detail; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java new file mode 100644 index 00000000..2988cdce --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java @@ -0,0 +1,13 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + String key(); + int limitPerDay() default 20; +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java new file mode 100644 index 00000000..95b3a6b5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java @@ -0,0 +1,46 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class RateLimitAspect { + + private static final String KEY_PREFIX = "rate_limit:"; + + private final RedisTemplate redisTemplate; + + @Before("@annotation(rateLimit)") + public void checkRateLimit(RateLimit rateLimit) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + String key = KEY_PREFIX + rateLimit.key() + ":" + userId; + + try { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, Duration.ofDays(1)); + } + if (count != null && count > rateLimit.limitPerDay()) { + log.warn("Rate limit exceeded - userId: {}, key: {}, count: {}/{}", userId, rateLimit.key(), count, rateLimit.limitPerDay()); + throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + } + } catch (ApplicationException e) { + throw e; + } catch (Exception e) { + // Redis 장애 시 서비스 중단 방지를 위해 통과 + log.warn("Rate limit check failed for key: {}, failing open - reason: {}", key, e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/config/FlywayConfig.java b/src/main/java/com/aisip/OnO/backend/config/FlywayConfig.java new file mode 100644 index 00000000..0c701356 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/FlywayConfig.java @@ -0,0 +1,29 @@ +package com.aisip.OnO.backend.config; + +import org.flywaydb.core.api.MigrationVersion; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FlywayConfig { + + @Bean + public FlywayConfigurationCustomizer flywayConfigurationCustomizer( + @Value("${spring.flyway.baseline-on-migrate:true}") boolean baselineOnMigrate, + @Value("${spring.flyway.baseline-version:1}") String baselineVersion, + @Value("${spring.flyway.baseline-description:Existing schema before Flyway adoption}") String baselineDescription, + @Value("${spring.flyway.validate-on-migrate:true}") boolean validateOnMigrate, + @Value("${spring.flyway.clean-disabled:true}") boolean cleanDisabled, + @Value("${spring.flyway.connect-retries:10}") int connectRetries + ) { + return configuration -> configuration + .baselineOnMigrate(baselineOnMigrate) + .baselineVersion(MigrationVersion.fromVersion(baselineVersion)) + .baselineDescription(baselineDescription) + .validateOnMigrate(validateOnMigrate) + .cleanDisabled(cleanDisabled) + .connectRetries(connectRetries); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java b/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java index 70424c70..40ed9e82 100644 --- a/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java +++ b/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java @@ -3,8 +3,6 @@ import com.p6spy.engine.spy.appender.MessageFormattingStrategy; import lombok.extern.slf4j.Slf4j; import org.hibernate.engine.jdbc.internal.FormatStyle; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; @@ -28,10 +26,6 @@ private static boolean isExplainEnabled() { return Boolean.parseBoolean(System.getProperty("p6spy.enable.explain", "false")); } - // EXPLAIN을 실행할 쿼리의 최소 실행 시간 (ms) - // 0으로 설정하면 모든 SELECT 쿼리에 대해 EXPLAIN 실행 - private static final long SLOW_QUERY_THRESHOLD_MS = 0; - @Override public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) { @@ -45,17 +39,14 @@ public String formatMessage(int connectionId, String now, long elapsed, // 기본 로그 출력 String formattedSql = formatSql(category, sql); - result.append(String.format("[P6Spy] | %s | took %dms | %s | connection %d\n%s\n", + result.append(String.format("[P6Spy][SLOW_QUERY] | %s | took %dms | %s | connection %d\n%s\n", now, elapsed, category, connectionId, formattedSql)); // EXPLAIN 실행 조건 체크 boolean explainEnabled = isExplainEnabled(); boolean isSelect = sql.trim().toLowerCase(Locale.ROOT).startsWith("select"); - boolean isSlowQuery = elapsed >= SLOW_QUERY_THRESHOLD_MS; - // ⭐ category 필터 제거: statement, commit 모두 EXPLAIN 실행 - // PreparedStatement도 분석하기 위해 category 조건을 완화 - boolean shouldExplain = explainEnabled && isSelect && isSlowQuery; + boolean shouldExplain = explainEnabled && isSelect; if (shouldExplain) { try { @@ -64,7 +55,7 @@ public String formatMessage(int connectionId, String now, long elapsed, result.append("╔════════════════════════════════════════════════════════════════════════════════╗\n"); result.append("║ EXPLAIN RESULT (took " + elapsed + "ms) ║\n"); result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n"); - result.append("║ Query: ").append(String.format("%-70s", sql.replaceAll("\\s+", " ").trim().substring(0, Math.min(70, sql.length())))).append(" ║\n"); + result.append("║ Query: ").append(String.format("%-70s", summarizeSql(sql))).append(" ║\n"); result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n"); result.append(explainResult); result.append("╚════════════════════════════════════════════════════════════════════════════════╝\n"); @@ -88,6 +79,11 @@ private String formatSql(String category, String sql) { return sql; } + private String summarizeSql(String sql) { + String normalizedSql = sql.replaceAll("\\s+", " ").trim(); + return normalizedSql.substring(0, Math.min(70, normalizedSql.length())); + } + private String executeExplain(String sql, String jdbcUrl) throws Exception { if (dataSource == null) { return "DataSource not available"; 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 16d7b18e..db887b34 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 @@ -8,6 +8,7 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; import com.google.firebase.messaging.Notification; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; @@ -40,15 +41,18 @@ public class FcmNotificationConsumer { */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_QUEUE, concurrency = "5-15") public void handleNotificationMessage(FcmNotificationMessage message) { - log.info("[FCM Notification Consumer] 메시지 수신 - userId: {}, title: {}, retryCount: {}", - message.getUserId(), message.getTitle(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, userId: {}, messageRetryCount: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", + message.getUserId(), message.getRetryCount()); try { // 사용자의 모든 FCM 토큰 조회 List userFcmTokenList = fcmTokenRepository.findAllByUserId(message.getUserId()); if (userFcmTokenList.isEmpty()) { - log.warn("[FCM Notification Consumer] FCM 토큰 없음 - userId: {}", message.getUserId()); + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "token_not_found", + message.getUserId(), message.getRetryCount()); return; // 토큰 없으면 스킵 (정상 처리) } @@ -60,23 +64,35 @@ public void handleNotificationMessage(FcmNotificationMessage message) { try { sendToDevice(fcmToken.getToken(), message); successCount++; + } catch (FirebaseMessagingException e) { + if (isInvalidToken(e)) { + fcmTokenRepository.deleteByToken(fcmToken.getToken()); + log.info("만료된 FCM 토큰 삭제 - userId: {}, errorCode: {}", message.getUserId(), e.getMessagingErrorCode()); + } else { + log.warn("RabbitMQ device delivery failed - queue: {}, operation: {}, userId: {}, error: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", message.getUserId(), e.getMessage()); + failCount++; + } } catch (Exception e) { - log.warn("[FCM Notification Consumer] 디바이스 전송 실패 - userId: {}, token: {}, error: {}", - message.getUserId(), fcmToken.getToken(), e.getMessage()); + log.warn("RabbitMQ device delivery failed - queue: {}, operation: {}, userId: {}, error: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", message.getUserId(), e.getMessage()); failCount++; } } - log.info("[FCM Notification Consumer] 전송 완료 - userId: {}, 성공: {}, 실패: {}", - message.getUserId(), successCount, failCount); - // 모든 디바이스 전송 실패 시 예외 발생 (재시도) if (successCount == 0 && failCount > 0) { throw new RuntimeException("모든 디바이스 FCM 전송 실패 - userId: " + message.getUserId()); } + String outcome = failCount > 0 ? "partial_success" : "success"; + log.info("RabbitMQ message processed - queue: {}, operation: {}, outcome: {}, userId: {}, successCount: {}, failCount: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", outcome, + message.getUserId(), successCount, failCount); + } catch (Exception e) { - log.error("[FCM Notification Consumer] 알림 전송 실패 - userId: {}, retryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}, error: {}", + RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "failure", message.getUserId(), message.getRetryCount(), e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 @@ -101,7 +117,8 @@ private void sendToDevice(String token, FcmNotificationMessage message) throws F try { String messageId = firebaseMessaging.send(fcmMessage); recordExternalCall("firebase", "send_notification_async", "success", sample); - log.debug("[FCM Notification Consumer] FCM 전송 성공 - messageId: {}", messageId); + log.debug("External delivery succeeded - dependency: {}, operation: {}, messageId: {}", + "firebase", "send_notification_async", messageId); } catch (FirebaseMessagingException e) { recordExternalCall("firebase", "send_notification_async", "failure", sample); throw e; @@ -115,17 +132,18 @@ private void sendToDevice(String token, FcmNotificationMessage message) throws F */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_DLQ) public void handleNotificationDLQ(FcmNotificationMessage message) { - log.error("[FCM Notification DLQ] 최종 실패 메시지 - userId: {}, title: {}, retryCount: {}", - message.getUserId(), message.getTitle(), message.getRetryCount()); + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", "dlq", + message.getUserId(), message.getRetryCount()); // Discord 알림 전송 String errorTitle = String.format("🚨 FCM 푸시 알림 최종 실패 (DLQ)"); String errorDetails = String.format( - "**User ID:** %d\n**Title:** %s\n**Body:** %s\n**Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**User ID:** %d\n**Message Retry Count:** %d\n\n" + "모든 재시도가 실패했습니다. FCM 토큰을 확인하거나 수동으로 알림을 재전송해주세요.", + RabbitMQConfig.FCM_NOTIFICATION_DLQ, + "fcm_notification", message.getUserId(), - message.getTitle(), - message.getBody(), message.getRetryCount() ); @@ -136,12 +154,20 @@ public void handleNotificationDLQ(FcmNotificationMessage message) { "ERROR", errorTitle ); - log.info("[FCM Notification DLQ] Discord 알림 전송 완료"); + log.info("RabbitMQ DLQ notification sent - queue: {}, operation: {}, userId: {}", + RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", message.getUserId()); } catch (Exception e) { - log.error("[FCM Notification DLQ] Discord 알림 전송 실패: {}", e.getMessage()); + log.error("RabbitMQ DLQ notification failed - queue: {}, operation: {}, userId: {}, error: {}", + RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", message.getUserId(), e.getMessage()); } } + private boolean isInvalidToken(FirebaseMessagingException e) { + MessagingErrorCode code = e.getMessagingErrorCode(); + return code == MessagingErrorCode.UNREGISTERED + || code == MessagingErrorCode.INVALID_ARGUMENT; + } + private void recordExternalCall(String dependency, String operation, String outcome, Timer.Sample sample) { sample.stop( Timer.builder("ono.external.requests") diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java index 8212ed13..969b120e 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java @@ -32,34 +32,39 @@ public class ProblemAnalysisConsumer { */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_QUEUE, concurrency = "1-3") public void handleAnalysisMessage(ProblemAnalysisMessage message) { - log.info("[GPT Analysis Consumer] 메시지 수신 - problemId: {}, retryCount: {}", - message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), message.getRetryCount()); try { // 실제 GPT 분석 수행 analysisService.analyzeProblemSync(message.getProblemId()); - log.info("[GPT Analysis Consumer] 분석 성공 - problemId: {}", message.getProblemId()); + log.info("RabbitMQ message processed - queue: {}, operation: {}, outcome: {}, problemId: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "success", message.getProblemId()); } catch (NonRetryableAnalysisException e) { - log.warn("[GPT Analysis Consumer] 비재시도 분석 실패 처리 - problemId: {}, error: {}", - message.getProblemId(), e.getMessage()); + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, reason: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "non_retryable_failure", + message.getProblemId(), message.getRetryCount(), e.getMessage()); // 상태는 Service에서 FAILED로 업데이트됨. 재큐잉 없이 종료(ACK) return; } catch (ApplicationException e) { if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND) { - log.warn("[GPT Analysis Consumer] 문제가 이미 삭제/미존재하여 분석 스킵 - problemId: {}", - message.getProblemId()); + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "resource_not_found", + message.getProblemId(), message.getRetryCount()); return; } - log.error("[GPT Analysis Consumer] 분석 실패 - problemId: {}, retryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", message.getProblemId(), message.getRetryCount(), e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("GPT 문제 분석 실패: " + message.getProblemId(), e); } catch (Exception e) { - log.error("[GPT Analysis Consumer] 분석 실패 - problemId: {}, retryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", message.getProblemId(), message.getRetryCount(), e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 @@ -74,14 +79,17 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_DLQ) public void handleAnalysisDLQ(ProblemAnalysisMessage message) { - log.error("[GPT Analysis DLQ] 최종 실패 메시지 - problemId: {}, retryCount: {}", + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", "dlq", message.getProblemId(), message.getRetryCount()); // Discord 알림 전송 String errorTitle = String.format("🚨 GPT 문제 분석 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Problem ID:** %d\n**Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Message Retry Count:** %d\n\n" + "모든 재시도가 실패했습니다. 문제를 확인하고 수동으로 재분석을 요청해주세요.", + RabbitMQConfig.GPT_ANALYSIS_DLQ, + "problem_analysis", message.getProblemId(), message.getRetryCount() ); @@ -93,9 +101,11 @@ public void handleAnalysisDLQ(ProblemAnalysisMessage message) { "ERROR", errorTitle ); - log.info("[GPT Analysis DLQ] Discord 알림 전송 완료"); + log.info("RabbitMQ DLQ notification sent - queue: {}, operation: {}, problemId: {}", + RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", message.getProblemId()); } catch (Exception e) { - log.error("[GPT Analysis DLQ] Discord 알림 전송 실패: {}", e.getMessage()); + log.error("RabbitMQ DLQ notification failed - queue: {}, operation: {}, problemId: {}, error: {}", + RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", message.getProblemId(), e.getMessage()); } } } diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java index b274d149..7bf921fb 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java @@ -29,22 +29,23 @@ public class S3DeleteConsumer { */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_QUEUE, concurrency = "3-10") public void handleS3DeleteMessage(S3DeleteMessage message) { - log.info("[S3 Delete Consumer] 메시지 수신 - imageUrl: {}, problemId: {}, retryCount: {}", - message.getImageUrl(), message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", message.getProblemId(), message.getRetryCount()); try { // S3 파일 삭제 실행 fileUploadService.deleteImageFileFromS3(message.getImageUrl()); - log.info("[S3 Delete Consumer] 삭제 성공 - imageUrl: {}, problemId: {}", - message.getImageUrl(), message.getProblemId()); + log.info("RabbitMQ message processed - queue: {}, operation: {}, outcome: {}, problemId: {}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "success", message.getProblemId()); } catch (Exception e) { - log.error("[S3 Delete Consumer] 삭제 실패 - imageUrl: {}, problemId: {}, retryCount: {}, error: {}", - message.getImageUrl(), message.getProblemId(), message.getRetryCount(), e.getMessage()); + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "failure", + message.getProblemId(), message.getRetryCount(), e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 - throw new RuntimeException("S3 파일 삭제 실패: " + message.getImageUrl(), e); + throw new RuntimeException("S3 파일 삭제 실패 - problemId: " + message.getProblemId(), e); } } @@ -55,16 +56,21 @@ public void handleS3DeleteMessage(S3DeleteMessage message) { */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_DLQ) public void handleS3DeleteDLQ(S3DeleteMessage message) { - log.error("[S3 Delete DLQ] 최종 실패 메시지 - imageUrl: {}, problemId: {}, retryCount: {}", - message.getImageUrl(), message.getProblemId(), message.getRetryCount()); + String maskedObjectKey = maskS3ObjectKey(message.getImageUrl()); + + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, objectKey: {}, messageRetryCount: {}", + RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", "dlq", + message.getProblemId(), maskedObjectKey, message.getRetryCount()); // Discord 알림 전송 String errorTitle = String.format("🚨 S3 파일 삭제 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Problem ID:** %d\n**Image URL:** %s\n**Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Object Key:** %s\n**Message Retry Count:** %d\n\n" + "모든 재시도가 실패했습니다. 수동으로 S3에서 파일을 삭제하거나 메시지를 재처리해주세요.", + RabbitMQConfig.S3_DELETE_DLQ, + "s3_delete", message.getProblemId(), - message.getImageUrl(), + maskedObjectKey, message.getRetryCount() ); @@ -75,9 +81,29 @@ public void handleS3DeleteDLQ(S3DeleteMessage message) { "ERROR", errorTitle ); - log.info("[S3 Delete DLQ] Discord 알림 전송 완료"); + log.info("RabbitMQ DLQ notification sent - queue: {}, operation: {}, problemId: {}", + RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", message.getProblemId()); } catch (Exception e) { - log.error("[S3 Delete DLQ] Discord 알림 전송 실패: {}", e.getMessage()); + log.error("RabbitMQ DLQ notification failed - queue: {}, operation: {}, problemId: {}, error: {}", + RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", message.getProblemId(), e.getMessage()); + } + } + + private String maskS3ObjectKey(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + return "unknown"; } + + String splitStr = ".com/"; + int keyStartIndex = imageUrl.lastIndexOf(splitStr); + String objectKey = keyStartIndex >= 0 + ? imageUrl.substring(keyStartIndex + splitStr.length()) + : imageUrl; + + if (objectKey.length() <= 16) { + return "***"; + } + + return objectKey.substring(0, 8) + "***" + objectKey.substring(objectKey.length() - 8); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java index f73d57ac..e64ba568 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java @@ -37,11 +37,14 @@ public void sendNotificationMessage(Long userId, String title, String body, Map< message ); - log.info("[FCM Notification Producer] 메시지 전송 성공 - userId: {}, title: {}", userId, title); + log.info("RabbitMQ message sent - exchange: {}, routingKey: {}, operation: {}, userId: {}", + RabbitMQConfig.NOTIFICATION_EXCHANGE, RabbitMQConfig.FCM_NOTIFICATION_ROUTING_KEY, + "fcm_notification", userId); } catch (Exception e) { - log.error("[FCM Notification Producer] 메시지 전송 실패 - userId: {}, error: {}", - userId, e.getMessage(), e); + log.error("RabbitMQ message send failed - exchange: {}, routingKey: {}, operation: {}, userId: {}, error: {}", + RabbitMQConfig.NOTIFICATION_EXCHANGE, RabbitMQConfig.FCM_NOTIFICATION_ROUTING_KEY, + "fcm_notification", userId, e.getMessage(), e); // 알림 전송 실패해도 예외를 던지지 않음 (알림은 선택적 기능) } } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/ProblemAnalysisProducer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/ProblemAnalysisProducer.java index bb43f8ad..1fb40db8 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/ProblemAnalysisProducer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/ProblemAnalysisProducer.java @@ -32,10 +32,13 @@ public void sendAnalysisMessage(Long problemId) { message ); - log.info("[GPT Analysis Producer] 메시지 전송 성공 - problemId: {}", problemId); + log.info("RabbitMQ message sent - exchange: {}, routingKey: {}, operation: {}, problemId: {}", + RabbitMQConfig.ANALYSIS_EXCHANGE, RabbitMQConfig.GPT_ANALYSIS_ROUTING_KEY, + "problem_analysis", problemId); } catch (Exception e) { - log.error("[GPT Analysis Producer] 메시지 전송 실패 - problemId: {}, error: {}", - problemId, e.getMessage(), e); + log.error("RabbitMQ message send failed - exchange: {}, routingKey: {}, operation: {}, problemId: {}, error: {}", + RabbitMQConfig.ANALYSIS_EXCHANGE, RabbitMQConfig.GPT_ANALYSIS_ROUTING_KEY, + "problem_analysis", problemId, e.getMessage(), e); // 메시지 전송 실패해도 예외를 던지지 않음 (분석은 선택적 기능) } } diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/S3DeleteProducer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/S3DeleteProducer.java index 446e30ce..da094a89 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/S3DeleteProducer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/S3DeleteProducer.java @@ -33,10 +33,13 @@ public void sendDeleteMessage(String imageUrl, Long problemId) { message ); - log.info("[S3 Delete Producer] 메시지 전송 성공 - imageUrl: {}, problemId: {}", imageUrl, problemId); + log.info("RabbitMQ message sent - exchange: {}, routingKey: {}, operation: {}, problemId: {}", + RabbitMQConfig.FILE_EXCHANGE, RabbitMQConfig.S3_DELETE_ROUTING_KEY, + "s3_delete", problemId); } catch (Exception e) { - log.error("[S3 Delete Producer] 메시지 전송 실패 - imageUrl: {}, problemId: {}, error: {}", - imageUrl, problemId, e.getMessage(), e); + log.error("RabbitMQ message send failed - exchange: {}, routingKey: {}, operation: {}, problemId: {}, error: {}", + RabbitMQConfig.FILE_EXCHANGE, RabbitMQConfig.S3_DELETE_ROUTING_KEY, + "s3_delete", problemId, e.getMessage(), e); // 메시지 전송 실패해도 DB 삭제는 완료되므로 예외를 던지지 않음 } } @@ -49,4 +52,4 @@ public void sendDeleteMessage(String imageUrl, Long problemId) { public void sendBulkDeleteMessages(Iterable imageUrls, Long problemId) { imageUrls.forEach(imageUrl -> sendDeleteMessage(imageUrl, problemId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java index ca212c7e..3e5680d4 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java @@ -8,11 +8,11 @@ @RequiredArgsConstructor public enum FolderErrorCase implements ErrorCase { - FOLDER_NOT_FOUND(400, 5001, "폴더를 찾을 수 없습니다."), + FOLDER_NOT_FOUND(404, 5001, "폴더를 찾을 수 없습니다."), - FOLDER_USER_UNMATCHED(400, 5002, "폴더를 소유한 유저가 아닙니다."), + FOLDER_USER_UNMATCHED(403, 5002, "폴더를 소유한 유저가 아닙니다."), - ROOT_FOLDER_NOT_EXIST(400, 5003, "루트 폴더가 존재하지 않습니다."), + ROOT_FOLDER_NOT_EXIST(404, 5003, "루트 폴더가 존재하지 않습니다."), ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다."); diff --git a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java index 08bc9260..060f0a5e 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java @@ -10,7 +10,7 @@ public enum MissionErrorCase implements ErrorCase { MISSION_TYPE_NOT_FOUND(400, 7001, "잘못된 미션 종류입니다."), - USER_NOT_FOUND(400, 7002, "해당하는 유저가 존재하지 않습니다."); + USER_NOT_FOUND(404, 7002, "해당하는 유저가 존재하지 않습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java index 7d355cd7..6a2b211c 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java @@ -1,9 +1,52 @@ package com.aisip.OnO.backend.mission.repository; +import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.entity.MissionLog; +import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MissionLogRepository extends JpaRepository, MissionLogRepositoryCustom { List findAllByUserId(Long userId); + + List findAllByMissionType(MissionType missionType, Pageable pageable); + + @Query("SELECT m FROM MissionLog m JOIN FETCH m.user WHERE m.missionType = :missionType") + List findAllByMissionTypeWithUser(@Param("missionType") MissionType missionType, Pageable pageable); + + long countByMissionType(MissionType missionType); + + long countByMissionTypeAndCreatedAtBetween( + MissionType missionType, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ); + + @Query(""" + SELECT COUNT(DISTINCT m.user.id) + FROM MissionLog m + WHERE m.missionType = :missionType + AND m.createdAt BETWEEN :startDateTime AND :endDateTime + """) + long countDistinctUsersByMissionTypeAndCreatedAtBetween( + @Param("missionType") MissionType missionType, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime + ); + + @Query(""" + SELECT FUNCTION('DATE', m.createdAt), COUNT(m) + FROM MissionLog m + WHERE m.missionType = :missionType + AND m.createdAt BETWEEN :startDateTime AND :endDateTime + GROUP BY FUNCTION('DATE', m.createdAt) + """) + List countDailyByMissionType( + @Param("missionType") MissionType missionType, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime + ); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java index 541e4bbf..33a6cbea 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java @@ -19,5 +19,7 @@ public interface MissionLogRepositoryCustom { Map getDailyActiveUsersCount(int days); + Map getDailyActiveUsersCount(LocalDate startDate, LocalDate endDate); + List getActiveUsersByDate(LocalDate date); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java index 20c3a722..f48c0450 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java @@ -2,9 +2,15 @@ import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.user.entity.User; +import com.querydsl.core.Tuple; +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 java.sql.Date; +import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -87,24 +93,40 @@ public Long getPointSumToday(Long userId){ @Override public Map getDailyActiveUsersCount(int days) { - Map result = new LinkedHashMap<>(); LocalDate today = LocalDate.now(); + return getDailyActiveUsersCount(today.minusDays(days - 1L), today); + } + + @Override + public Map getDailyActiveUsersCount(LocalDate startDate, LocalDate endDate) { + Map result = new LinkedHashMap<>(); + DateExpression createdDate = Expressions.dateTemplate( + LocalDate.class, + "date({0})", + missionLog.createdAt + ); + NumberExpression activeUserCount = missionLog.user.id.countDistinct(); + + java.util.List dailyCounts = queryFactory + .select(createdDate, activeUserCount) + .from(missionLog) + .where(missionLog.missionType.eq(MissionType.USER_LOGIN) + .and(missionLog.createdAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX))) + ) + .groupBy(createdDate) + .fetch(); - // 최근 날짜가 위로 오도록 역순으로 조회 - for (int i = 0; i < days; i++) { - LocalDate date = today.minusDays(i); - LocalDateTime startOfDay = date.atStartOfDay(); - LocalDateTime endOfDay = date.atTime(LocalTime.MAX); - - Long count = queryFactory - .select(missionLog.user.id.countDistinct()) - .from(missionLog) - .where(missionLog.missionType.eq(MissionType.USER_LOGIN) - .and(missionLog.createdAt.between(startOfDay, endOfDay)) - ) - .fetchOne(); - - result.put(date, count != null ? count : 0L); + Map countByDate = new LinkedHashMap<>(); + for (Tuple row : dailyCounts) { + LocalDate date = toLocalDate(row.get(createdDate)); + Long count = row.get(activeUserCount); + if (date != null) { + countByDate.put(date, count != null ? count : 0L); + } + } + + for (LocalDate date = endDate; !date.isBefore(startDate); date = date.minusDays(1)) { + result.put(date, countByDate.getOrDefault(date, 0L)); } return result; @@ -132,4 +154,20 @@ private LocalDateTime getStartOfToday() { private LocalDateTime getEndOfToday() { return LocalDate.now().atTime(LocalTime.MAX); } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return value != null ? LocalDate.parse(value.toString()) : null; + } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index facc7b10..4a9f58d1 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -1,18 +1,32 @@ package com.aisip.OnO.backend.mission.service; +import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; 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.exception.MissionErrorCase; import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; +import java.sql.Date; +import java.sql.Timestamp; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +39,8 @@ public class MissionLogService { private final UserRepository userRepository; + private final PracticeNoteRepository practiceNoteRepository; + private static final Long DAILY_MISSION_POINT_LIMIT = 200L; public Long registerMissionLog(@NotNull MissionRegisterDto missionRegisterDto) { @@ -158,8 +174,93 @@ public Map getDailyActiveUsersCount(int days) { return missionLogRepository.getDailyActiveUsersCount(days); } + @Transactional(readOnly = true) + public Map getDailyActiveUsersCount(LocalDate startDate, LocalDate endDate) { + return missionLogRepository.getDailyActiveUsersCount(startDate, endDate); + } + + @Transactional(readOnly = true) + public Map getDailyVisitCount(LocalDate startDate, LocalDate endDate) { + return getDailyMissionCount(MissionType.USER_LOGIN, startDate, endDate); + } + + @Transactional(readOnly = true) + public long countUniqueVisitors(LocalDate startDate, LocalDate endDate) { + return missionLogRepository.countDistinctUsersByMissionTypeAndCreatedAtBetween( + MissionType.USER_LOGIN, + startDate.atStartOfDay(), + endDate.atTime(LocalTime.MAX) + ); + } + @Transactional(readOnly = true) public List getActiveUsersByDate(LocalDate date) { return missionLogRepository.getActiveUsersByDate(date); } + + @Transactional(readOnly = true) + public Page findAdminPracticeLogs(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + List missionLogs = missionLogRepository.findAllByMissionTypeWithUser(MissionType.NOTE_PRACTICE, pageRequest); + long total = missionLogRepository.countByMissionType(MissionType.NOTE_PRACTICE); + List practiceNoteIds = missionLogs.stream() + .map(MissionLog::getReferenceId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map practiceNotesById = practiceNoteRepository.findAllById(practiceNoteIds).stream() + .collect(Collectors.toMap(PracticeNote::getId, Function.identity())); + + List content = missionLogs.stream() + .map(missionLog -> AdminPracticeLogResponseDto.from( + missionLog, + practiceNotesById.get(missionLog.getReferenceId()) + )) + .toList(); + + return new PageImpl<>(content, pageRequest, total); + } + + @Transactional(readOnly = true) + public long countNotePracticeLogs() { + return missionLogRepository.countByMissionType(MissionType.NOTE_PRACTICE); + } + + @Transactional(readOnly = true) + public Map getDailyNotePracticeLogsCount(LocalDate startDate, LocalDate endDate) { + return getDailyMissionCount(MissionType.NOTE_PRACTICE, startDate, endDate); + } + + private Map getDailyMissionCount(MissionType missionType, LocalDate startDate, LocalDate endDate) { + Map result = new LinkedHashMap<>(); + missionLogRepository.countDailyByMissionType( + missionType, + startDate.atStartOfDay(), + endDate.atTime(LocalTime.MAX) + ) + .forEach(row -> result.put(toLocalDate(row[0]), (Long) row[1])); + + Map orderedResult = new LinkedHashMap<>(); + for (LocalDate date = endDate; !date.isBefore(startDate); date = date.minusDays(1)) { + orderedResult.put(date, result.getOrDefault(date, 0L)); + } + + return orderedResult; + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java index 47c6f7cf..46a8a65b 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java @@ -8,7 +8,7 @@ @RequiredArgsConstructor public enum PracticeNoteErrorCase implements ErrorCase { - PRACTICE_NOTE_NOT_FOUND(400, 6001, "복습 노트를 찾을 수 없습니다."); + PRACTICE_NOTE_NOT_FOUND(404, 6001, "복습 노트를 찾을 수 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepository.java b/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepository.java index da8b8479..1ee22960 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepository.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepository.java @@ -5,12 +5,34 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface PracticeNoteRepository extends JpaRepository, PracticeNoteRepositoryCustom { List findAllByUserId(Long userId); + long countByCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime); + + @Query(""" + SELECT FUNCTION('DATE', p.createdAt), COUNT(p) + FROM PracticeNote p + WHERE p.createdAt BETWEEN :startDateTime AND :endDateTime + GROUP BY FUNCTION('DATE', p.createdAt) + """) + List countDailyPracticeNotes( + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime + ); + + @Query(""" + SELECT m.practiceNote.id, COUNT(m.problem.id) + FROM ProblemPracticeNoteMapping m + WHERE m.practiceNote.id IN :practiceNoteIds + GROUP BY m.practiceNote.id + """) + List countProblemsByPracticeNoteIds(@Param("practiceNoteIds") List practiceNoteIds); + @Query("SELECT p.id FROM PracticeNote p WHERE p.userId = :userId") List findAllPracticeIdsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index 624e554b..62683f5c 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.practicenote.service; +import com.aisip.OnO.backend.admin.dto.AdminPracticeNoteResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.mission.service.MissionLogService; @@ -13,13 +14,27 @@ import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; @Slf4j @@ -39,6 +54,8 @@ public class PracticeNoteService { private final MissionLogService missionLogService; + private final UserRepository userRepository; + private PracticeNote getPracticeEntity(Long practiceId){ return practiceNoteRepository.findById(practiceId) @@ -226,4 +243,73 @@ public CursorPageResponse findPracticeThumbnai log.info("userId: {} find practice thumbnails with cursor: {}, size: {}, hasNext: {}", userId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); } + + @Transactional(readOnly = true) + public Page findAdminPracticeNotes(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page practiceNotePage = practiceNoteRepository.findAll(pageRequest); + List practiceNotes = practiceNotePage.getContent(); + List userIds = practiceNotes.stream() + .map(PracticeNote::getUserId) + .distinct() + .toList(); + List practiceNoteIds = practiceNotes.stream() + .map(PracticeNote::getId) + .toList(); + + Map usersById = userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + Map problemCountsByPracticeNoteId = practiceNoteIds.isEmpty() + ? Map.of() + : practiceNoteRepository.countProblemsByPracticeNoteIds(practiceNoteIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + List content = practiceNotes.stream() + .map(practiceNote -> { + User user = usersById.get(practiceNote.getUserId()); + Long problemCount = problemCountsByPracticeNoteId.getOrDefault(practiceNote.getId(), 0L); + return AdminPracticeNoteResponseDto.from(practiceNote, user, problemCount); + }) + .toList(); + + return new PageImpl<>(content, pageRequest, practiceNotePage.getTotalElements()); + } + + @Transactional(readOnly = true) + public long countAllPracticeNotes() { + return practiceNoteRepository.count(); + } + + @Transactional(readOnly = true) + public Map getDailyPracticeNotesCount(LocalDate startDate, LocalDate endDate) { + Map result = new LinkedHashMap<>(); + practiceNoteRepository.countDailyPracticeNotes(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX)) + .forEach(row -> result.put(toLocalDate(row[0]), (Long) row[1])); + + Map orderedResult = new LinkedHashMap<>(); + for (LocalDate date = endDate; !date.isBefore(startDate); date = date.minusDays(1)) { + orderedResult.put(date, result.getOrDefault(date, 0L)); + } + + return orderedResult; + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } } 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 78f87c62..07cbb456 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 @@ -1,8 +1,10 @@ package com.aisip.OnO.backend.problem.controller; +import com.aisip.OnO.backend.common.ratelimit.RateLimit; import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; @@ -113,6 +115,7 @@ public CommonResponse getProblemAnalysis(@PathVariab } // ✅ 문제 분석 요청 (비동기 트리거) + @RateLimit(key = "ai_analysis", limitPerDay = 20) @PostMapping("/{problemId}/analysis") public CommonResponse requestProblemAnalysis(@PathVariable("problemId") Long problemId) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); @@ -141,6 +144,7 @@ public CommonResponse registerProblemV2(@RequestBody ProblemRegisterV2Dto } // ✅ 문제 이미지 비동기 업로드 + @RateLimit(key = "ai_analysis", limitPerDay = 20) @PostMapping("/{problemId}/imageData") public CommonResponse uploadProblemImages( @PathVariable("problemId") Long problemId, @@ -222,4 +226,11 @@ public CommonResponse deleteProblemImageData(@RequestParam("imageUrl") S return CommonResponse.success("문제 이미지 데이터 삭제가 완료되었습니다."); } + // ✅ 오늘 복습 대상 문제 조회 + @GetMapping("/review-due") + public CommonResponse getReviewDueProblems() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(problemService.getReviewDueProblems(userId)); + } + } diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java index 2dd167c2..81bd6e6c 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java @@ -30,6 +30,10 @@ public record ProblemResponseDto ( List imageUrlList, + Long solveCount, + + LocalDateTime lastSolvedAt, + ProblemAnalysisResponseDto analysis, List tagIdList, @@ -37,6 +41,10 @@ public record ProblemResponseDto ( List tags ) { public static ProblemResponseDto from(@NotNull Problem problem) { + return from(problem, 0L, null); + } + + public static ProblemResponseDto from(@NotNull Problem problem, Long solveCount, LocalDateTime lastSolvedAt) { List problemImageDataList = Optional.ofNullable(problem.getProblemImageDataList()) .orElse(List.of()) @@ -69,6 +77,8 @@ public static ProblemResponseDto from(@NotNull Problem problem) { .createdAt(problem.getCreatedAt()) .updatedAt(problem.getUpdatedAt()) .imageUrlList(problemImageDataList) + .solveCount(solveCount) + .lastSolvedAt(lastSolvedAt) .analysis(analysisDto) .tagIdList(tagIds) .tags(tags) diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java new file mode 100644 index 00000000..4f0dc8bb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java @@ -0,0 +1,35 @@ +package com.aisip.OnO.backend.problem.dto; + +import com.aisip.OnO.backend.problem.entity.Problem; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record ReviewDueResponseDto( + long dueCount, + long overdueCount, + List problems +) { + @Builder + public record ReviewDueProblemDto( + Long problemId, + String memo, + String reference, + LocalDate nextReviewAt, + int reviewInterval, + int consecutiveCorrectCount + ) { + public static ReviewDueProblemDto from(Problem problem) { + return ReviewDueProblemDto.builder() + .problemId(problem.getId()) + .memo(problem.getMemo()) + .reference(problem.getReference()) + .nextReviewAt(problem.getNextReviewAt()) + .reviewInterval(problem.getReviewInterval()) + .consecutiveCorrectCount(problem.getConsecutiveCorrectCount()) + .build(); + } + } +} \ No newline at end of file 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 885e42f4..6fa22a86 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 @@ -10,6 +10,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -43,6 +44,16 @@ public class Problem extends BaseEntity { private LocalDateTime solvedAt; + private LocalDate nextReviewAt; + + @Column(nullable = false) + @Builder.Default + private int reviewInterval = 1; + + @Column(nullable = false) + @Builder.Default + private int consecutiveCorrectCount = 0; + @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) private List problemImageDataList = new ArrayList<>(); @@ -123,4 +134,10 @@ public void removeTagMappingFromProblem(ProblemTagMapping problemTagMapping) { public void updateProblemAnalysis(ProblemAnalysis analysis) { this.problemAnalysis = analysis; } + + public void updateReviewSchedule(LocalDate nextReviewAt, int reviewInterval, int consecutiveCorrectCount) { + this.nextReviewAt = nextReviewAt; + this.reviewInterval = reviewInterval; + this.consecutiveCorrectCount = consecutiveCorrectCount; + } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java index 860b75aa..b6b2f149 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java @@ -8,13 +8,15 @@ @RequiredArgsConstructor public enum ProblemErrorCase implements ErrorCase { - PROBLEM_NOT_FOUND(400, 4001, "문제를 찾을 수 없습니다."), + PROBLEM_NOT_FOUND(404, 4001, "문제를 찾을 수 없습니다."), - PROBLEM_USER_UNMATCHED(400, 4002, "문제 작성자가 아닙니다."), + PROBLEM_USER_UNMATCHED(403, 4002, "문제 작성자가 아닙니다."), PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED(400, 4003, "이미 오늘의 복습을 완료한 문제입니다."), - PROBLEM_ANALYSIS_NOT_FOUND(404, 4004, "문제 분석 결과를 찾을 수 없습니다."); + PROBLEM_ANALYSIS_NOT_FOUND(404, 4004, "문제 분석 결과를 찾을 수 없습니다."), + + ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java new file mode 100644 index 00000000..bfc471aa --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java @@ -0,0 +1,142 @@ +package com.aisip.OnO.backend.problem.quartz; + +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.repository.ReviewDueSummary; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +public class ReviewDueNotificationJob extends QuartzJobBean { + + private static final int REENGAGEMENT_THRESHOLD_DAYS = 7; + private static final int REENGAGEMENT_INTERVAL_DAYS = 5; + private static final int LONG_INACTIVE_THRESHOLD_DAYS = 30; + private static final int LONG_INACTIVE_INTERVAL_DAYS = 30; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FcmService fcmService; + + @Override + protected void executeInternal(JobExecutionContext context) { + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + ZoneId seoul = ZoneId.of("Asia/Seoul"); + + LocalDateTime reengagementCutoff = today.minusDays(REENGAGEMENT_THRESHOLD_DAYS).atStartOfDay(seoul).toLocalDateTime(); + LocalDateTime longInactiveCutoff = today.minusDays(LONG_INACTIVE_THRESHOLD_DAYS).atStartOfDay(seoul).toLocalDateTime(); + + sendReviewNotifications(today, reengagementCutoff); + sendReengagementNotifications(today, reengagementCutoff, longInactiveCutoff); + sendLongInactiveReengagementNotifications(today, longInactiveCutoff); + } + + // 흐름 1: 복습 예정일이 된 문제가 있는 활성 유저에게 복습 알림 + private void sendReviewNotifications(LocalDate today, LocalDateTime reengagementCutoff) { + List summaries = problemRepository.findReviewDueSummaryByDate(today); + if (summaries.isEmpty()) return; + + Map dueCountByUserId = summaries.stream() + .collect(Collectors.toMap(ReviewDueSummary::getUserId, ReviewDueSummary::getDueCount)); + + List users = userRepository.findAllById(new ArrayList<>(dueCountByUserId.keySet())); + List notifiedUserIds = new ArrayList<>(); + + for (User user : users) { + // 알림 수신 거부한 유저 스킵 + if (!user.isNotificationEnabled()) continue; + + // 7일 이상 미접속 유저는 재참여 흐름으로 처리 + if (user.getLastActiveAt() == null || user.getLastActiveAt().isBefore(reengagementCutoff)) { + continue; + } + // 오늘 이미 알림 받은 경우 스킵 + if (today.equals(user.getLastNotifiedAt())) { + continue; + } + + long dueCount = dueCountByUserId.get(user.getId()); + fcmService.sendNotificationToAllUserDevice(user.getId(), + new NotificationRequestDto("", "오늘의 복습 알림", + "오늘 복습할 문제가 " + dueCount + "개 있어요!", + Map.of("type", "review_due"))); + notifiedUserIds.add(user.getId()); + } + + if (!notifiedUserIds.isEmpty()) { + userRepository.bulkUpdateLastNotifiedAt(notifiedUserIds, today); + } + log.info("[ReviewDue] 복습 알림 발송: {}명", notifiedUserIds.size()); + } + + // 흐름 2: 일정 기간 미접속 유저에게 재참여 알림 (due 문제 여부 무관) + private void sendReengagementNotifications(LocalDate today, LocalDateTime reengagementCutoff, LocalDateTime inactiveCutoff) { + LocalDate notificationCutoff = today.minusDays(REENGAGEMENT_INTERVAL_DAYS); + + List inactiveUsers = userRepository.findUsersForReengagement( + reengagementCutoff, inactiveCutoff, notificationCutoff); + + if (inactiveUsers.isEmpty()) return; + + List notifiedUserIds = new ArrayList<>(); + + for (User user : inactiveUsers) { + if (!user.isNotificationEnabled()) continue; + fcmService.sendNotificationToAllUserDevice(user.getId(), + new NotificationRequestDto("", "오랜만이에요!", + "오답노트를 펼칠 시간이에요. 복습하러 돌아와보세요!", + Map.of("type", "reengagement"))); + notifiedUserIds.add(user.getId()); + } + + if (!notifiedUserIds.isEmpty()) { + userRepository.bulkUpdateLastNotifiedAt(notifiedUserIds, today); + } + log.info("[ReviewDue] 재참여 알림 발송: {}명", notifiedUserIds.size()); + } + + // 흐름 3: 30일 초과 미접속 유저에게 월 1회 알림 + private void sendLongInactiveReengagementNotifications(LocalDate today, LocalDateTime longInactiveCutoff) { + LocalDate notificationCutoff = today.minusDays(LONG_INACTIVE_INTERVAL_DAYS); + + List longInactiveUsers = userRepository.findUsersForLongInactiveReengagement( + longInactiveCutoff, notificationCutoff); + + if (longInactiveUsers.isEmpty()) return; + + List notifiedUserIds = new ArrayList<>(); + + for (User user : longInactiveUsers) { + if (!user.isNotificationEnabled()) continue; + fcmService.sendNotificationToAllUserDevice(user.getId(), + new NotificationRequestDto("", "오답노트가 기다리고 있어요", + "한동안 자리를 비우셨네요. 다시 시작하기 딱 좋은 날이에요!", + Map.of("type", "reengagement_monthly"))); + notifiedUserIds.add(user.getId()); + } + + if (!notifiedUserIds.isEmpty()) { + userRepository.bulkUpdateLastNotifiedAt(notifiedUserIds, today); + } + log.info("[ReviewDue] 장기 미접속 알림 발송: {}명", notifiedUserIds.size()); + } +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationScheduler.java new file mode 100644 index 00000000..4034ff9a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationScheduler.java @@ -0,0 +1,53 @@ +package com.aisip.OnO.backend.problem.quartz; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.stereotype.Component; + +import java.util.TimeZone; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReviewDueNotificationScheduler { + + private static final String JOB_NAME = "review-due-notification"; + private static final String JOB_GROUP = "review"; + private static final String TRIGGER_NAME = "review-due-trigger"; + private static final String CRON = "0 0 9 ? * *"; + + private final Scheduler scheduler; + + @PostConstruct + public void schedule() { + try { + JobDetail jobDetail = JobBuilder.newJob(ReviewDueNotificationJob.class) + .withIdentity(JOB_NAME, JOB_GROUP) + .storeDurably(true) + .build(); + + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_NAME, JOB_GROUP) + .withSchedule( + CronScheduleBuilder.cronSchedule(CRON) + .inTimeZone(TimeZone.getTimeZone("Asia/Seoul")) + ) + .forJob(jobDetail) + .build(); + + scheduler.addJob(jobDetail, true); + + if (scheduler.checkExists(trigger.getKey())) { + scheduler.rescheduleJob(trigger.getKey(), trigger); + } else { + scheduler.scheduleJob(trigger); + } + + log.info("[ReviewDue] 복습 알림 스케줄 등록 완료 - cron: {} (Asia/Seoul)", CRON); + } catch (SchedulerException e) { + log.error("[ReviewDue] 복습 알림 스케줄 등록 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java index a8cdd437..fdd8c3c9 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java @@ -2,8 +2,19 @@ import com.aisip.OnO.backend.problem.entity.Problem; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; public interface ProblemRepository extends JpaRepository, ProblemRepositoryCustom { Long countByUserId(Long userId); + + @Query("SELECT p FROM Problem p WHERE p.userId = :userId AND p.nextReviewAt <= :today ORDER BY p.nextReviewAt ASC") + List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); + + @Query("SELECT p.userId as userId, COUNT(p) as dueCount FROM Problem p WHERE p.nextReviewAt <= :today GROUP BY p.userId") + List findReviewDueSummaryByDate(@Param("today") LocalDate today); } diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java index 661b383f..63a09041 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java @@ -1,8 +1,14 @@ package com.aisip.OnO.backend.problem.repository; +import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.Optional; public interface ProblemRepositoryCustom { @@ -15,6 +21,16 @@ public interface ProblemRepositoryCustom { List findAll(); + Page findAdminProblems(Pageable pageable); + + Map countDailyProblems(LocalDate startDate, LocalDate endDate); + + long countProblemAnalysesForActiveProblems(); + + Map countProblemAnalysesByStatusForActiveProblems(); + + Map countProblemAnalysesByStatusForActiveProblems(LocalDate startDate, LocalDate endDate); + List findAllProblemsByPracticeId(Long practiceId); /** diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java index d814244a..e427f186 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java @@ -1,16 +1,33 @@ package com.aisip.OnO.backend.problem.repository; +import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.entity.QProblem; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.DateExpression; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import java.sql.Date; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import static com.aisip.OnO.backend.folder.entity.QFolder.folder; import static com.aisip.OnO.backend.practicenote.entity.QPracticeNote.practiceNote; import static com.aisip.OnO.backend.practicenote.entity.QProblemPracticeNoteMapping.problemPracticeNoteMapping; 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.problem.entity.QProblemImageData.problemImageData; import static com.aisip.OnO.backend.tag.entity.QProblemTagMapping.problemTagMapping; @@ -66,6 +83,105 @@ public List findAll() { .fetch(); } + @Override + public Page findAdminProblems(Pageable pageable) { + List content = queryFactory + .select(Projections.constructor( + AdminProblemResponseDto.class, + problem.id, + folder.id, + problem.memo, + problem.reference, + problemAnalysis.status.stringValue(), + problem.solvedAt, + problem.createdAt + )) + .from(problem) + .leftJoin(problem.folder, folder) + .leftJoin(problem.problemAnalysis, problemAnalysis) + .orderBy(problem.createdAt.desc(), problem.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(problem.count()) + .from(problem) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + @Override + public Map countDailyProblems(LocalDate startDate, LocalDate endDate) { + DateExpression createdDate = Expressions.dateTemplate( + Date.class, + "date({0})", + problem.createdAt + ); + + List rows = queryFactory + .select(createdDate, problem.count()) + .from(problem) + .where(problem.createdAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX))) + .groupBy(createdDate) + .fetch(); + + Map countsByDate = new LinkedHashMap<>(); + rows.forEach(row -> { + Date date = row.get(createdDate); + if (date != null) { + countsByDate.put(date.toLocalDate(), row.get(problem.count())); + } + }); + + Map result = new LinkedHashMap<>(); + for (LocalDate date = endDate; !date.isBefore(startDate); date = date.minusDays(1)) { + result.put(date, countsByDate.getOrDefault(date, 0L)); + } + + return result; + } + + @Override + public long countProblemAnalysesForActiveProblems() { + Long count = queryFactory + .select(problemAnalysis.count()) + .from(problemAnalysis) + .join(problemAnalysis.problem, problem) + .where(problem.deletedAt.isNull()) + .fetchOne(); + + return count != null ? count : 0L; + } + + @Override + public Map countProblemAnalysesByStatusForActiveProblems() { + return countProblemAnalysesByStatusForActiveProblems(null, null); + } + + @Override + public Map countProblemAnalysesByStatusForActiveProblems(LocalDate startDate, LocalDate endDate) { + var query = queryFactory + .select(problemAnalysis.status, problemAnalysis.count()) + .from(problemAnalysis) + .join(problemAnalysis.problem, problem) + .where(problem.deletedAt.isNull()); + + if (startDate != null && endDate != null) { + query.where(problemAnalysis.updatedAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX))); + } + + List rows = query + .groupBy(problemAnalysis.status) + .fetch(); + + Map result = new EnumMap<>(AnalysisStatus.class); + rows.forEach(row -> result.put(row.get(problemAnalysis.status), row.get(problemAnalysis.count()))); + + return result; + } + @Override public List findAllProblemsByPracticeId(Long practiceId) { return queryFactory diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueSummary.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueSummary.java new file mode 100644 index 00000000..2fa46d11 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueSummary.java @@ -0,0 +1,6 @@ +package com.aisip.OnO.backend.problem.repository; + +public interface ReviewDueSummary { + Long getUserId(); + Long getDueCount(); +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java index b0eaa288..2be67908 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java @@ -190,7 +190,7 @@ public void analyzeProblemSync(Long problemId) { @Deprecated @Async public void analyzeProblemAsync(Long problemId) { - log.info("Starting async analysis for problemId: {}, imageUrls: {}", problemId); + log.info("Starting async analysis for problemId: {}", problemId); try { // Problem 조회 @@ -252,7 +252,7 @@ public void analyzeProblemAsync(Long problemId) { */ public ProblemAnalysisResponseDto testAnalyzeImage(String imageUrl) { try { - log.info("Testing image analysis for URL: {}", imageUrl); + log.info("Testing image analysis"); // AI 분석 실행 ProblemAnalysisResult result = openAIClient.analyzeImage(imageUrl); 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 d7ed5ee9..3cc8af4a 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 @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.problem.service; +import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; @@ -20,9 +21,12 @@ import com.aisip.OnO.backend.folder.repository.FolderRepository; import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveSummary; import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; import com.aisip.OnO.backend.tag.entity.Tag; import com.aisip.OnO.backend.tag.exception.TagErrorCase; @@ -32,11 +36,18 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.ArrayList; import java.util.Collection; +import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -61,6 +72,8 @@ public class ProblemService { private final PracticeNoteRepository practiceNoteRepository; + private final ProblemSolveRepository problemSolveRepository; + private final S3DeleteProducer s3DeleteProducer; private final ProblemAnalysisProducer analysisProducer; @@ -72,7 +85,7 @@ public ProblemResponseDto findProblem(Long problemId) { Problem problem = problemRepository.findProblemWithImageData(problemId) .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); - return ProblemResponseDto.from(problem); + return toProblemResponseDto(problem); } @Transactional(readOnly = true) @@ -103,27 +116,62 @@ public Problem findProblemEntityWithImageData(Long problemId, Long userId) { public List findUserProblems(Long userId) { log.info("userId: {} find all user problems", userId); - return problemRepository.findAllByUserId(userId) - .stream() - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + return toProblemResponseDtos(problemRepository.findAllByUserId(userId)); } @Transactional(readOnly = true) public List findFolderProblemList(Long folderId) { - return problemRepository.findAllByFolderId(folderId) - .stream() - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + return toProblemResponseDtos(problemRepository.findAllByFolderId(folderId)); } @Transactional(readOnly = true) public List findAllProblems() { - return problemRepository.findAll() + List problems = problemRepository.findAll() .stream() .sorted((p1, p2) -> p2.getCreatedAt().compareTo(p1.getCreatedAt())) // 최신순 정렬 - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + .toList(); + + return toProblemResponseDtos(problems); + } + + @Transactional(readOnly = true) + public long countAllProblems() { + return problemRepository.count(); + } + + @Transactional(readOnly = true) + public long countAllProblemAnalyses() { + return problemRepository.countProblemAnalysesForActiveProblems(); + } + + @Transactional(readOnly = true) + public Map getDailyProblemsCount(LocalDate startDate, LocalDate endDate) { + return problemRepository.countDailyProblems(startDate, endDate); + } + + @Transactional(readOnly = true) + public Map countProblemAnalysesByStatus() { + return fillMissingAnalysisStatuses(problemRepository.countProblemAnalysesByStatusForActiveProblems()); + } + + @Transactional(readOnly = true) + public Map countProblemAnalysesByStatus(LocalDate startDate, LocalDate endDate) { + return fillMissingAnalysisStatuses(problemRepository.countProblemAnalysesByStatusForActiveProblems(startDate, endDate)); + } + + private Map fillMissingAnalysisStatuses(Map source) { + Map result = new EnumMap<>(AnalysisStatus.class); + + for (AnalysisStatus status : AnalysisStatus.values()) { + result.put(status, source.getOrDefault(status, 0L)); + } + + return result; + } + + @Transactional(readOnly = true) + public Page findAdminProblems(int page, int size) { + return problemRepository.findAdminProblems(PageRequest.of(page, size)); } @Transactional(readOnly = true) @@ -143,6 +191,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) problemRepository.save(problem); syncProblemTags(problem, userId, problemRegisterDto.tagIds()); + problem.updateReviewSchedule(LocalDate.now(java.time.ZoneId.of("Asia/Seoul")), 1, 0); analysisService.createSkippedAnalysis(problem.getId()); missionLogService.registerProblemWriteMission(userId); @@ -199,6 +248,7 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us problemImageDataRepository.save(imageData); }); } + problem.updateReviewSchedule(LocalDate.now(java.time.ZoneId.of("Asia/Seoul")), 1, 0); analysisService.createSkippedAnalysis(problem.getId()); missionLogService.registerProblemWriteMission(userId); @@ -243,7 +293,7 @@ public void uploadProblemImages(Long problemId, Long userId, List missionLogService.registerProblemPracticeMission(userId, problemId); } - log.info("Uploaded image to S3: {} for problemId: {}", imageUrl, problemId); + log.info("Uploaded image to S3 for problemId: {}, imageType: {}", problemId, imageType); } } @@ -440,8 +490,8 @@ public void deleteProblem(Long problemId) { try { s3DeleteProducer.sendDeleteMessage(imageData.getImageUrl(), problemId); } catch (Exception e) { - log.error("S3 삭제 메시지 전송 실패 - problemId: {}, imageUrl: {}, error: {}", - problemId, imageData.getImageUrl(), e.getMessage()); + log.error("S3 삭제 메시지 전송 실패 - problemId: {}, error: {}", + problemId, e.getMessage()); } }); @@ -499,9 +549,7 @@ public CursorPageResponse findProblemsByFolderWithCursor(Lon List content = hasNext ? problems.subList(0, size) : problems; Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; - List dtoList = content.stream() - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + List dtoList = toProblemResponseDtos(content); log.info("folderId: {} find problems with cursor: {}, size: {}, hasNext: {}", folderId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); @@ -530,9 +578,7 @@ public CursorPageResponse findProblemsByTagWithCursor(Long t List content = hasNext ? problems.subList(0, size) : problems; Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; - List dtoList = content.stream() - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + List dtoList = toProblemResponseDtos(content); log.info("userId: {} find problems by tagId: {} with cursor: {}, size: {}, hasNext: {}", userId, tagId, cursor, size, hasNext); @@ -558,12 +604,67 @@ public CursorPageResponse findProblemsByTitleWithCursor( List content = hasNext ? problems.subList(0, size) : problems; Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; - List dtoList = content.stream() - .map(ProblemResponseDto::from) - .collect(Collectors.toList()); + List dtoList = toProblemResponseDtos(content); log.info("userId: {} find problems by title query: '{}' with cursor: {}, size: {}, hasNext: {}", userId, query, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); } + + private ProblemResponseDto toProblemResponseDto(Problem problem) { + Long solveCount = problemSolveRepository.countByProblemId(problem.getId()); + return ProblemResponseDto.from( + problem, + solveCount != null ? solveCount : 0L, + problemSolveRepository.findLastSolvedAtByProblemId(problem.getId()) + ); + } + + private List toProblemResponseDtos(List problems) { + if (problems.isEmpty()) { + return List.of(); + } + + List problemIds = problems.stream() + .map(Problem::getId) + .toList(); + + Map solveSummariesByProblemId = problemSolveRepository.findSolveSummariesByProblemIds(problemIds) + .stream() + .collect(Collectors.toMap( + ProblemSolveSummary::getProblemId, + solveSummary -> solveSummary + )); + + return problems.stream() + .map(problem -> { + ProblemSolveSummary solveSummary = solveSummariesByProblemId.get(problem.getId()); + return ProblemResponseDto.from( + problem, + solveSummary != null ? solveSummary.getSolveCount() : 0L, + solveSummary != null ? solveSummary.getLastSolvedAt() : null + ); + }) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public ReviewDueResponseDto getReviewDueProblems(Long userId) { + LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + List dueProblems = problemRepository.findReviewDueProblems(userId, today); + + long overdueCount = dueProblems.stream() + .filter(p -> p.getNextReviewAt().isBefore(today)) + .count(); + + List problemDtos = dueProblems.stream() + .map(ReviewDueResponseDto.ReviewDueProblemDto::from) + .collect(Collectors.toList()); + + return ReviewDueResponseDto.builder() + .dueCount(dueProblems.size()) + .overdueCount(overdueCount) + .problems(problemDtos) + .build(); + } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ReviewIntervalCalculator.java b/src/main/java/com/aisip/OnO/backend/problem/service/ReviewIntervalCalculator.java new file mode 100644 index 00000000..e12766e0 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ReviewIntervalCalculator.java @@ -0,0 +1,35 @@ +package com.aisip.OnO.backend.problem.service; + +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; + +import java.time.LocalDate; +import java.time.ZoneId; + +public class ReviewIntervalCalculator { + + private static final int MAX_INTERVAL_DAYS = 30; + static final int MASTERY_THRESHOLD = 3; + + public record ReviewSchedule(LocalDate nextReviewAt, int reviewInterval, int consecutiveCorrectCount) { + public boolean isMastered() { + return nextReviewAt == null; + } + } + + public static ReviewSchedule calculate(AnswerStatus status, int currentInterval, int currentConsecutiveCorrect) { + return switch (status) { + case CORRECT -> { + int newConsecutive = currentConsecutiveCorrect + 1; + if (newConsecutive >= MASTERY_THRESHOLD) { + yield new ReviewSchedule(null, currentInterval, newConsecutive); + } + int newInterval = Math.min(currentInterval * 2, MAX_INTERVAL_DAYS); + yield new ReviewSchedule(LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(newInterval), newInterval, newConsecutive); + } + case WRONG, PARTIAL -> new ReviewSchedule(LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(1), 1, 0); + default -> new ReviewSchedule(LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(3), currentInterval, currentConsecutiveCorrect); + }; + } + + private ReviewIntervalCalculator() {} +} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java index 9446eff3..1f941ca3 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java @@ -8,10 +8,10 @@ @RequiredArgsConstructor public enum ProblemSolveErrorCase implements ErrorCase { - PROBLEM_SOLVE_NOT_FOUND(400, 4021, "복습 기록을 찾을 수 없습니다."), - PROBLEM_SOLVE_USER_UNMATCHED(400, 4022, "해당 복습 기록에 대한 권한이 없습니다."); + PROBLEM_SOLVE_NOT_FOUND(404, 4021, "복습 기록을 찾을 수 없습니다."), + PROBLEM_SOLVE_USER_UNMATCHED(403, 4022, "해당 복습 기록에 대한 권한이 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java index 1c929975..17cf086a 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -15,7 +17,7 @@ public interface ProblemSolveRepository extends JpaRepository findByIdWithImages(@Param("problemSolveId") Long problemSolveId); - @Query("SELECT pr FROM ProblemSolve pr " + + @Query("SELECT DISTINCT pr FROM ProblemSolve pr " + "LEFT JOIN FETCH pr.images " + "WHERE pr.problem.id = :problemId " + "ORDER BY pr.practicedAt DESC") @@ -39,5 +41,14 @@ public interface ProblemSolveRepository extends JpaRepository findSolveSummariesByProblemIds(@Param("problemIds") Collection problemIds); + void deleteAllByProblemId(Long problemId); -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveSummary.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveSummary.java new file mode 100644 index 00000000..0c831bd4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveSummary.java @@ -0,0 +1,11 @@ +package com.aisip.OnO.backend.problemsolve.repository; + +import java.time.LocalDateTime; + +public interface ProblemSolveSummary { + Long getProblemId(); + + Long getSolveCount(); + + LocalDateTime getLastSolvedAt(); +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index 4f681e70..ff9fd066 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.problem.service.ReviewIntervalCalculator; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveUpdateDto; @@ -67,6 +68,15 @@ public List getProblemSolvesByProblemId(Long problemId, .collect(Collectors.toList()); } + @Transactional(readOnly = true) + public List getAdminProblemSolvesByProblemId(Long problemId) { + List problemSolves = problemSolveRepository.findAllByProblemIdWithImages(problemId); + + return problemSolves.stream() + .map(ProblemSolveResponseDto::from) + .collect(Collectors.toList()); + } + @Transactional(readOnly = true) public List getUserProblemSolves(Long userId) { List problemSolves = problemSolveRepository.findAllByUserId(userId); @@ -108,7 +118,16 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { problemSolveRepository.save(problemSolve); missionLogService.registerProblemPracticeMission(userId, problem.getId()); - log.info("userId: {} created problem solve: {}", userId, problemSolve.getId()); + + ReviewIntervalCalculator.ReviewSchedule schedule = ReviewIntervalCalculator.calculate( + dto.answerStatus(), + problem.getReviewInterval(), + problem.getConsecutiveCorrectCount() + ); + problem.updateReviewSchedule(schedule.nextReviewAt(), schedule.reviewInterval(), schedule.consecutiveCorrectCount()); + + log.info("userId: {} created problem solve: {}, nextReviewAt: {}, mastered: {}", + userId, problemSolve.getId(), schedule.nextReviewAt(), schedule.isMastered()); return problemSolve.getId(); } @@ -130,7 +149,7 @@ public void uploadProblemSolveImages(Long problemSolveId, Long userId, List tagRepository.save(Tag.from(userId, tagName, normalizedName))); + evictTagCache(userId); log.info("userId: {} create tag: {}", userId, tag.getName()); return TagResponseDto.from(tag); } @Transactional(readOnly = true) public List getUserTags(Long userId) { - return tagRepository.findAllByUserIdOrderByNameAsc(userId) + String cacheKey = TAG_LIST_CACHE_PREFIX + userId; + + List cached = readTagCache(cacheKey); + if (cached != null) { + return cached; + } + + List tags = tagRepository.findAllByUserIdOrderByNameAsc(userId) .stream() .map(TagResponseDto::from) .toList(); + + writeTagCache(cacheKey, tags); + return tags; } public void deleteTag(Long userId, Long tagId) { @@ -80,6 +99,7 @@ public void deleteTags(Long userId, TagDeleteRequestDto requestDto) { } tagRepository.deleteAll(tags); + evictTagCache(userId); log.info("userId: {} deleted tags count: {}", userId, tagIds.size()); } @@ -123,6 +143,32 @@ private Set toDistinctIds(List ids) { .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); } + private List readTagCache(String key) { + try { + String json = redisSingleDataService.getSingleData(key); + if (json == null || json.isBlank()) { + return null; + } + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to read tag list cache. key={}, reason={}", key, e.getMessage()); + return null; + } + } + + private void writeTagCache(String key, List tags) { + try { + String json = objectMapper.writeValueAsString(tags); + redisSingleDataService.setSingleData(key, json, TAG_CACHE_TTL); + } catch (Exception e) { + log.warn("Failed to write tag list cache. key={}, reason={}", key, e.getMessage()); + } + } + + private void evictTagCache(Long userId) { + redisSingleDataService.deleteSingleData(TAG_LIST_CACHE_PREFIX + userId); + } + private String normalizeDisplayName(String rawTagName) { String tagName = rawTagName == null ? "" : rawTagName.trim(); if (tagName.startsWith("#")) { diff --git a/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java b/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java index d7c28656..316a80f5 100644 --- a/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java +++ b/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.user.dto.NotificationSettingsUpdateDto; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.service.UserService; @@ -22,6 +23,7 @@ public class UserController { public CommonResponse getUserInfo() { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); missionLogService.registerLoginMission(userId); + userService.touchLastActiveAt(userId); return CommonResponse.success(userService.findUser(userId)); } @@ -35,6 +37,16 @@ public CommonResponse updateUserInfo(@RequestBody UserRegisterDto userRe return CommonResponse.success("사용자 정보 수정이 완료되었습니다."); } + // ✅ 알림 수신 설정 + @PatchMapping("/notification-settings") + public CommonResponse updateNotificationSettings( + @RequestBody NotificationSettingsUpdateDto dto) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + userService.updateNotificationSettings(userId, dto.notificationEnabled()); + + return CommonResponse.success("알림 설정이 변경되었습니다."); + } + // ✅ 사용자 계정 삭제 @DeleteMapping("") public void deleteUserInfo() { diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/NotificationSettingsUpdateDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/NotificationSettingsUpdateDto.java new file mode 100644 index 00000000..b4997930 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/user/dto/NotificationSettingsUpdateDto.java @@ -0,0 +1,3 @@ +package com.aisip.OnO.backend.user.dto; + +public record NotificationSettingsUpdateDto(boolean notificationEnabled) {} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index c77a2928..e6178b00 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -26,33 +26,66 @@ public record UserResponseDto ( LocalDateTime createdAt, LocalDateTime updatedAt ) { + private static final Long MAX_LEVEL = 15L; + public static UserResponseDto from(@NotNull User user) { + var missionStatus = user.getUserMissionStatus(); + return UserResponseDto.builder() .userId(user.getId()) .name(user.getName()) .email(user.getEmail()) - .attendanceLevel(user.getUserMissionStatus().getAttendanceLevel()) - .attendancePoint(user.getUserMissionStatus().getAttendancePoint()) - .noteWriteLevel(user.getUserMissionStatus().getNoteWriteLevel()) - .noteWritePoint(user.getUserMissionStatus().getNoteWritePoint()) - .problemPracticeLevel(user.getUserMissionStatus().getProblemPracticeLevel()) - .problemPracticePoint(user.getUserMissionStatus().getProblemPracticePoint()) - .notePracticeLevel(user.getUserMissionStatus().getNotePracticeLevel()) - .notePracticePoint(user.getUserMissionStatus().getNotePracticePoint()) + .attendanceLevel(getResponseLevel(missionStatus.getAttendanceLevel())) + .attendancePoint(getResponsePoint(missionStatus.getAttendanceLevel(), missionStatus.getAttendancePoint())) + .noteWriteLevel(getResponseLevel(missionStatus.getNoteWriteLevel())) + .noteWritePoint(getResponsePoint(missionStatus.getNoteWriteLevel(), missionStatus.getNoteWritePoint())) + .problemPracticeLevel(getResponseLevel(missionStatus.getProblemPracticeLevel())) + .problemPracticePoint(getResponsePoint(missionStatus.getProblemPracticeLevel(), missionStatus.getProblemPracticePoint())) + .notePracticeLevel(getResponseLevel(missionStatus.getNotePracticeLevel())) + .notePracticePoint(getResponsePoint(missionStatus.getNotePracticeLevel(), missionStatus.getNotePracticePoint())) // DB에 저장된 총 학습 레벨 정보 사용 (계산 불필요) - .totalStudyLevel(user.getUserMissionStatus().getTotalStudyLevel()) - .totalStudyCurrentPoint(user.getUserMissionStatus().getTotalStudyPoint()) - .totalStudyNextLevelThreshold(getTotalStudyNextLevelThreshold(user.getUserMissionStatus())) + .totalStudyLevel(getResponseLevel(missionStatus.getTotalStudyLevel())) + .totalStudyCurrentPoint(getTotalStudyResponsePoint(missionStatus.getTotalStudyLevel(), missionStatus.getTotalStudyPoint())) + .totalStudyNextLevelThreshold(getTotalStudyNextLevelThreshold(missionStatus)) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); } + private static Long getResponseLevel(Long level) { + if (level > MAX_LEVEL) { + return MAX_LEVEL; + } + return level; + } + + private static Long getResponsePoint(Long level, Long point) { + if (level > MAX_LEVEL) { + return getThresholdForLevel(MAX_LEVEL); + } + return point; + } + + private static Long getTotalStudyResponsePoint(Long level, Long point) { + if (level > MAX_LEVEL) { + return getTotalStudyThresholdForLevel(MAX_LEVEL); + } + return point; + } + private static Long getTotalStudyNextLevelThreshold(com.aisip.OnO.backend.mission.entity.UserMissionStatus status) { - if (status.getTotalStudyLevel() >= 15) { + if (status.getTotalStudyLevel() >= MAX_LEVEL) { return 0L; } // 개별 능력치 필요 경험치 × 4 - return (10 + (status.getTotalStudyLevel() - 1) * 10) * 4; + return getTotalStudyThresholdForLevel(status.getTotalStudyLevel()); + } + + private static Long getThresholdForLevel(Long level) { + return 10 + (level - 1) * 10; + } + + private static Long getTotalStudyThresholdForLevel(Long level) { + return getThresholdForLevel(level) * 4; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/user/entity/User.java b/src/main/java/com/aisip/OnO/backend/user/entity/User.java index 75e5520a..8a60d524 100644 --- a/src/main/java/com/aisip/OnO/backend/user/entity/User.java +++ b/src/main/java/com/aisip/OnO/backend/user/entity/User.java @@ -10,6 +10,9 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -40,6 +43,14 @@ public class User extends BaseEntity { private String platform; + private LocalDateTime lastActiveAt; + + private LocalDate lastNotifiedAt; + + @Column(nullable = false) + @Builder.Default + private boolean notificationEnabled = true; + @Embedded private UserMissionStatus userMissionStatus; @@ -63,6 +74,23 @@ public static User from(UserRegisterDto userRegisterDto) { .build(); } + public void touchLastActiveAt() { + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + // 오늘 이미 갱신한 경우 DB write 생략 + if (this.lastActiveAt != null && !this.lastActiveAt.toLocalDate().isBefore(today)) { + return; + } + this.lastActiveAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + } + + public void updateLastNotifiedAt(LocalDate date) { + this.lastNotifiedAt = date; + } + + public void updateNotificationEnabled(boolean enabled) { + this.notificationEnabled = enabled; + } + public void updateUser(UserRegisterDto userRegisterDto) { if (userRegisterDto.email() != null && !userRegisterDto.email().isBlank()) { this.email = userRegisterDto.email(); diff --git a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java index a0909502..7e11eba3 100644 --- a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java @@ -8,7 +8,7 @@ @RequiredArgsConstructor public enum UserErrorCase implements ErrorCase { - USER_NOT_FOUND(400, 3001, "사용자를 찾을 수 없습니다."); + USER_NOT_FOUND(404, 3001, "사용자를 찾을 수 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/user/repository/UserAdminRow.java b/src/main/java/com/aisip/OnO/backend/user/repository/UserAdminRow.java new file mode 100644 index 00000000..52ff0e42 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/user/repository/UserAdminRow.java @@ -0,0 +1,9 @@ +package com.aisip.OnO.backend.user.repository; + +import com.aisip.OnO.backend.user.entity.User; + +public record UserAdminRow( + User user, + Long problemCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java index e3476947..8ac86f0f 100644 --- a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java +++ b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java @@ -4,10 +4,18 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findByEmail(String email); Optional findByIdentifier(String identifier); @@ -15,4 +23,46 @@ public interface UserRepository extends JpaRepository { Optional findByName(String name); Page findAll(Pageable pageable); + + List findAllByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDateTime, LocalDateTime endDateTime); + + @Query(""" + SELECT FUNCTION('DATE', u.createdAt), COUNT(u) + FROM User u + WHERE u.createdAt BETWEEN :startDateTime AND :endDateTime + GROUP BY FUNCTION('DATE', u.createdAt) + """) + List countDailyNewUsers( + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime + ); + + @Transactional + @Modifying + @Query("UPDATE User u SET u.lastNotifiedAt = :date WHERE u.id IN :userIds") + void bulkUpdateLastNotifiedAt(@Param("userIds") List userIds, @Param("date") LocalDate date); + + // 7일 ~ 30일 미접속 유저 (5일 간격) + @Query(""" + SELECT u FROM User u + WHERE u.lastActiveAt < :reengagementCutoff + AND u.lastActiveAt >= :inactiveCutoff + AND (u.lastNotifiedAt IS NULL OR u.lastNotifiedAt < :notificationCutoff) + """) + List findUsersForReengagement( + @Param("reengagementCutoff") LocalDateTime reengagementCutoff, + @Param("inactiveCutoff") LocalDateTime inactiveCutoff, + @Param("notificationCutoff") LocalDate notificationCutoff + ); + + // 30일 초과 미접속 유저 (30일 간격) + @Query(""" + SELECT u FROM User u + WHERE u.lastActiveAt < :inactiveCutoff + AND (u.lastNotifiedAt IS NULL OR u.lastNotifiedAt < :notificationCutoff) + """) + List findUsersForLongInactiveReengagement( + @Param("inactiveCutoff") LocalDateTime inactiveCutoff, + @Param("notificationCutoff") LocalDate notificationCutoff + ); } diff --git a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryCustom.java new file mode 100644 index 00000000..01598b84 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.user.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface UserRepositoryCustom { + Page findAdminUsers(Pageable pageable, String sortBy, String direction); +} diff --git a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryImpl.java new file mode 100644 index 00000000..dc2cef3a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.user.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static com.aisip.OnO.backend.problem.entity.QProblem.problem; +import static com.aisip.OnO.backend.user.entity.QUser.user; + +public class UserRepositoryImpl implements UserRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public UserRepositoryImpl(EntityManager entityManager) { + this.queryFactory = new JPAQueryFactory(entityManager); + } + + @Override + public Page findAdminUsers(Pageable pageable, String sortBy, String direction) { + NumberExpression problemCount = problem.id.count(); + + List rows = queryFactory + .select(user, problemCount) + .from(user) + .leftJoin(problem).on(problem.userId.eq(user.id)) + .groupBy(user.id) + .orderBy(getOrderSpecifier(sortBy, direction, problemCount), user.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + List content = rows.stream() + .map(row -> new UserAdminRow(row.get(user), row.get(problemCount))) + .toList(); + + Long total = queryFactory + .select(user.count()) + .from(user) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + private OrderSpecifier getOrderSpecifier( + String sortBy, + String direction, + NumberExpression problemCount + ) { + Order order = "asc".equalsIgnoreCase(direction) ? Order.ASC : Order.DESC; + + return switch (sortBy) { + case "level" -> new OrderSpecifier<>(order, user.userMissionStatus.totalStudyLevel); + case "problemCount" -> new OrderSpecifier<>(order, problemCount); + default -> new OrderSpecifier<>(order, user.createdAt); + }; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 4164bdd0..7789ccd1 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.user.service; +import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; import com.aisip.OnO.backend.problem.service.ProblemService; @@ -12,8 +13,13 @@ import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.sql.Date; +import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -22,7 +28,6 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; -import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -103,6 +108,28 @@ public List findAllUsers() { .collect(Collectors.toList()); } + @Transactional(readOnly = true) + public Page findAdminUsers(int page, int size, String sortBy, String direction) { + PageRequest pageRequest = PageRequest.of(page, size); + return userRepository.findAdminUsers(pageRequest, sortBy, direction) + .map(row -> AdminUserResponseDto.from(row.user(), row.problemCount())); + } + + @Transactional(readOnly = true) + public long countAllUsers() { + return userRepository.count(); + } + + @Transactional + public void touchLastActiveAt(Long userId) { + findUserEntity(userId).touchLastActiveAt(); + } + + @Transactional + public void updateNotificationSettings(Long userId, boolean notificationEnabled) { + findUserEntity(userId).updateNotificationEnabled(notificationEnabled); + } + @Transactional public void updateUser(Long userId, UserRegisterDto userRegisterDto) { @@ -156,29 +183,22 @@ public void updateUserLevel(Long userId, String levelType, Long levelValue, Long @Transactional(readOnly = true) public Map getDailyNewUsersCount(int days) { - Map result = new LinkedHashMap<>(); LocalDate today = LocalDate.now(); - List allUsers = userRepository.findAll(); - - // 최근 날짜가 위로 오도록 역순으로 조회 - for (int i = 0; i < days; i++) { - LocalDate date = today.minusDays(i); - LocalDateTime startOfDay = date.atStartOfDay(); - LocalDateTime endOfDay = date.atTime(LocalTime.MAX); - - long count = allUsers.stream() - .filter(user -> { - LocalDateTime createdAt = user.getCreatedAt(); - return createdAt != null && - !createdAt.isBefore(startOfDay) && - !createdAt.isAfter(endOfDay); - }) - .count(); - - result.put(date, count); + return getDailyNewUsersCount(today.minusDays(days - 1L), today); + } + + @Transactional(readOnly = true) + public Map getDailyNewUsersCount(LocalDate startDate, LocalDate endDate) { + Map result = new LinkedHashMap<>(); + userRepository.countDailyNewUsers(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX)) + .forEach(row -> result.put(toLocalDate(row[0]), (Long) row[1])); + + Map orderedResult = new LinkedHashMap<>(); + for (LocalDate date = endDate; !date.isBefore(startDate); date = date.minusDays(1)) { + orderedResult.put(date, result.getOrDefault(date, 0L)); } - return result; + return orderedResult; } @Transactional(readOnly = true) @@ -186,15 +206,24 @@ public List getUsersByDate(LocalDate date) { LocalDateTime startOfDay = date.atStartOfDay(); LocalDateTime endOfDay = date.atTime(LocalTime.MAX); - return userRepository.findAll().stream() - .filter(user -> { - LocalDateTime createdAt = user.getCreatedAt(); - return createdAt != null && - !createdAt.isBefore(startOfDay) && - !createdAt.isAfter(endOfDay); - }) - .sorted((u1, u2) -> u2.getCreatedAt().compareTo(u1.getCreatedAt())) + return userRepository.findAllByCreatedAtBetweenOrderByCreatedAtDesc(startOfDay, endOfDay).stream() .map(UserResponseDto::from) .collect(Collectors.toList()); } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } } 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 d485bcbc..1b07fe53 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 @@ -88,7 +88,7 @@ public ProblemAnalysisResult analyzeImages(List imageUrls) { .getMessage() .getContent(); - log.info("Received response from OpenAI: {}", content); + log.debug("Received response from OpenAI - contentLength: {}", content == null ? 0 : content.length()); // 6. JSON 응답 파싱 recordExternalCall("openai", "analyze_images", "success", sample); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java b/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java index ba98c37c..dcfe5d4e 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java @@ -25,7 +25,8 @@ public class FcmController { @PostMapping("/token") public CommonResponse registerFcmToken(@RequestBody FcmTokenRequestDto fcmTokenRequestDto) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - log.info("fcm token: {}", fcmTokenRequestDto.token()); + log.info("FCM token registration requested - userId: {}, tokenLength: {}", + userId, fcmTokenRequestDto.token() == null ? 0 : fcmTokenRequestDto.token().length()); fcmService.registerToken(fcmTokenRequestDto, userId); return CommonResponse.success("문제가 등록되었습니다."); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/exception/FcmErrorCase.java b/src/main/java/com/aisip/OnO/backend/util/fcm/exception/FcmErrorCase.java index b000114c..e38ec9b6 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/exception/FcmErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/exception/FcmErrorCase.java @@ -8,9 +8,9 @@ @RequiredArgsConstructor public enum FcmErrorCase implements ErrorCase { - FCM_TOKEN_NOT_FOUND(400, 7001, "Fcm Token을 찾을 수 없습니다."), + FCM_TOKEN_NOT_FOUND(404, 8001, "Fcm Token을 찾을 수 없습니다."), - FCM_SEND_FAILED(400, 7002, "Fcm 메시지 전송에 실패했습니다."); + FCM_SEND_FAILED(502, 8002, "Fcm 메시지 전송에 실패했습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java index d584cca8..31e7007e 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -15,4 +16,7 @@ public interface FcmTokenRepository extends JpaRepository { Optional findByToken(String token); List findAllByUserId(Long userId); + + @Transactional + void deleteByToken(String token); } 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 1d0f2901..7220f1ea 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 @@ -122,7 +122,7 @@ public void sendNotificationToAllUserDeviceSync(Long userId, NotificationRequest notificationRequestDto.data() )); } catch (ApplicationException e) { - log.warn("FCM 전송 실패 (userId: {}, token: {}): {}", userId, fcmToken.getToken(), e.getMessage()); + log.warn("FCM 전송 실패 (userId: {}): {}", userId, e.getMessage()); } }); } 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 ce766964..2f944213 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 @@ -45,7 +45,7 @@ public String uploadFileToS3(MultipartFile file) { } recordExternalCall("s3", "upload", "success", sample); - log.info("file url : " + fileUrl + " has upload to S3"); + log.info("S3 upload completed - fileSize: {}, contentType: {}", file.getSize(), file.getContentType()); return fileUrl; } @@ -54,7 +54,7 @@ public void deleteImageFileFromS3(String imageUrl) { String splitStr = ".com/"; String fileName = imageUrl.substring(imageUrl.lastIndexOf(splitStr) + splitStr.length()); - log.info("file url : " + imageUrl + " has removed from S3"); + log.info("S3 delete requested"); try { amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileName)); diff --git a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java index 0fc51932..e74eb14a 100644 --- a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java +++ b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java @@ -9,8 +9,11 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @Service @@ -18,6 +21,8 @@ public class DiscordWebhookNotificationService { private final RestTemplate restTemplate = new RestTemplate(); + private final Map lastSentAtByDedupKey = new ConcurrentHashMap<>(); + private static final Duration ERROR_NOTIFICATION_DEDUP_WINDOW = Duration.ofMinutes(5); @Value("${discord.webhook-url}") private String webhookUrl; @@ -27,6 +32,12 @@ public class DiscordWebhookNotificationService { */ public void sendErrorNotification(String path, String errorMessage, String status, String exceptionType) { String timestamp = Instant.now().toString(); + String dedupKey = createDedupKey(path, status, exceptionType); + + if (shouldSuppress(dedupKey)) { + log.debug("Discord error notification suppressed - key: {}", dedupKey); + return; + } DiscordWebhookPayload.Embed embed = new DiscordWebhookPayload.Embed( "🚨 서버 에러 발생", @@ -40,7 +51,7 @@ public void sendErrorNotification(String path, String errorMessage, String statu ) ); - sendToDiscord(new DiscordWebhookPayload(null, List.of(embed))); + sendToDiscord(new DiscordWebhookPayload(null, List.of(embed)), dedupKey); } /** @@ -64,6 +75,12 @@ public void sendMessage(String title, String message) { */ public void sendCustomEmbed(String title, String description, List fields) { String timestamp = Instant.now().toString(); + String dedupKey = createDedupKey(title, description); + + if (shouldSuppress(dedupKey)) { + log.debug("Discord custom notification suppressed - key: {}", dedupKey); + return; + } DiscordWebhookPayload.Embed embed = new DiscordWebhookPayload.Embed( title, @@ -72,13 +89,17 @@ public void sendCustomEmbed(String title, String description, List(payload, headers), String.class ); + if (dedupKey != null) { + lastSentAtByDedupKey.put(dedupKey, Instant.now()); + } log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); } catch (Exception e) { - log.error("Discord webhook 전송 실패: {}", e.getMessage()); + log.error("Discord webhook 전송 실패: {}", e.getMessage(), e); + } + } + + private boolean shouldSuppress(String dedupKey) { + Instant lastSentAt = lastSentAtByDedupKey.get(dedupKey); + return lastSentAt != null + && Instant.now().isBefore(lastSentAt.plus(ERROR_NOTIFICATION_DEDUP_WINDOW)); + } + + private String createDedupKey(String path, String status, String exceptionType) { + return String.join("|", + normalize(path), + normalize(status), + normalize(exceptionType) + ); + } + + private String createDedupKey(String title, String description) { + return String.join("|", + normalize(title), + normalize(description) + ); + } + + private String normalize(String value) { + if (value == null || value.isBlank()) { + return "unknown"; } + return value.replaceAll("\\s+", " ").trim(); } -} \ No newline at end of file +} diff --git a/src/main/resources/db/migration/README.md b/src/main/resources/db/migration/README.md new file mode 100644 index 00000000..7a196f0f --- /dev/null +++ b/src/main/resources/db/migration/README.md @@ -0,0 +1,11 @@ +# Flyway migrations + +Flyway is enabled for `local`, `dev`, and `prod`. + +The current production-compatible schema is treated as version `1`: + +- Existing non-empty databases are baselined automatically through `baseline-on-migrate`. +- `V1__baseline_existing_schema.sql` is intentionally a no-op marker. +- New schema changes must start at `V2__*.sql`. + +The older `.sql` files in this directory do not follow Flyway naming and are kept as historical/manual scripts. Do not rename them into `V*__*.sql` unless they are reviewed as production-safe, idempotent migrations. diff --git a/src/main/resources/db/migration/V1__baseline_existing_schema.sql b/src/main/resources/db/migration/V1__baseline_existing_schema.sql new file mode 100644 index 00000000..d1df6036 --- /dev/null +++ b/src/main/resources/db/migration/V1__baseline_existing_schema.sql @@ -0,0 +1,3 @@ +-- Flyway adoption baseline. +-- Existing dev/prod schemas are recorded at version 1 by baseline-on-migrate. +-- Add new schema changes as V2__*.sql and later migrations. diff --git a/src/main/resources/db/migration/V2__add_review_schedule_fields.sql b/src/main/resources/db/migration/V2__add_review_schedule_fields.sql new file mode 100644 index 00000000..3867ed33 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_review_schedule_fields.sql @@ -0,0 +1,12 @@ +-- Problem 테이블: 복습 스케줄 필드 추가 +ALTER TABLE problem + ADD COLUMN next_review_at DATE NULL, + ADD COLUMN review_interval INT NOT NULL DEFAULT 1, + ADD COLUMN consecutive_correct_count INT NOT NULL DEFAULT 0; + +-- User 테이블: 마지막 앱 접속 시각 추가 (알림 대상 필터링용) +ALTER TABLE user + ADD COLUMN last_active_at DATETIME NULL; + +-- 복습 대상 조회 성능을 위한 인덱스 +CREATE INDEX idx_problem_review_due ON problem (user_id, next_review_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__add_notification_fields_and_backfill.sql b/src/main/resources/db/migration/V3__add_notification_fields_and_backfill.sql new file mode 100644 index 00000000..78d2da70 --- /dev/null +++ b/src/main/resources/db/migration/V3__add_notification_fields_and_backfill.sql @@ -0,0 +1,9 @@ +-- User: 알림 스팸 방지용 마지막 알림 발송일 +ALTER TABLE user + ADD COLUMN last_notified_at DATE NULL; + +-- 기존 유저 last_active_at 백필 (updated_at 기준) +UPDATE user SET last_active_at = updated_at WHERE last_active_at IS NULL AND deleted_at IS NULL; + +-- 스케줄러 전체 날짜 범위 스캔용 인덱스 (next_review_at 선두) +CREATE INDEX idx_problem_next_review_date ON problem (next_review_at, user_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__add_notification_enabled.sql b/src/main/resources/db/migration/V4__add_notification_enabled.sql new file mode 100644 index 00000000..6a2eeab4 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_notification_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE user + ADD COLUMN notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..c2b5764c --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,75 @@ + + + + + + + + + + ${CONSOLE_PATTERN} + UTF-8 + + + + + + {"service":"${appName}","environment":"${activeProfile}"} + + timestamp + [ignore] + level + logger + thread + message + stacktrace + + traceId + userId + method + uri + clientIp + status + latencyMs + errorCode + exceptionType + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties index ab5454bf..64f9318f 100644 --- a/src/main/resources/spy.properties +++ b/src/main/resources/spy.properties @@ -7,6 +7,6 @@ excludecategories=info,debug,result,resultset,batch # Quartz 관련 테이블 제외 (스케줄러 쿼리) exclude=QRTZ_TRIGGERS,QRTZ_FIRED_TRIGGERS,QRTZ_JOB_DETAILS,QRTZ_CRON_TRIGGERS,QRTZ_SIMPLE_TRIGGERS,QRTZ_LOCKS,QRTZ_SCHEDULER_STATE -# 실행 시간 필터링 (0으로 설정하면 모든 쿼리 출력) -# 0 = 모든 쿼리, 1 = 1ms 이상, 100 = 100ms 이상 -executionThreshold=0 +# 실행 시간 필터링 +# 300 = 300ms 이상 걸린 쿼리만 출력 +executionThreshold=300 diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index c5ad9724..d9381100 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -18,6 +18,9 @@

OnO 관리자

대시보드 + + 복습 +
+ +
+
@@ -86,10 +130,62 @@

시스템 통계

+
+
+
+

선택 기간 문제 등록 수

+

0

+
+
+ + + +
+
+
+

선택 기간 작성된 문제

+
+
+ +
+
+
+

선택 기간 분석 실패율

+

0.0%

+
+
+ + + +
+
+
+

완료/실패 처리된 분석 기준

+
+
+ +
+
+
+

선택 기간 고유 방문 유저

+

0

+
+
+ + + +
+
+
+

선택 기간 로그인한 서로 다른 유저

+
+
+
-

하루 평균 방문자 수

+

선택 기간 평균 DAU

0.0

@@ -100,15 +196,15 @@

시스템 통계

-

최근 30일 기준

+

날짜별 출석 유저 수 평균

- +
-

최근 30일 가입자 수

+

선택 기간 가입자 수

0

@@ -118,25 +214,42 @@

시스템 통계

-

지난 30일간 신규 가입

+

선택 기간 신규 가입

+
+
+
+

총 복습노트 수

+

0

+
+
+ + + +
+
+ +
+
-

시스템 상태

-

정상

+

총 복습 기록 수

+

0

- +
-

모든 시스템 작동 중

+

복습노트 사용 미션 기준

@@ -147,7 +260,7 @@

시스템 통계

상세 통계

-
+

@@ -161,6 +274,19 @@

총 유저 수 0

+
+ 선택 기간 방문 횟수 + 0 +
+
+ 선택 기간 고유 방문 유저 + 0 +
+
+ 선택 기간 평균 DAU + 0.0 +
@@ -177,6 +303,87 @@

총 문제 수 0

+
+ 선택 기간 문제 등록 + 0 +
+
+ 총 분석 데이터 수 + 0 +
+
+ 전체 분석 완료 + 0 +
+
+ 전체 분석 실패 + 0 +
+
+ 전체 분석 중 + 0 +
+
+ 전체 분석 대기 + 0 +
+
+ 전체 이미지 없음 + 0 +
+
+ 선택 기간 분석 완료 + 0 +
+
+ 선택 기간 분석 실패 + 0 +
+
+ 선택 기간 분석 실패율 + 0.0% +
+
+ 선택 기간 분석 중 + 0 +
+
+ 선택 기간 분석 대기 + 0 +
+
+ 선택 기간 이미지 없음 + 0 +
+ + + + +
+

+ + + + 복습 통계 +

+
+
+ 총 복습노트 수 + 0 +
+
+ 총 복습 기록 수 + 0 +
+
+ 선택 기간 복습노트 생성 + 0 +
+
+ 선택 기간 복습 수행 + 0 +
@@ -186,7 +393,7 @@

-

날짜별 출석 유저 수 (최근 30일)

+

날짜별 출석 유저 수

@@ -194,13 +401,22 @@

날짜별 출석 유저 수 (최 날짜 + 방문 횟수 출석 유저 수 신규 가입자 수 + 문제 등록 수 + 복습노트 생성 수 + 복습 수행 수 2024-01-01 + + 0 + + 날짜별 출석 유저 수 (최 + + 0 + + + + 0 + + + + 0 + + - + 데이터가 없습니다 + + + 합계 + + 0 + + + + 0 + + + + 0 + + + + 0 + + + + 0 + + + + 0 + + + +

@@ -240,7 +500,7 @@

날짜별 출석 유저 수 (최 - \ No newline at end of file + diff --git a/src/main/resources/templates/practice-logs.html b/src/main/resources/templates/practice-logs.html new file mode 100644 index 00000000..17b049cd --- /dev/null +++ b/src/main/resources/templates/practice-logs.html @@ -0,0 +1,119 @@ + + + + + + OnO 관리자 - 복습 기록 + + + + + +
+
+
+

복습 기록

+

+ 총 0개 +

+
+ + 복습노트 목록 + +
+ +
+
+

복습 기록 목록

+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
기록 ID유저복습노트포인트완료일
1 + 유저 +

user@example.com

+
+ 복습노트 + #1 + 15pt2024-01-01 00:00
복습 기록이 없습니다
+
+ +
+ 이전 + 이전 + + << + << + + + 1 + 1 + + + >> + >> + + 다음 + 다음 +
+
+
+ + diff --git a/src/main/resources/templates/practice-notes.html b/src/main/resources/templates/practice-notes.html new file mode 100644 index 00000000..cc3e8a59 --- /dev/null +++ b/src/main/resources/templates/practice-notes.html @@ -0,0 +1,130 @@ + + + + + + OnO 관리자 - 복습 관리 + + + + + +
+
+

복습 관리

+

+ 복습노트 0개 +

+
+ + + +
+
+

복습노트 목록

+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
복습노트 ID유저제목문제 수복습 횟수마지막 복습일작성일
1 + 유저 +

user@example.com

+
복습노트00 + 2024-01-01 00:00 + - + 2024-01-01 00:00
복습노트가 없습니다
+
+ +
+ 이전 + 이전 + + << + << + + + 1 + 1 + + + >> + >> + + 다음 + 다음 +
+
+ +
+ + diff --git a/src/main/resources/templates/problem.html b/src/main/resources/templates/problem.html index 1eaa1dc4..40e0bc27 100644 --- a/src/main/resources/templates/problem.html +++ b/src/main/resources/templates/problem.html @@ -26,6 +26,9 @@

OnO 관리자

문제 + + 복습 + 통계 @@ -218,6 +221,57 @@

AI 분석 결과

+ +
+
+

문제 복습 기록

+ + 총 0개 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
기록 ID유저 ID복습일정답 상태소요 시간회고이미지
1 + 1 + 2024-01-01CORRECT + 60초 + - + + 회고 + - + + 0장 +
+
+
+ 이 문제에 작성된 복습 기록이 없습니다 +
+
+
+

- \ No newline at end of file + diff --git a/src/main/resources/templates/problems.html b/src/main/resources/templates/problems.html index 4ff00d15..6c0acd1f 100644 --- a/src/main/resources/templates/problems.html +++ b/src/main/resources/templates/problems.html @@ -26,6 +26,9 @@

OnO 관리자

문제 + + 복습 + 통계 @@ -57,7 +60,6 @@

문제 관리

폴더 ID 메모 출처 - 이미지 분석 푼 날짜 작성일 @@ -77,37 +79,27 @@

문제 관리

- -
- - - 문제 이미지 - - - 이미지 없음 -
- - - 분석 완료 - 분석중 - 분석 대기 - 분석 실패 - + 이미지 없음 + + 미등록 @@ -119,7 +111,7 @@

문제 관리

2024-01-01 00:00 - + 문제가 없습니다 @@ -131,12 +123,12 @@

문제 관리

- 1 - 부터 20 + 1 + 부터 20 까지 (총 100개)
-
+ - \ No newline at end of file + diff --git a/src/main/resources/templates/user.html b/src/main/resources/templates/user.html index 064f835a..e99f0fed 100644 --- a/src/main/resources/templates/user.html +++ b/src/main/resources/templates/user.html @@ -26,6 +26,9 @@

OnO 관리자

문제 + + 복습 + 통계 @@ -454,4 +457,4 @@

레벨 수정 } - \ No newline at end of file + diff --git a/src/main/resources/templates/users.html b/src/main/resources/templates/users.html index 3ba8ea6d..23d63e41 100644 --- a/src/main/resources/templates/users.html +++ b/src/main/resources/templates/users.html @@ -26,6 +26,9 @@

OnO 관리자

문제 + + 복습 + 통계 @@ -47,6 +50,38 @@

유저 관리

0명의 유저

+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
@@ -56,13 +91,25 @@

유저 관리

유저 ID 이름 이메일 - 총 학습 레벨 - 문제 수 + + + 총 학습 레벨 + 내림차순 + + + + + 문제 수 + 내림차순 + + 가입일 - 1 유저 이름 @@ -73,7 +120,7 @@

유저 관리

(0pt)
- 0 + 0 2024-01-01 00:00 @@ -89,15 +136,15 @@

유저 관리

- 1 - 부터 20 + 1 + 부터 20 까지 (총 100개)
-
+
이전 @@ -106,24 +153,46 @@

유저 관리

이전 + + + << + + + << + + -
- + + + + >> + + + >> + + 다음 @@ -135,4 +204,4 @@

유저 관리

- \ No newline at end of file + diff --git a/src/test/java/com/aisip/OnO/backend/BackendApplicationTests.java b/src/test/java/com/aisip/OnO/backend/BackendApplicationTests.java index 68cbe27f..6cf5e821 100644 --- a/src/test/java/com/aisip/OnO/backend/BackendApplicationTests.java +++ b/src/test/java/com/aisip/OnO/backend/BackendApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BackendApplicationTests { @Test diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java index a41507f2..c8d9b6b7 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java @@ -13,6 +13,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; @@ -24,6 +25,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class AuthControllerTest { @Autowired @@ -88,4 +90,4 @@ void refreshToken() throws Exception { // Verify userAuthService 호출 검증 Mockito.verify(userAuthService, Mockito.times(1)).refreshAccessToken(any()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java b/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java new file mode 100644 index 00000000..00191420 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java @@ -0,0 +1,94 @@ +package com.aisip.OnO.backend.common.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AssignableTypeFilter; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorCaseContractTest { + + private static final String BASE_PACKAGE = "com.aisip.OnO.backend"; + + @Test + void errorCodesAreUnique() { + List errorCases = findErrorCases(); + + Map> casesByCode = errorCases.stream() + .collect(Collectors.groupingBy( + ErrorCase::getErrorCode, + LinkedHashMap::new, + Collectors.toList() + )); + + Map> duplicatedCodes = casesByCode.entrySet().stream() + .filter(entry -> entry.getValue().size() > 1) + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().stream() + .map(this::formatErrorCase) + .toList(), + (left, right) -> left, + LinkedHashMap::new + )); + + assertThat(duplicatedCodes) + .as("ErrorCase errorCode must be unique. Duplicates: %s", duplicatedCodes) + .isEmpty(); + } + + @Test + void errorCasesHaveValidResponseFields() { + List errorCases = findErrorCases(); + + assertThat(errorCases) + .allSatisfy(errorCase -> { + assertThat(errorCase.getHttpStatusCode()) + .as("%s httpStatusCode", formatErrorCase(errorCase)) + .isBetween(400, 599); + assertThat(errorCase.getErrorCode()) + .as("%s errorCode", formatErrorCase(errorCase)) + .isPositive(); + assertThat(errorCase.getMessage()) + .as("%s message", formatErrorCase(errorCase)) + .isNotBlank(); + }); + } + + private List findErrorCases() { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AssignableTypeFilter(ErrorCase.class)); + + return scanner.findCandidateComponents(BASE_PACKAGE).stream() + .map(beanDefinition -> loadClass(beanDefinition.getBeanClassName())) + .filter(Class::isEnum) + .map(Class::getEnumConstants) + .map(constants -> Arrays.stream(constants) + .map(ErrorCase.class::cast) + .toList()) + .flatMap(Collection::stream) + .sorted(Comparator.comparing(ErrorCase::getErrorCode)) + .toList(); + } + + private Class loadClass(String className) { + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Failed to load ErrorCase class: " + className, e); + } + } + + private String formatErrorCase(ErrorCase errorCase) { + Enum enumValue = (Enum) errorCase; + return enumValue.getDeclaringClass().getSimpleName() + "." + enumValue.name(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java index 9957228c..3724c2fe 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java @@ -45,7 +45,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 랜덤 포트로 애플리케이션 실행 @AutoConfigureMockMvc -@ActiveProfiles("local") +@ActiveProfiles("test") public class FolderApiIntegrationTest { @Autowired @@ -251,12 +251,7 @@ public void getRootFolderTest_NotExist() throws Exception { // when & then - 해당 폴더를 조회하는 API 호출 MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/api/folders/root")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.folderId").isNotEmpty()) - .andExpect(jsonPath("$.data.folderName").isNotEmpty()) - .andExpect(jsonPath("$.data.parentFolder").isEmpty()) - .andExpect(jsonPath("$.data.subFolderList.length()").value(0)) - .andExpect(jsonPath("$.data.problemIdList.length()").value(0)) + .andExpect(status().is4xxClientError()) .andReturn(); String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); 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 f49a1d28..2e0e9e0e 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 @@ -21,6 +21,7 @@ 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 java.time.LocalDateTime; import java.util.ArrayList; @@ -33,6 +34,7 @@ import static org.mockito.Mockito.doNothing; @SpringBootTest +@ActiveProfiles("test") class FolderServiceTest { @Autowired 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 index aa5d1338..88b5212f 100644 --- a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java +++ b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java @@ -34,7 +34,7 @@ import static org.mockito.Mockito.when; @SpringBootTest -@ActiveProfiles("local") +@ActiveProfiles("test") @Transactional class LearningReportServiceAiTest { 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 index 7ba5ae33..a70885e5 100644 --- a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceTest.java @@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(properties = "learning-report.ai.enabled=false") -@ActiveProfiles("local") +@ActiveProfiles("test") @Transactional class LearningReportServiceTest { diff --git a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java index 3d70377d..65cbde3d 100644 --- a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java +++ b/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java @@ -40,7 +40,7 @@ */ @Slf4j @SpringBootTest -@ActiveProfiles("local") +@ActiveProfiles("test") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class LargePerformanceTest { diff --git a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java index bc638e61..cf9481ae 100644 --- a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java +++ b/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java @@ -23,7 +23,7 @@ */ @Slf4j @SpringBootTest -@ActiveProfiles("local") +@ActiveProfiles("test") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class PersonalPerformanceTest { diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java index 993ba910..6ec2f41f 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java @@ -46,7 +46,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 랜덤 포트로 애플리케이션 실행 @AutoConfigureMockMvc -@ActiveProfiles("local") +@ActiveProfiles("test") public class PracticeNoteIntegrationTest { @Autowired diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java index abb5827b..029fda17 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; import java.util.ArrayList; @@ -34,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest +@ActiveProfiles("test") class PracticeNoteServiceTest { @Autowired @@ -318,4 +320,4 @@ void deleteAllPracticesByUser() { assertThat(practiceNoteRepository.findAll()).isEmpty(); assertThat(problemPracticeNoteMappingRepository.findAll()).isEmpty(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java b/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java index e5d885af..0a7c521e 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java @@ -19,6 +19,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.mock.web.MockMultipartFile; import java.time.LocalDateTime; import java.util.ArrayList; @@ -36,7 +37,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("local") // 로컬 프로필 사용 +@ActiveProfiles("test") class ProblemControllerTest { @Autowired @@ -75,7 +76,11 @@ void setUp() { LocalDateTime.now(), LocalDateTime.now(), imageUrlList, - null + (long) i, + LocalDateTime.now(), + null, + List.of(), + List.of() ); problemResponseDtoList.add(problemResponseDto); @@ -99,6 +104,8 @@ void getProblem() throws Exception { .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.problemId").value(1L)) + .andExpect(jsonPath("$.data.solveCount").value(1L)) + .andExpect(jsonPath("$.data.lastSolvedAt").exists()) .andExpect(jsonPath("$.data.memo").value("memo1")) .andExpect(jsonPath("$.data.reference").value("reference1")) .andExpect(jsonPath("$.data.imageUrlList.size()").value(3)) @@ -117,6 +124,8 @@ void getProblemsByUserId() throws Exception { .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.size()").value(5)) + .andExpect(jsonPath("$.data[0].solveCount").value(1L)) + .andExpect(jsonPath("$.data[0].lastSolvedAt").exists()) .andExpect(jsonPath("$.data[0].memo").value("memo1")) .andExpect(jsonPath("$.data[0].reference").value("reference1")) .andExpect(jsonPath("$.data[0].imageUrlList.size()").value(3)) @@ -181,18 +190,20 @@ void registerProblem() throws Exception { @WithMockCustomUser() void registerProblemImageData() throws Exception { // When & Then - mockMvc.perform(post("/api/problems/imageData") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - new ProblemImageDataRegisterDto( - 1L, - "problemImage", - ProblemImageType.PROBLEM_IMAGE - ) - ))) + MockMultipartFile image = new MockMultipartFile( + "problemImages", + "problem.png", + MediaType.IMAGE_PNG_VALUE, + "problem-image".getBytes() + ); + + mockMvc.perform(multipart("/api/problems/{problemId}/imageData", 1L) + .file(image) + .param("problemImageTypes", ProblemImageType.PROBLEM_IMAGE.name())) .andExpect(status().isOk()); - //verify(problemService, times(1)).registerProblemImageData(any(), eq(1L)); // userId가 1L인 것도 검증 + verify(problemService, times(1)).uploadProblemImages(eq(1L), eq(1L), any(), any()); + verify(problemService, times(1)).analysisProblem(eq(1L)); } @Test @@ -255,4 +266,4 @@ void deleteProblemImageData() throws Exception { Mockito.verify(problemService, Mockito.times(1)).deleteProblemImageData(imageUrl); } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index ffb5361f..7916544e 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -51,7 +51,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 랜덤 포트로 애플리케이션 실행 @AutoConfigureMockMvc -@ActiveProfiles("local") +@ActiveProfiles("test") class ProblemApiIntegrationTest { @Autowired diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java index 72e3f472..73414c04 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java @@ -14,6 +14,9 @@ import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; 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 org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,6 +24,7 @@ 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 java.time.LocalDateTime; import java.util.ArrayList; @@ -34,6 +38,7 @@ import static org.springframework.test.util.ReflectionTestUtils.setField; @SpringBootTest +@ActiveProfiles("test") class ProblemServiceTest { @Autowired @@ -48,6 +53,9 @@ class ProblemServiceTest { @Autowired private FolderRepository folderRepository; + @Autowired + private ProblemSolveRepository problemSolveRepository; + @MockBean private FileUploadService fileUploadService; @@ -107,6 +115,7 @@ void tearDown() { problemList.clear(); folderList.clear(); + problemSolveRepository.deleteAll(); problemImageDataRepository.deleteAll(); problemRepository.deleteAll(); folderRepository.deleteAll(); @@ -118,6 +127,10 @@ void findProblem() { // given Problem problem = problemList.get(0); Long problemId = problem.getId(); + LocalDateTime firstSolvedAt = LocalDateTime.now().minusDays(1); + LocalDateTime lastSolvedAt = LocalDateTime.now(); + problemSolveRepository.save(ProblemSolve.create(problem, userId, firstSolvedAt, AnswerStatus.CORRECT, null, null, null)); + problemSolveRepository.save(ProblemSolve.create(problem, userId, lastSolvedAt, AnswerStatus.WRONG, null, null, null)); // when ProblemResponseDto problemResponseDto = problemService.findProblem(problemId); @@ -127,6 +140,8 @@ void findProblem() { assertThat(problemResponseDto.reference()).isEqualTo(problem.getReference()); assertThat(problemResponseDto.imageUrlList().size()).isEqualTo(problemImageDataRepository.findAllByProblemId(problemId).size()); assertThat(problemResponseDto.imageUrlList().size()).isEqualTo(2); + assertThat(problemResponseDto.solveCount()).isEqualTo(2L); + assertThat(problemResponseDto.lastSolvedAt()).isEqualTo(lastSolvedAt); } @Test @@ -135,6 +150,9 @@ void findUserProblems() { //given //when + Problem firstProblem = problemList.get(0); + LocalDateTime lastSolvedAt = LocalDateTime.now(); + problemSolveRepository.save(ProblemSolve.create(firstProblem, userId, lastSolvedAt, AnswerStatus.CORRECT, null, null, null)); List problemResponseDtoList = problemService.findUserProblems(userId); //then @@ -147,6 +165,10 @@ void findUserProblems() { assertThat(problemResponseDtoList.get(i).memo()).isEqualTo(problem.getMemo()); assertThat(problemResponseDtoList.get(i).reference()).isEqualTo(problem.getReference()); } + assertThat(problemResponseDtoList.get(0).solveCount()).isEqualTo(1L); + assertThat(problemResponseDtoList.get(0).lastSolvedAt()).isEqualTo(lastSolvedAt); + assertThat(problemResponseDtoList.get(1).solveCount()).isEqualTo(0L); + assertThat(problemResponseDtoList.get(1).lastSolvedAt()).isNull(); } @Test @@ -444,4 +466,4 @@ void deleteProblemImageData() { } } } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java index a98cd2c4..c062f266 100644 --- a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java @@ -16,6 +16,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; @@ -29,6 +30,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class UserControllerTest { @Autowired @@ -110,4 +112,4 @@ void deleteUserInfo() throws Exception { // userService.deleteUserById(userId)가 1번 호출되었는지 확인 Mockito.verify(userService, Mockito.times(1)).deleteUserById(1L); } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java new file mode 100644 index 00000000..49d8ca32 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java @@ -0,0 +1,42 @@ +package com.aisip.OnO.backend.user.dto; + +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserResponseDtoTest { + + @Test + @DisplayName("15레벨 초과 미션 정보는 15레벨 풀 게이지로 응답한다") + void fromCapsMissionStatusOverMaxLevel() { + User user = User.from(new UserRegisterDto( + "test@example.com", + "testUser", + "testIdentifier", + "MEMBER", + null + )); + + user.getUserMissionStatus().setAttendanceLevel(16L, 10L); + user.getUserMissionStatus().setNoteWriteLevel(17L, 20L); + user.getUserMissionStatus().setProblemPracticeLevel(18L, 30L); + user.getUserMissionStatus().setNotePracticeLevel(19L, 40L); + user.getUserMissionStatus().setTotalStudyLevel(16L, 50L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.attendanceLevel()).isEqualTo(15L); + assertThat(response.attendancePoint()).isEqualTo(150L); + assertThat(response.noteWriteLevel()).isEqualTo(15L); + assertThat(response.noteWritePoint()).isEqualTo(150L); + assertThat(response.problemPracticeLevel()).isEqualTo(15L); + assertThat(response.problemPracticePoint()).isEqualTo(150L); + assertThat(response.notePracticeLevel()).isEqualTo(15L); + assertThat(response.notePracticePoint()).isEqualTo(150L); + assertThat(response.totalStudyLevel()).isEqualTo(15L); + assertThat(response.totalStudyCurrentPoint()).isEqualTo(600L); + assertThat(response.totalStudyNextLevelThreshold()).isZero(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java index 7e47935c..f2e17a2c 100644 --- a/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java @@ -32,7 +32,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 랜덤 포트로 애플리케이션 실행 @AutoConfigureMockMvc -@ActiveProfiles("local") +@ActiveProfiles("test") public class UserApiIntegrationTest { @Autowired