From 5a12a82bfa13d9cf844a40458a4c6555500c5a29 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 15:00:14 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[Chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EB=B0=B0=ED=8F=AC=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci-prod 는 수동 실행이 아니라 main push 로 바로 배포를 시작하고, 이 워크플로를 기다리지 않는다 - 그래서 배포를 막는 것은 PR 단계이고, main 과 develop 의 branch protection 이 격리 환경 테스트를 required check 로 건다 --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57c73372..4479829d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,9 @@ name: Test # main / develop 으로 합치기 전에 반드시 통과해야 하는 게이트. -# 기존 ci-dev / ci-prod 는 workflow_dispatch 수동 실행이고 `-x test` 로 테스트를 건너뛴다. -# 배포 파이프라인은 그대로 두고, 검증만 이 워크플로가 책임진다. +# ci-dev 는 workflow_dispatch 수동 실행이고, ci-prod 는 main push 로 바로 배포를 시작한다. 둘 다 `-x test` 로 +# 테스트를 건너뛰고 이 워크플로를 기다리지 않는다. 그래서 main push 때 도는 이 워크플로는 배포와 나란히 돌 뿐이고, +# 배포를 막는 것은 PR 단계다. main 과 develop 의 branch protection 이 이 job(`격리 환경 테스트`)을 required check 로 건다. on: pull_request: branches: [main, develop] From 548e341a4d0fccd1b7284677a6116d55d6dddd31 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 16:06:41 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[Chore]=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A1=9C=20=EC=97=B0=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=EB=B9=88=20=ED=8C=A8=EB=84=90=EA=B3=BC=20=ED=8B=80=EB=A6=B0=20?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ono-alerts.yml 규칙 7개는 평가되고 있었지만 prometheus.prod.yml 에 alerting 설정이 없어서 어디로도 가지 않았다. Alertmanager 를 붙이고 앱 에러 알림과 같은 DISCORD_WEBHOOK_URL 로 보낸다. 웹훅 주소는 설정 파일에 적지 않으려고 컨테이너가 뜰 때 환경변수를 파일로 옮겨 webhook_url_file 로 읽는다 - Redis 와 RabbitMQ 는 떠 있는데 수집을 안 해서 Dependency 대시보드 패널 4개가 항상 비어 있었다. redis-exporter 를 추가하고, RabbitMQ 는 이미지에 내장된 rabbitmq_prometheus 의 detailed 엔드포인트에서 큐별 적체와 컨슈머 수를 가져온다. 합계만 보면 큐 하나의 컨슈머가 죽어도 티가 안 나기 때문이다 - 5xx 비율은 단위가 0~1 인데 경고 기준이 5 라서 에러율이 100% 여도 초록이었고, 평균 응답시간은 기준이 5ms 라 평소에도 빨강이었다. 기준을 고치고 평균 대신 p50 을 둔다 - Server/Process Memory 패널은 JVM 이 내보내지 않는 process_resident_memory_bytes 를 그려서 실제로는 mysql-exporter 메모리를 보여주고 있었다. JVM 메모리로 바꾸고, CPU 도 호스트 전체가 아닌 앱 프로세스 기준으로 바꾼다 - 하루 요청의 96% 가 스캐너라 요청 지표를 /api 로 한정하고, 행동 지표에서는 security_rejected, not_found, unmapped_request 를 뺀다. 트래픽이 적어 5분 창은 너무 튀기 때문에 1시간 창으로 보고 p99 는 뺐다 - 외부 API 성공률은 호출이 없으면 0% 빨강으로 보였다. 호출이 없을 때는 값을 비우고 호출 없음으로 표시한다 - promtail 이 Loki 자기 로그를 수집해서 하루 로그의 97% 를 차지하고 있었다. 수집 대상에서 빼고 Alertmanager 로그를 넣는다 - 배포와 모니터링 재시작 워크플로에 새 컨테이너를 추가하고, restart-monitoring 의 .env.prod 에 빠져 있던 DISCORD_WEBHOOK_URL 을 넣는다 Closes #335 --- .github/workflows/ci-dev.yml | 10 +- .github/workflows/ci-prod.yml | 10 +- .github/workflows/restart-monitoring.yml | 9 +- docker-compose.dev.yml | 13 ++ docker-compose.prod.yml | 37 ++++++ monitoring/alertmanager.prod.yml | 19 +++ .../dashboards/ono-backend-overview.json | 125 +++++++++--------- .../dashboards/ono-dependency-overview.json | 34 ++--- .../ono-user-behavior-overview.json | 15 ++- monitoring/prometheus.dev.yml | 12 ++ monitoring/prometheus.prod.yml | 20 +++ monitoring/promtail.dev.yml | 3 +- monitoring/promtail.prod.yml | 4 +- 13 files changed, 207 insertions(+), 104 deletions(-) create mode 100644 monitoring/alertmanager.prod.yml diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index cdbed3c0..ce9e31f4 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -158,13 +158,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 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 + mysql-exporter-dev redis-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 redis-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 redis-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 loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-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 loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev shell: bash - name: Verify Monitoring Integration diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index bad6a1b5..49f80516 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 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 + mysql-exporter-prod redis-exporter-prod alertmanager-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 redis-exporter-prod alertmanager-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 redis-exporter-prod alertmanager-prod || true docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-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 loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash - name: Verify Monitoring Integration diff --git a/.github/workflows/restart-monitoring.yml b/.github/workflows/restart-monitoring.yml index 3ee6a7aa..231d67c7 100644 --- a/.github/workflows/restart-monitoring.yml +++ b/.github/workflows/restart-monitoring.yml @@ -45,6 +45,7 @@ jobs: RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} GRAFANA_PROD_ROOT_URL=https://ono-prod.seungminki.shop/grafana/ GRAFANA_PROD_SERVE_FROM_SUB_PATH=true PROMETHEUS_PROD_EXTERNAL_URL=https://ono-prod.seungminki.shop/prometheus/ @@ -56,16 +57,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 loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-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 loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-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 loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-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 loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2870d6fe..40224926 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -209,6 +209,19 @@ services: profiles: - monitoring + redis-exporter-dev: + image: oliver006/redis_exporter:v1.67.0 + container_name: ono-redis-exporter-dev + restart: unless-stopped + environment: + REDIS_ADDR: redis://redis-dev:6379 + depends_on: + - redis-dev + networks: + - ono-network + profiles: + - monitoring + # Grafana - DEVELOPMENT (Profile: monitoring) grafana-dev: image: grafana/grafana:latest diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b928c4ed..9816239c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -275,6 +275,41 @@ services: profiles: - monitoring + redis-exporter-prod: + image: oliver006/redis_exporter:v1.67.0 + container_name: ono-redis-exporter-prod + restart: unless-stopped + environment: + REDIS_ADDR: redis://redis-prod:6379 + depends_on: + - redis-prod + networks: + - ono-network + profiles: + - monitoring + + # Alertmanager - PRODUCTION (Profile: monitoring) + # Prometheus 알림을 디스코드로 보낸다. 웹훅 주소를 설정 파일에 적지 않으려고 + # 뜰 때 환경변수를 파일로 옮기고 alertmanager.prod.yml 의 webhook_url_file 로 읽는다. + alertmanager-prod: + image: prom/alertmanager:v0.28.1 + container_name: ono-alertmanager-prod + restart: unless-stopped + environment: + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} + entrypoint: ["/bin/sh", "-c"] + command: + - | + printf '%s' "$$DISCORD_WEBHOOK_URL" > /tmp/discord_webhook_url + exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager --cluster.listen-address= + volumes: + - ./monitoring/alertmanager.prod.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_prod_data:/alertmanager + networks: + - ono-network + profiles: + - monitoring + # Grafana - PRODUCTION (Profile: monitoring) grafana-prod: image: grafana/grafana:latest @@ -320,3 +355,5 @@ volumes: driver: local grafana_prod_data: driver: local + alertmanager_prod_data: + driver: local diff --git a/monitoring/alertmanager.prod.yml b/monitoring/alertmanager.prod.yml new file mode 100644 index 00000000..fbb32722 --- /dev/null +++ b/monitoring/alertmanager.prod.yml @@ -0,0 +1,19 @@ +# Prometheus 가 평가한 알림(monitoring/alerts/ono-alerts.yml)을 디스코드로 보낸다. +# 웹훅 주소는 앱 에러 알림과 같은 DISCORD_WEBHOOK_URL 을 쓰고, 컨테이너가 뜰 때 +# 환경변수를 파일로 옮겨 둔다(docker-compose.prod.yml 의 alertmanager-prod 참고). +# 설정 파일에 비밀값을 적지 않기 위해 webhook_url 대신 webhook_url_file 을 쓴다. + +route: + receiver: discord + group_by: [alertname, job] + # 같은 알림이 여러 개 동시에 뜨면 30초 모아서 한 번에 보낸다. + group_wait: 30s + group_interval: 5m + # 해결되지 않은 알림은 4시간마다 다시 알린다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url_file: /tmp/discord_webhook_url + send_resolved: true diff --git a/monitoring/grafana/dashboards/ono-backend-overview.json b/monitoring/grafana/dashboards/ono-backend-overview.json index 9eadff30..90a48b86 100644 --- a/monitoring/grafana/dashboards/ono-backend-overview.json +++ b/monitoring/grafana/dashboards/ono-backend-overview.json @@ -37,12 +37,8 @@ "mode": "absolute", "steps": [ { - "color": "green", + "color": "blue", "value": null - }, - { - "color": "red", - "value": 1 } ] }, @@ -73,15 +69,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) or vector(0)", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])) or vector(0)", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "Request Rate (RPS)", - "type": "stat" + "title": "API 요청률 (1h)", + "type": "stat", + "description": "봇을 뺀 /api 요청만 센다. 최근 1시간 평균이라 0.004 req/s 는 1시간에 약 15건이다." }, { "datasource": { @@ -101,9 +96,13 @@ "color": "green", "value": null }, + { + "color": "orange", + "value": 300 + }, { "color": "red", - "value": 5 + "value": 1000 } ] }, @@ -134,15 +133,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "1000 * (sum(rate(http_server_requests_seconds_sum{job=~\"$job\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m])))", + "expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "Avg Response Time", - "type": "stat" + "title": "API p50 응답시간 (1h)", + "type": "stat", + "description": "절반의 요청이 이 시간 안에 끝났다는 뜻이다. 평균과 달리 느린 요청 몇 개에 끌려가지 않는다." }, { "datasource": { @@ -162,9 +160,13 @@ "color": "green", "value": null }, + { + "color": "orange", + "value": 0.01 + }, { "color": "red", - "value": 5 + "value": 0.05 } ] }, @@ -195,15 +197,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m]))", + "expr": "(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\",status=~\"5..\"}[1h])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])), 1e-9)", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "5xx Error Ratio", - "type": "stat" + "title": "API 5xx 비율 (1h)", + "type": "stat", + "description": "최근 1시간 /api 요청 중 서버 오류 비율. 5xx 가 없으면 0% 로 보인다. 1% 주황, 5% 빨강." }, { "datasource": { @@ -260,15 +261,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "system_cpu_usage{job=~\"$job\"}", + "expr": "max(process_cpu_usage{job=~\"$job\"})", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "System CPU Usage", - "type": "stat" + "title": "앱 CPU", + "type": "stat", + "description": "백엔드 프로세스의 CPU 사용률. 호스트 전체 CPU(system_cpu_usage)가 아니다." }, { "datasource": { @@ -306,15 +306,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))", + "expr": "topk(10, sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])))", "legendFormat": "{{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "Traffic by URI", - "type": "timeseries" + "title": "API 요청 (URI Top10, 1h)", + "type": "timeseries", + "description": "많이 불린 /api 10개. 1시간 창이라 선이 완만하다." }, { "datasource": { @@ -352,22 +351,19 @@ }, "targets": [ { - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", - "legendFormat": "p95", - "range": true, + "expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", + "legendFormat": "p50", "refId": "A" }, { - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", - "legendFormat": "p99", - "range": true, + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", + "legendFormat": "p95", "refId": "B" } ], - "title": "Latency (p95/p99)", - "type": "timeseries" + "title": "API 응답시간 p50 / p95 (1h)", + "type": "timeseries", + "description": "p50 은 보통 사용자, p95 는 느린 쪽 사용자가 겪는 시간. p50 은 그대로인데 p95 만 오르면 특정 API 나 특정 상황만 느린 것이다. 트래픽이 적어 p99 는 뺐다." }, { "datasource": { @@ -420,7 +416,8 @@ } ], "title": "JVM Heap", - "type": "timeseries" + "type": "timeseries", + "description": "Used 가 톱니 모양으로 오르내리는 건 GC 라서 정상이다. Max 의 70% 이상에 계속 붙어 있으면 메모리가 모자란 것이다." }, { "datasource": { @@ -480,7 +477,8 @@ } ], "title": "HikariCP Connections", - "type": "timeseries" + "type": "timeseries", + "description": "Pending 이 0 보다 크면 DB 커넥션을 기다리는 요청이 있다는 뜻이다. 느린 쿼리나 풀 부족 신호다." }, { "datasource": { @@ -517,22 +515,19 @@ }, "targets": [ { - "editorMode": "code", - "expr": "process_resident_memory_bytes{job=~\"$job\"}", - "legendFormat": "Process RSS", - "range": true, + "expr": "sum by (area) (jvm_memory_used_bytes{job=~\"$job\"})", + "legendFormat": "{{area}} used", "refId": "A" }, { - "editorMode": "code", - "expr": "jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"}", - "legendFormat": "JVM Heap Used", - "range": true, + "expr": "sum(jvm_memory_max_bytes{job=~\"$job\",area=\"heap\"})", + "legendFormat": "heap max", "refId": "B" } ], - "title": "Server/Process Memory", - "type": "timeseries" + "title": "JVM 메모리 (heap, non-heap)", + "type": "timeseries", + "description": "JVM 이 쓰는 메모리. 이전에는 process_resident_memory_bytes 를 그렸는데 JVM 은 이 지표를 내보내지 않아 mysql-exporter 메모리가 그려지고 있었다." }, { "datasource": { @@ -569,15 +564,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])), 1e-9)))", + "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri=~\"/api.*\"}[1h])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])), 1e-9)))", "legendFormat": "{{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "API Avg Latency by URI (Top20)", - "type": "timeseries" + "title": "API 평균 응답시간 (URI Top20, 1h)", + "type": "timeseries", + "description": "URI 별 평균. 느린 API 를 찾는 용도다." }, { "datasource": { @@ -614,15 +608,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "topk(20, histogram_quantile(0.95, sum by (le, uri, method) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000)", + "expr": "topk(20, histogram_quantile(0.95, sum by (le, uri, method) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000)", "legendFormat": "p95 {{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "API p95 Latency by URI (Top20)", - "type": "timeseries" + "title": "API p95 응답시간 (URI Top20, 1h)", + "type": "timeseries", + "description": "URI 별 p95. 호출이 적은 API 는 한두 건이 곧 p95 라서 튈 수 있다." } ], "refresh": "30s", @@ -644,7 +637,7 @@ "type": "prometheus", "uid": "prometheus" }, - "definition": "label_values(up, job)", + "definition": "label_values(up{job=~\"ono-backend-.*\"}, job)", "hide": 0, "includeAll": true, "label": "job", @@ -652,7 +645,7 @@ "name": "job", "options": [], "query": { - "query": "label_values(up, job)", + "query": "label_values(up{job=~\"ono-backend-.*\"}, job)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, diff --git a/monitoring/grafana/dashboards/ono-dependency-overview.json b/monitoring/grafana/dashboards/ono-dependency-overview.json index 032174e4..2e1bd0ae 100644 --- a/monitoring/grafana/dashboards/ono-dependency-overview.json +++ b/monitoring/grafana/dashboards/ono-dependency-overview.json @@ -298,20 +298,21 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rabbitmq_queue_messages_ready{job=~\"$rabbit_job\"})", - "legendFormat": "Ready", + "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_ready{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} ready", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rabbitmq_queue_messages_unacked{job=~\"$rabbit_job\"})", - "legendFormat": "Unacked", + "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_unacked{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} unacked", "range": true, "refId": "B" } ], - "title": "RabbitMQ Queue Depth", + "description": "큐별로 처리 대기(ready)와 처리 중(unacked) 메시지 수. ready 가 계속 늘면 컨슈머가 못 따라가거나 멈춘 것이다.", + "title": "RabbitMQ 큐 적체", "type": "timeseries" }, { @@ -332,20 +333,21 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rabbitmq_queue_consumers{job=~\"$rabbit_job\"})", - "legendFormat": "Consumers", + "expr": "sum by (queue) (rabbitmq_detailed_queue_consumers{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} consumers", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rabbitmq_up{job=~\"$rabbit_job\"})", - "legendFormat": "Exporter Up", + "expr": "sum(up{job=~\"$rabbit_job\"})", + "legendFormat": "scrape up", "range": true, "refId": "B" } ], - "title": "RabbitMQ Consumers & Up", + "description": "큐별 컨슈머 수. 0 이 된 큐는 메시지를 아무도 처리하지 않는다. scrape up 이 0 이면 RabbitMQ 지표 자체를 못 가져오는 것이다.", + "title": "RabbitMQ 큐별 컨슈머", "type": "timeseries" }, { @@ -364,6 +366,7 @@ {"color": "green", "value": 0.99} ] }, + "noValue": "최근 1시간 호출 없음", "unit": "percentunit" }, "overrides": [] @@ -385,13 +388,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\",outcome=\"success\"}[5m])) / clamp_min(sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])), 1e-9)", + "expr": "sum(increase(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\",outcome=\"success\"}[1h])) / (sum(increase(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[1h])) > 0)", "legendFormat": "Success Rate", "range": true, "refId": "A" } ], - "title": "External Dependency Success Rate", + "description": "최근 1시간 외부 API(OpenAI, S3 등) 호출 성공률. 호출이 없으면 0% 대신 호출 없음으로 표시한다.", + "title": "외부 API 성공률 (1h)", "type": "stat" }, { @@ -412,13 +416,13 @@ "targets": [ { "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(ono_external_requests_seconds_bucket{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])) by (le, dependency))", + "expr": "histogram_quantile(0.95, sum(rate(ono_external_requests_seconds_bucket{job=~\"$backend_job\",dependency=~\"$dependency\"}[1h])) by (le, dependency))", "legendFormat": "{{dependency}} p95", "range": true, "refId": "A" } ], - "title": "External Dependency Latency p95", + "title": "외부 API p95 응답시간 (1h)", "type": "timeseries" }, { @@ -449,7 +453,7 @@ "type": "timeseries" } ], - "refresh": "10s", + "refresh": "1m", "schemaVersion": 39, "style": "dark", "tags": ["ono", "dependencies", "infra"], diff --git a/monitoring/grafana/dashboards/ono-user-behavior-overview.json b/monitoring/grafana/dashboards/ono-user-behavior-overview.json index c0276093..e2bb37cf 100644 --- a/monitoring/grafana/dashboards/ono-user-behavior-overview.json +++ b/monitoring/grafana/dashboards/ono-user-behavior-overview.json @@ -58,7 +58,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])) or vector(0)", + "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])) or vector(0)", "legendFormat": "actions/sec", "range": true, "refId": "A" @@ -104,13 +104,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",outcome=~\"client_error|server_error\"}[5m])) / clamp_min(sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])), 1e-9)", + "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\",outcome=~\"client_error|server_error\"}[1h])) / clamp_min(sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[1h])), 1e-9)", "legendFormat": "error ratio", "range": true, "refId": "A" } ], - "title": "Action Error Ratio", + "description": "최근 1시간 실제 API 호출 중 4xx, 5xx 비율. 없는 경로를 긁는 스캐너 요청(security_rejected, not_found, unmapped_request)은 뺐다.", + "title": "행동 에러율 (1h, 봇 제외)", "type": "stat" }, { @@ -180,7 +181,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (authenticated, authority) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m]))", + "expr": "sum by (authenticated, authority) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m]))", "legendFormat": "{{authenticated}} / {{authority}}", "range": true, "refId": "A" @@ -210,7 +211,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(10, sum by (domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])))", + "expr": "topk(10, sum by (domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])))", "legendFormat": "{{domain}}", "range": true, "refId": "A" @@ -240,7 +241,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(15, sum by (domain, action, method) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])))", + "expr": "topk(15, sum by (domain, action, method) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])))", "legendFormat": "{{domain}}.{{action}} {{method}}", "range": true, "refId": "A" @@ -300,7 +301,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (outcome, domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m]))", + "expr": "sum by (outcome, domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m]))", "legendFormat": "{{outcome}} / {{domain}}", "range": true, "refId": "A" diff --git a/monitoring/prometheus.dev.yml b/monitoring/prometheus.dev.yml index eec31420..5a349b40 100644 --- a/monitoring/prometheus.dev.yml +++ b/monitoring/prometheus.dev.yml @@ -14,3 +14,15 @@ scrape_configs: - job_name: "ono-mysql-dev" static_configs: - targets: ["mysql-exporter-dev:9104"] + + - job_name: "ono-redis-dev" + static_configs: + - targets: ["redis-exporter-dev:9121"] + + # prod 와 같다. 큐별 적체와 컨슈머 수를 보려고 detailed 엔드포인트를 쓴다. + - job_name: "ono-rabbitmq-dev" + metrics_path: "/metrics/detailed" + params: + family: ["queue_coarse_metrics", "queue_consumer_count"] + static_configs: + - targets: ["rabbitmq-dev:15692"] diff --git a/monitoring/prometheus.prod.yml b/monitoring/prometheus.prod.yml index 423980b4..2c556803 100644 --- a/monitoring/prometheus.prod.yml +++ b/monitoring/prometheus.prod.yml @@ -5,6 +5,12 @@ global: rule_files: - /etc/prometheus/alerts/ono-alerts.yml +# 규칙이 평가돼도 받을 곳이 없으면 아무도 모른다. Alertmanager 가 디스코드로 보낸다. +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager-prod:9093"] + scrape_configs: - job_name: "ono-backend-prod-blue" metrics_path: "/actuator/prometheus" @@ -19,3 +25,17 @@ scrape_configs: - job_name: "ono-mysql-prod" static_configs: - targets: ["mysql-exporter-prod:9104"] + + - job_name: "ono-redis-prod" + static_configs: + - targets: ["redis-exporter-prod:9121"] + + # RabbitMQ 이미지에 내장된 rabbitmq_prometheus 플러그인을 쓴다. + # 기본 /metrics 는 전체 합계만 주기 때문에, 큐 하나의 컨슈머가 죽어도 티가 안 난다. + # 큐별 적체와 컨슈머 수를 보려고 detailed 엔드포인트에서 필요한 묶음만 가져온다. + - job_name: "ono-rabbitmq-prod" + metrics_path: "/metrics/detailed" + params: + family: ["queue_coarse_metrics", "queue_consumer_count"] + static_configs: + - targets: ["rabbitmq-prod:15692"] diff --git a/monitoring/promtail.dev.yml b/monitoring/promtail.dev.yml index 7fb01b99..5a494a36 100644 --- a/monitoring/promtail.dev.yml +++ b/monitoring/promtail.dev.yml @@ -15,6 +15,8 @@ scrape_configs: refresh_interval: 5s filters: - name: name + # ono-loki-dev 는 넣지 않는다. Loki 는 자기 로그를 많이 찍어서 + # 넣으면 하루 로그의 97%를 Loki 자기 로그가 차지한다(2026-09-21 기준 약 15만 줄 중 14.8만 줄). values: - ono-app-dev - ono-mysql-dev @@ -22,7 +24,6 @@ scrape_configs: - ono-rabbitmq-dev - ono-prometheus-dev - ono-grafana-dev - - ono-loki-dev - ono-promtail-dev - ono-mysql-exporter-dev relabel_configs: diff --git a/monitoring/promtail.prod.yml b/monitoring/promtail.prod.yml index 1083fcfa..7c319d27 100644 --- a/monitoring/promtail.prod.yml +++ b/monitoring/promtail.prod.yml @@ -15,6 +15,8 @@ scrape_configs: refresh_interval: 5s filters: - name: name + # ono-loki-prod 는 넣지 않는다. Loki 는 자기 로그를 많이 찍어서 + # 넣으면 하루 로그의 97%를 Loki 자기 로그가 차지한다(2026-09-21 기준 약 15만 줄 중 14.8만 줄). values: - ono-app-prod-blue - ono-app-prod-green @@ -23,9 +25,9 @@ scrape_configs: - ono-rabbitmq-prod - ono-prometheus-prod - ono-grafana-prod - - ono-loki-prod - ono-promtail-prod - ono-mysql-exporter-prod + - ono-alertmanager-prod relabel_configs: - source_labels: [__meta_docker_container_name] regex: '/(.*)' From 00630acc47cdd17dbf6c35d0dc92e64bc2612cf2 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 17:05:36 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[Feat]=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EA=B3=BC=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=EA=B9=8C=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read 부터 서버에 저장된 적 없는 refresh token 으로 토큰 갱신 경로가 1002 로 답하는지 본다. 서버는 저장 전에 끝나서 쓰기가 없다 - full 에서 이미지 업로드 URL 발급과 오답노트 등록, 조회, 삭제, 404 를 확인한다. X-App-Version 헤더로 XP 적립을 막는다 - 흔적이 남는 GET(presigned URL)은 full 에서만 재시도 없이 보내고, refresh 는 확인용 토큰만 허용한다 - nginx 전환 직전에 새 컨테이너를 확인하는 pre_switch_check.sh 를 추가한다 (actuator health 후 새 포트로 read) - 자체 테스트 42개에서 64개, README 와 smoke-test.yml 의 서명 키 설명을 실제 설정에 맞게 고친다 --- .github/workflows/smoke-test.yml | 14 +- scripts/smoke/README.md | 46 ++++- scripts/smoke/pre_switch_check.sh | 62 +++++++ scripts/smoke/smoke_test.py | 163 ++++++++++++++++-- scripts/smoke/test_smoke_test.py | 278 +++++++++++++++++++++++++++++- 5 files changed, 527 insertions(+), 36 deletions(-) create mode 100755 scripts/smoke/pre_switch_check.sh diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 41383f8d..1f6ba889 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -15,10 +15,12 @@ name: Smoke Test # - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다. # # 필요한 Secrets (없으면 할 수 있는 단계까지만 한다) -# JWT_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL +# JWT_ACCESS_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL # -# JWT_ACCESS_TOKEN_SECRET 은 배포 워크플로가 서버에 넘기는 그 키다(ci-dev.yml, ci-prod.yml). 스모크는 같은 키로 -# 토큰을 직접 만들어 쓰기 때문에 값이 같아야 하고, 따로 복사해 두면 키를 바꿀 때 한쪽만 남아 알림이 계속 울린다. +# JWT_*_TOKEN_SECRET 은 배포 워크플로가 컨테이너에 환경변수로 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는 +# APPLICATION_PROD 안의 jwt.accessToken.secret, jwt.refreshToken.secret 이다(환경변수 이름이 Spring 설정 키와 이어지지 않는다). +# 스모크는 이 시크릿으로 토큰을 직접 만들기 때문에 두 곳의 값이 같아야 한다. 서버 키를 바꾸면 이 시크릿도 같이 바꾼다. +# refresh 키로는 서버에 저장된 적 없는 토큰을 만들어 갱신 경로가 1002 로 답하는지만 본다(서버에 쓰기 없음). # # workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다. @@ -128,10 +130,11 @@ jobs: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then - unset SMOKE_ACCESS_TOKEN_SECRET + unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET python3 scripts/smoke/smoke_test.py bootstrap else python3 scripts/smoke/smoke_test.py @@ -144,10 +147,11 @@ jobs: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then - unset SMOKE_ACCESS_TOKEN_SECRET + unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET python3 scripts/smoke/smoke_test.py bootstrap else python3 scripts/smoke/smoke_test.py diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md index a3d72acf..667a657b 100644 --- a/scripts/smoke/README.md +++ b/scripts/smoke/README.md @@ -11,22 +11,40 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 | 배포 워크플로가 끝난 뒤 (`workflow_run`) | 그 배포의 서버 | 배포 성공이면 `full`, 실패면 `read` | 통과와 실패 모두 Discord 로 | | 매시 17분 (`schedule`) | prod | `read` | 실패로 바뀔 때와 복구될 때. 실패가 이어지면 6시간마다 다시 | | 수동 실행 (`workflow_dispatch`) | 고른 서버 | 고른 단계 | 없음. prod 는 `main` 브랜치에서만 실행된다 | +| prod 배포 중 nginx 전환 직전 (`ci-prod.yml`) | 새 컨테이너 포트 `http://127.0.0.1:8080` 또는 `8081` | actuator health + `read` (토큰 갱신 확인 제외) | 실패하면 전환하지 않고 배포가 실패로 끝난다. 돌고 있던 앱이 없던 복구 배포는 경고만 하고 전환한다 | -배포 워크플로 파일은 건드리지 않았다. 배포가 끝난 것을 받아서 따로 도는 방식이라, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. -맥미니 러너가 아니라 GitHub 호스티드 러너에서 돌기 때문에 운영 기계의 자원도 쓰지 않는다. +위 세 줄은 `smoke-test.yml` 이 GitHub 호스티드 러너에서 돌리고, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. + +마지막 줄은 `pre_switch_check.sh` 를 배포 job(맥미니 러너)이 부른다. 새 컨테이너가 healthy 가 된 뒤, nginx 를 넘기기 전에 +그 컨테이너를 직접 확인하고, 실패하면 새 컨테이너를 내린다. nginx 를 건드리지 않았으니 이전 버전이 계속 사용자를 받는다. + +- **actuator health 는 여기서만 본다.** actuator 는 컨테이너 안 8081 에서만 떠서(`docker-compose.prod.yml` 의 `MANAGEMENT_SERVER_PORT`) 밖에서는 닿지 않는다. db, redis, rabbit 중 하나라도 DOWN 이면 503 이다. 컨테이너 healthcheck 도 같은 주소를 보지만 30초 간격에 3번 실패해야 unhealthy 가 되어서 전환 직전에 한 번 더 본다. 응답 본문에 버전과 디스크 용량이 있어 로그에는 구성요소 이름과 상태만 남긴다. +- **쓰기가 있는 `full` 은 전환 전에 돌리지 않는다.** 전환 전에는 이전 버전이 같은 DB 로 사용자를 받고 있다. `full` 은 전환 뒤 `workflow_run` 으로 돈다. +- **러너에 python3 3.9 이상이 없으면 스모크만 건너뛰고 경고를 남긴다.** 이 확인이 생기기 전과 같은 상태라 배포를 막지 않는다. +- **이 확인을 우회해야 하면** `deploy-prod.yml`(수동 배포)을 쓴다. 거기에는 넣지 않았다. +- **실패하면 새 컨테이너를 이름으로 지우고 `prod-latest` 를 이전 이미지로 되돌린다.** 새 컨테이너가 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽는다. 지우지 못하면 `::error::` 로 남기니 다음 배포 전에 직접 지운다. +- **토큰 갱신 확인은 여기서 하지 않는다.** refresh 서명 키가 서버와 같은지 아직 확인되지 않아서, 틀리면 배포가 막히기만 한다. 배포 뒤 확인이 `1002` 로 통과하는 것을 본 뒤에 넣는다. ## 무엇을 확인하나 | 단계 | 확인 | 요청 수 | |---|---|---:| | `reach` | 토큰 없이 `GET /api/problems/problemCount` 가 `401 (1007)` 인가. Cloudflare, nginx, 앱의 인증 필터까지 닿았다는 뜻이다 | 1 | -| `read` | 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 17 | -| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인 | 28 | +| `read` | 토큰 갱신 경로가 `401 (1002)` 로 답하는가. 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 18 | +| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인. 이미지 업로드 URL 발급, 오답노트 등록, 조회, 지우기, 지운 뒤 404 확인 | 35 | `read` 의 조회 API 는 폴더 목록과 썸네일, 폴더 단건, 하위 폴더, 오답노트 수, 복습할 문제, 내 오답노트, 폴더 안 오답노트, 복습노트 썸네일과 전체, 풀이 기록 수, 내 스터디룸, 태그, 학습 캘린더, 학습 리포트 요약이다. 운영 main 버전과 develop 버전 양쪽에 다 있는 API 만 골랐다. +**토큰 갱신은 실패 응답으로 확인한다.** `JWT_REFRESH_TOKEN_SECRET` 으로 서버가 발급한 적 없는 refresh token 을 만들어 보낸다. +서명이 맞으면 서버는 `refresh_token` 테이블을 찾아보고 없으니 `1002` 로 끝나고, 저장 전에 끝나서 쓰기가 없다(`JwtTokenService.refreshAccessToken`). +`1001` 이 오면 refresh 서명 키가 어긋난 것이고, 그러면 앱 사용자의 토큰 갱신이 전부 실패한다. +성공 경로는 보지 않는다. 갱신할 때마다 토큰이 바뀌어서 다음 실행에 쓸 토큰을 어딘가에 저장해야 하기 때문이다. + +**오답노트는 이미지 없이 루트 폴더에 등록한다.** `X-App-Version: 99.0.0` 을 붙여서 서버가 구버전 앱으로 보고 XP 를 적립하지 않게 한다. +이미지 업로드 URL 은 발급만 확인한다. 서버가 서명만 하고 S3 에 올리지는 않아서, S3 자격 증명이 틀린 것은 여기서 잡히지 않는다. + ## 운영 서버에 영향이 없는 이유 - **보낼 수 있는 요청이 스크립트에 고정되어 있다.** `ALLOWED_REQUESTS` 밖의 요청은 보내기 전에 막는다. 쓰기는 `full` 에서만, 게스트 가입은 `bootstrap` 에서만 허용한다. @@ -35,7 +53,7 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 - **지우는 것은 스모크 흔적뿐이다.** 이번 실행에서 만든 것과, 이름이 `__smoke_run_` 으로 시작하는 지난 실행의 흔적만 지운다. - **요청 수와 시간에 상한이 있다.** 한 번 실행에 80개, 200초를 넘으면 멈춘다. 재시도는 GET 만, 연결 실패와 5xx, 429 에서 최대 3번이다. 쓰기는 재시도하지 않는다. - **쓰기가 있는 실행은 대상별로 한 줄로 선다.** 조회만 하는 매시 실행은 따로 돌아서 서로 밀어내지 않는다. -- **리다이렉트를 따라가지 않고, 토큰은 https 로만 보낸다.** +- **리다이렉트를 따라가지 않고, 토큰은 https 이거나 로컬 주소(전환 전 확인)일 때만 보낸다.** ### 넣지 않은 API 와 이유 @@ -46,7 +64,9 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 | `GET /api/users` | 로그인 미션 기록, 포인트와 마지막 접속 시각 갱신이 같이 돈다. 관리자 DAU 집계에도 잡힌다 | | `GET /api/learning-reports` | 캐시가 없으면 OpenAI 를 부른다 | | `GET /api/achievements` | 조건이 맞으면 업적 행을 INSERT 한다 | -| 문제 등록과 삭제 | 삭제해도 XP 와 미션 기록, develop 의 리마인더 행이 남는다 | +| S3 에 실제로 올리는 업로드 | S3 객체가 생기고, 문제에 붙지 않은 이미지를 지우는 경로를 아직 확인하지 않았다 | +| 이미지를 붙인 오답노트 | 분석 요청(OpenAI)과 S3 삭제 메시지가 생길 수 있다 | +| 토큰 갱신 성공, 로그인 | 위에 적은 이유. 로그인은 스모크 계정(게스트)으로 다시 할 수 없다 | | 태그 만들기 | main 에서 지운 태그와 같은 이름으로 다시 만들면 유니크 인덱스에 걸린다 | | 알림 붙인 복습노트 | Quartz 작업이 생기고, main 은 복습노트를 지워도 그 작업을 지우지 않는다 | | 스터디룸 가입 | 다른 멤버의 피드에 보이고, 탈퇴 로직이 외래키에 막힌다 | @@ -66,16 +86,20 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 그래도 남는 것은 이 정도다. -- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트, 오답노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- 오답노트를 지워도 되돌려지지 않는 것이 스모크 계정에 남는다. 하루 3건까지의 `mission_log` 행, 미션 진행도, CANCELED 로 바뀐 복습 리마인더 행이다. + XP 는 버전 헤더로 막는다. 스모크 계정은 스터디룸에 들어가 있지 않아서 피드는 생기지 않는다. +- 이미지 업로드 URL 발급이 스모크 계정의 하루 200회 카운터(Redis)를 한 번 올린다. - 스모크 계정 1명이 관리자 사용자 목록과 사용자 수에 잡힌다. - 스모크 계정 자신의 Redis 캐시 키(`TAG_LIST`, `STREAK`, `LEARNING_REPORT_SUMMARY`)가 TTL 로 생긴다. - Prometheus 의 사용자 행동 지표에 게스트 요청으로 잡힌다. 사용자 ID 태그가 없어서 따로 걸러낼 수는 없다. + 매시 토큰 갱신 확인의 401 이 하루 24건 client_error 로 섞인다. - 서버 DB 가 이미 죽어 있으면 조회가 500 이 되어 기존 예외 처리대로 Discord 에러 알림이 나간다(5분 중복 억제). ## 처음 설정하는 순서 1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다. -2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 서버에 넘기는 그 키다(`ci-dev.yml`, `ci-prod.yml`). 스모크가 같은 키로 토큰을 만들기 때문에 따로 등록할 것이 없다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. +2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 과 `JWT_REFRESH_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 컨테이너에 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는 `APPLICATION_PROD` 안의 `jwt.accessToken.secret`, `jwt.refreshToken.secret` 이다. 두 값이 같아야 한다. access 키는 2026-09-21 prod `full` 통과로 같은 것이 확인됐고, refresh 키는 첫 배포 뒤 확인에서 `1002` 가 나오는지 봐야 한다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. 3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다. 4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다. 5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다. @@ -94,6 +118,10 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된 | 토큰이 거절됐습니다 `1009` | 시크릿이 서버 서명 키와 다르다 (develop 이후) | | 토큰이 거절됐습니다 `1005` | 시크릿이 다르거나(main 은 서명이 틀려도 1005) 시계가 어긋났다 | | 토큰이 거절됐습니다 `1007` | 시크릿이 다르거나 서버의 Redis 블랙리스트 조회가 실패했다 | +| 토큰 갱신 경로: refresh token 을 거절했습니다 `1001` | `JWT_REFRESH_TOKEN_SECRET` 이 서버 refresh 서명 키와 다르다. 같다면 앱 사용자의 토큰 갱신이 전부 실패하고 있다 | +| 토큰 갱신 경로: 만료로 봤습니다 `1006` | 러너와 서버의 시계가 어긋났다 | +| 이미지 업로드 URL 발급 `429` | 스모크 계정의 하루 200회 한도에 닿았다. 수동 `full` 을 많이 돌렸는지 본다 | +| 전환 전 확인: actuator health 가 200 이 아닙니다 | 새 컨테이너에서 db, redis, rabbit 중 하나가 DOWN 이다. 배포 로그의 컨테이너 로그를 본다 | | 앱이 정상 응답하지 않습니다 (5xx) | 앱 내부 오류. DB 연결을 먼저 본다 | | 루트 폴더에 표식 폴더가 없습니다 | `SMOKE_USER_ID` 가 스모크 계정이 아니다 | | 응답 모양이 예상과 다릅니다 | 배포된 API 의 응답 형태가 바뀌었다. 앱 파싱도 깨졌을 수 있다 | @@ -107,7 +135,7 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된 # 닿는지만 SMOKE_BASE_URL=https://ono-prod.seungminki.shop SMOKE_LEVEL=reach python3 scripts/smoke/smoke_test.py -# 스크립트 자체 테스트 42개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한까지 본다) +# 스크립트 자체 테스트 61개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한, 전환 전 확인 스크립트까지 본다) python3 -m unittest discover -s scripts/smoke -p 'test_*.py' ``` diff --git a/scripts/smoke/pre_switch_check.sh b/scripts/smoke/pre_switch_check.sh new file mode 100755 index 00000000..a8c507f5 --- /dev/null +++ b/scripts/smoke/pre_switch_check.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# nginx 를 새 컨테이너로 넘기기 직전에 그 컨테이너를 직접 확인한다. ci-prod.yml 배포 job(맥미니 러너)이 부른다. +# +# pre_switch_check.sh <컨테이너 이름> <호스트 포트> +# +# 1. 컨테이너 안 actuator health. 구성요소(db, redis, rabbit) 중 하나라도 DOWN 이면 503 이라 wget 이 실패한다. +# 컨테이너 healthcheck 도 같은 주소를 보지만 30초 간격에 3번 실패해야 unhealthy 가 되어서, 전환 직전에 한 번 더 본다. +# 응답 본문에는 DB 버전과 디스크 용량 같은 값이 들어 있고 Actions 로그는 공개라, 구성요소 이름과 상태만 찍는다. +# 2. 스모크 테스트 read 를 http://127.0.0.1:<포트> 로. Cloudflare 와 nginx 를 거치지 않고 새 버전 앱만 본다. +# 쓰기가 있는 full 은 전환 뒤 smoke-test.yml 이 돌린다. 전환 전에는 이전 버전이 같은 DB 로 사용자를 받고 있다. +# +# 종료 코드 0 이면 전환해도 된다. 0 이 아니면 호출한 쪽이 새 컨테이너를 내리고 전환하지 않는다. +# python3 가 없거나 너무 오래됐으면 스모크 테스트만 건너뛰고 경고를 남긴다. 이 스텝이 생기기 전과 같은 상태라 +# 배포를 막지 않는다. +# +# 필요한 환경 변수: SMOKE_ACCESS_TOKEN_SECRET, SMOKE_REFRESH_TOKEN_SECRET, SMOKE_USER_ID (없으면 할 수 있는 만큼만 한다) +# 시험할 때는 DOCKER 로 docker 대신 쓸 명령을 넣을 수 있다. + +set -uo pipefail + +container="${1:?컨테이너 이름이 필요합니다}" +port="${2:?호스트 포트가 필요합니다}" +docker_cmd="${DOCKER:-docker}" +here="$(cd "$(dirname "$0")" && pwd)" + +echo "=== 전환 전 확인: $container (포트 $port) ===" + +python_ok=false +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' 2>/dev/null; then + python_ok=true +fi + +if ! health="$($docker_cmd exec "$container" wget -qO- --tries=1 --timeout=10 http://localhost:8081/actuator/health)"; then + echo "[실패] actuator health 가 200 이 아닙니다. db, redis, rabbit 중 하나가 DOWN 이거나 앱이 응답하지 않습니다" + exit 1 +fi +if [ "$python_ok" = true ]; then + HEALTH_JSON="$health" python3 - <<'PY' +import json, os +try: + body = json.loads(os.environ["HEALTH_JSON"]) +except ValueError: + body = None +components = body.get("components") if isinstance(body, dict) else None +if body is None: + print("[통과] actuator health: 200 (본문이 JSON 이 아니라 구성요소는 읽지 못했습니다)") +elif not isinstance(components, dict): + print("[통과] actuator health: 200 (show-details 가 꺼져 있어 구성요소는 보이지 않습니다)") +else: + parts = ", ".join(f"{name} {c.get('status') if isinstance(c, dict) else '?'}" for name, c in sorted(components.items())) + print(f"[통과] actuator health: 200 ({parts})") +PY +else + echo "[통과] actuator health: 200" +fi + +if [ "$python_ok" != true ]; then + echo "::warning::python3 3.9 이상이 없어 전환 전 스모크 테스트를 건너뛰었습니다. 러너에 python3 를 설치해야 합니다" + exit 0 +fi + +SMOKE_BASE_URL="http://127.0.0.1:$port" SMOKE_LEVEL=read python3 "$here/smoke_test.py" diff --git a/scripts/smoke/smoke_test.py b/scripts/smoke/smoke_test.py index d09580b1..134cc5f0 100644 --- a/scripts/smoke/smoke_test.py +++ b/scripts/smoke/smoke_test.py @@ -9,14 +9,17 @@ reach 토큰 없이 한 번 불러서 앱까지 닿는지 본다. read 스모크 전용 게스트 계정으로 앱 화면들이 쓰는 조회 API 를 부른다. - full read 에 더해, 스모크 계정 안에서 폴더와 복습노트를 만들고 조회하고 지운다. + full read 에 더해, 스모크 계정 안에서 폴더와 복습노트, 오답노트를 만들고 조회하고 지운다. + +refresh 서명 키가 있으면 reach 다음에 토큰 갱신 경로도 본다. 서명 키로 직접 만든 refresh token 은 +DB 에 없어서 서버는 항상 1002 를 돌려주고 아무것도 쓰지 않는다. 1001 이 오면 서명 키가 어긋난 것이다. 운영 서버에 지장을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. 각 API 를 고른 근거는 README.md 에 있다. - 보낼 수 있는 요청은 ALLOWED_REQUESTS 에 적힌 메서드와 경로뿐이다. 그 밖의 요청은 보내기 전에 막는다. GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 접속 시각 갱신, DAU 집계가 같이 돌아서 넣지 않았다. -- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. +- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. 토큰 갱신(POST)만 예외인데, 서버가 쓰기 전에 1002 로 끝난다. - 지우는 것은 이번 실행에서 만든 것과, 이름이 RUN_PREFIX 로 시작하는 지난 실행의 흔적뿐이다. - 스모크 계정 루트 폴더 아래에 MARKER_FOLDER_NAME 폴더가 없으면 스모크 계정이 아니라고 보고 더 이상 요청하지 않는다. SMOKE_USER_ID 를 잘못 넣어 실사용자 계정을 건드리는 일을 막는다. @@ -31,6 +34,7 @@ SMOKE_BASE_URL 필수. 예: https://ono-prod.seungminki.shop SMOKE_LEVEL reach | read | full. 기본 read SMOKE_ACCESS_TOKEN_SECRET 서버의 jwt.accessToken.secret 과 같은 Base64 값 + SMOKE_REFRESH_TOKEN_SECRET 서버의 jwt.refreshToken.secret 과 같은 Base64 값. 없으면 토큰 갱신 확인만 건너뛴다 SMOKE_USER_ID bootstrap 으로 만든 스모크 게스트 계정 ID 시크릿이 없으면 reach 만 한다. 시크릿만 있고 계정 ID 가 없으면 존재하지 않는 사용자 ID 0 으로 @@ -85,15 +89,32 @@ KST = timezone(timedelta(hours=9)) # AuthErrorCase +INVALID_REFRESH_TOKEN = 1001 +REFRESH_TOKEN_NOT_FOUND = 1002 +REFRESH_TOKEN_EXPIRED = 1006 AUTHENTICATION_FAILED = 1007 ACCESS_TOKEN_EXPIRED = 1005 INVALID_ACCESS_TOKEN = 1009 # FolderErrorCase, PracticeNoteErrorCase FOLDER_NOT_FOUND = 5001 PRACTICE_NOTE_NOT_FOUND = 6001 +# ProblemErrorCase +PROBLEM_NOT_FOUND = 4001 + +# 오답노트를 등록할 때만 붙인다. 헤더가 없으면 서버가 구버전 앱으로 보고 능력치 포인트(XP)를 적립한다 +# (LegacyAccrualPolicy). 미션을 받을 수 있는 버전 기준(ono.mission.mission-capable-version, 기본 4.0.0)이 +# 올라가도 계속 신버전으로 읽히도록 크게 둔다. 신버전 요청은 XP 대신 미션 진행도만 올린다. +SMOKE_APP_VERSION = "99.0.0" WRITE_METHODS = ("POST", "PATCH", "DELETE") SIGNUP_PATH = "/api/auth/signup/guest" +REFRESH_PATH = "/api/auth/refresh" +PROBLEM_PATH = "/api/problems/v2" +PRESIGNED_PATH = "/api/fileUpload/presigned-urls" + +# GET 이지만 서버에 흔적이 남는 요청. 쓰기처럼 full 에서만 보내고 재시도하지 않는다. +# presigned URL 발급은 서명만 하고 S3 객체는 만들지 않지만, 사용자별 하루 200회 카운터(Redis)를 호출마다 1 올린다. +WRITE_LIKE_GETS = (PRESIGNED_PATH,) # 보낼 수 있는 요청 전부. 경로는 쿼리스트링을 뺀 값과 통째로 맞아야 한다. ALLOWED_REQUESTS = [ @@ -115,11 +136,16 @@ ("GET", r"/api/tags"), ("GET", r"/api/learning-calendar"), ("GET", r"/api/learning-reports/summary"), + ("GET", r"/api/problems/\d+"), + ("GET", PRESIGNED_PATH), ("POST", r"/api/folders"), ("PATCH", r"/api/folders"), ("DELETE", r"/api/folders"), ("POST", r"/api/practiceNotes"), ("DELETE", r"/api/practiceNotes"), + ("POST", PROBLEM_PATH), + ("DELETE", r"/api/problems"), + ("POST", REFRESH_PATH), ("POST", SIGNUP_PATH), ] @@ -176,9 +202,24 @@ def mint_access_token(secret_b64: str, now: int, user_id: str = NONEXISTENT_USER 서버는 jwt.accessToken.secret 을 Base64 로 디코딩한 바이트를 HMAC 키로 쓴다. """ + payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} + return _sign(secret_b64, payload) + + +def mint_refresh_token(secret_b64: str, now: int) -> str: + """JwtTokenizer.createRefreshToken 과 같은 모양의 토큰을 만든다. + + 서버가 발급한 적 없는 토큰이라 refresh_token 테이블에 없고, 서버는 1002 로 끝낸다. + jti 를 매번 새로 넣어서 우연히라도 저장된 토큰과 같아질 일이 없다. + """ + payload = {"authority": SMOKE_AUTHORITY, "sub": NONEXISTENT_USER_ID, "iat": now, + "jti": secrets.token_hex(16), "exp": now + TOKEN_TTL_SECONDS} + return _sign(secret_b64, payload) + + +def _sign(secret_b64: str, payload: dict) -> str: key = base64.b64decode(secret_b64.strip(), validate=True) header = {"alg": "HS256"} - payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} signing_input = ( _b64url(json.dumps(header, separators=(",", ":")).encode()) + "." @@ -245,20 +286,26 @@ def __init__(self, base_url: str, sleep: Callable[[float], None], budget: Budget self.allow_signup = allow_signup def request(self, method: str, path: str, body=None, query: Mapping[str, object] | None = None, - auth: bool = True) -> Response: + auth: bool = True, extra_headers: Mapping[str, str] | None = None) -> Response: method = method.upper() if not any(m == method and re.fullmatch(p, path) for m, p in ALLOWED_REQUESTS): raise SmokeAbort(f"허용 목록에 없는 요청이라 보내지 않았습니다: {method} {path}") if path == SIGNUP_PATH: if not self.allow_signup: raise SmokeAbort("게스트 가입은 bootstrap 에서만 보낼 수 있습니다") - elif method in WRITE_METHODS and not self.allow_writes: + elif path == REFRESH_PATH: + # 저장된 적 없는 토큰만 보내므로 서버에 쓰기가 없다. 그래서 read 에서도 허용한다. + # 서버에 저장된 진짜 refresh token 을 보내면 토큰이 회전(쓰기)되므로, 없는 사용자(sub 0)로 만든 토큰만 통과시킨다. + token = body.get("refreshToken") if isinstance(body, dict) else None + if auth or not isinstance(token, str) or subject_of(token) != NONEXISTENT_USER_ID: + raise SmokeAbort("토큰 갱신은 없는 사용자로 만든 확인용 토큰만 보낼 수 있습니다") + elif (method in WRITE_METHODS or path in WRITE_LIKE_GETS) and not self.allow_writes: raise SmokeAbort(f"쓰기 요청은 full 단계에서만 보낼 수 있습니다: {method} {path}") url = self.base_url + path if query: url += "?" + urllib.parse.urlencode(query) - headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + headers = {"User-Agent": USER_AGENT, "Accept": "application/json", **(extra_headers or {})} if auth and self.token: headers["Authorization"] = self.token data = None @@ -268,8 +315,10 @@ def request(self, method: str, path: str, body=None, query: Mapping[str, object] res = self._send(method, url, headers, data) attempts = 1 - # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. - while method == "GET" and _should_retry(res) and attempts < MAX_ATTEMPTS: + # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. 흔적이 남는 GET 도 같다. + # 토큰 갱신 확인은 서버에 쓰기가 없어 재시도해도 된다. 막 뜬 새 컨테이너에 두 번째로 가는 요청이라 느릴 수 있다. + retryable = (method == "GET" and path not in WRITE_LIKE_GETS) or path == REFRESH_PATH + while retryable and _should_retry(res) and attempts < MAX_ATTEMPTS: self.sleep(RETRY_DELAY_SECONDS) res = self._send(method, url, headers, data) attempts += 1 @@ -373,6 +422,7 @@ def __init__(self, env: Mapping[str, str], sleep: Callable[[float], None], clock self.base_url = env.get("SMOKE_BASE_URL", "").strip() self.level = env.get("SMOKE_LEVEL", "").strip() or "read" self.secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() + self.refresh_secret = env.get("SMOKE_REFRESH_TOKEN_SECRET", "").strip() self.user_id = env.get("SMOKE_USER_ID", "").strip() stamp = datetime.fromtimestamp(clock(), timezone.utc).strftime("%Y%m%dT%H%M%S") self.run_tag = f"{RUN_PREFIX}{stamp}_{secrets.token_hex(2)}" @@ -405,7 +455,7 @@ def validate_config(self) -> str | None: if self.level not in LEVELS: return f"SMOKE_LEVEL 은 {', '.join(LEVELS)} 중 하나여야 합니다: {self.level}" host = urllib.parse.urlsplit(self.base_url).hostname or "" - if self.secret and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: + if (self.secret or self.refresh_secret) and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: return "토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다" if self.user_id and not re.fullmatch(r"[0-9]+", self.user_id): return "SMOKE_USER_ID 는 숫자여야 합니다" @@ -414,6 +464,11 @@ def validate_config(self) -> str | None: base64.b64decode(self.secret, validate=True) except (binascii.Error, ValueError): return "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다" + if self.refresh_secret: + try: + base64.b64decode(self.refresh_secret, validate=True) + except (binascii.Error, ValueError): + return "SMOKE_REFRESH_TOKEN_SECRET 이 Base64 값이 아닙니다" return None def execute(self) -> None: @@ -422,6 +477,10 @@ def execute(self) -> None: return if self.level == "reach": return + if self.refresh_secret: + self.check_refresh(Client(self.base_url, self.sleep, self.budget)) + else: + self.skip("인증: 토큰 갱신 경로", "SMOKE_REFRESH_TOKEN_SECRET 이 없어 건너뛰었습니다") if not self.secret: self.skip("인증과 조회", "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다") return @@ -456,6 +515,25 @@ def check_reachable(self, client: Client) -> bool: self.fail(name, describe_unexpected(res), res) return False + def check_refresh(self, client: Client) -> None: + name = "인증: 토큰 갱신 경로 (없는 refresh token 으로 1002)" + token = mint_refresh_token(self.refresh_secret, int(self.clock())) + _mask(token, self.env) + res = client.request("POST", REFRESH_PATH, {"refreshToken": token}, auth=False) + code = res.error_code() + if res.status == 401 and code == REFRESH_TOKEN_NOT_FOUND: + self.ok(name, f"HTTP 401, errorCode {REFRESH_TOKEN_NOT_FOUND}", res) + elif code == INVALID_REFRESH_TOKEN: + self.fail(name, f"refresh token 을 거절했습니다 (errorCode {code}). SMOKE_REFRESH_TOKEN_SECRET 이 서버의 " + "refresh 서명 키(APPLICATION_PROD 의 jwt.refreshToken.secret)와 다를 수 있습니다. " + "서버 키가 바뀐 것이라면 앱 사용자의 기존 refresh token 이 전부 거절됩니다", res) + elif code == REFRESH_TOKEN_EXPIRED: + self.fail(name, f"방금 만든 토큰을 만료로 봤습니다 (errorCode {code}). 러너와 서버의 시계가 어긋났을 수 있습니다", res) + elif res.status == 200: + self.fail(name, "저장된 적 없는 refresh token 으로 갱신에 성공했습니다. 토큰 저장소 확인이 빠졌을 수 있습니다", res) + else: + self.fail(name, describe_unexpected(res), res) + def check_probe_with_nonexistent_user(self) -> None: token = "Bearer " + mint_access_token(self.secret, int(self.clock())) _mask(token, self.env) @@ -514,17 +592,19 @@ def check_reads(self, client: Client, root: dict) -> None: def check_writes(self, client: Client, root: dict) -> None: created_folders: list[int] = [] created_notes: list[int] = [] + created_problems: list[int] = [] try: self.clean_leftovers(client, root) self.folder_flow(client, root["folderId"], created_folders) self.practice_note_flow(client, created_notes) + self.problem_flow(client, root["folderId"], created_problems) except SmokeAbort as e: self.fail("쓰기 흐름 중단", str(e)) finally: - self.cleanup(client, created_folders, created_notes) + self.cleanup(client, created_folders, created_notes, created_problems) def clean_leftovers(self, client: Client, root: dict) -> None: - """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트를 먼저 지운다.""" + """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트, 오답노트를 먼저 지운다.""" name = "지난 실행 흔적 정리" folder_ids = [f["folderId"] for f in root["subFolderList"] if isinstance(f, dict) and str(f.get("folderName", "")).startswith(RUN_PREFIX)] @@ -534,7 +614,13 @@ def clean_leftovers(self, client: Client, root: dict) -> None: raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") note_ids = [n["practiceNoteId"] for n in res.data() if isinstance(n, dict) and str(n.get("practiceTitle", "")).startswith(RUN_PREFIX)] - if not folder_ids and not note_ids: + res = client.request("GET", "/api/problems/user") + if res.status != 200 or not is_list(res.data()): + self.fail(name, describe_unexpected(res) if res.status != 200 else "오답노트 목록 모양이 예상과 다릅니다", res) + raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") + problem_ids = [p["problemId"] for p in res.data() + if isinstance(p, dict) and str(p.get("memo") or "").startswith(RUN_PREFIX)] + if not folder_ids and not note_ids and not problem_ids: self.ok(name, "남은 흔적 없음", res) return failed = [] @@ -542,10 +628,12 @@ def clean_leftovers(self, client: Client, root: dict) -> None: failed.append(f"폴더 {folder_ids}") if note_ids and self.delete_notes(client, note_ids).status != 200: failed.append(f"복습노트 {note_ids}") + if problem_ids and self.delete_problems(client, problem_ids).status != 200: + failed.append(f"오답노트 {problem_ids}") if failed: self.fail(name, f"지우지 못했습니다: {', '.join(failed)}") else: - self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개를 지웠습니다") + self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개, 오답노트 {len(problem_ids)}개를 지웠습니다") def folder_flow(self, client: Client, root_id: int, created: list[int]) -> None: folder_name = self.run_tag @@ -600,6 +688,45 @@ def practice_note_flow(self, client: Client, created: list[int]) -> None: self.fail("쓰기: 지운 복습노트가 안 보이는가", f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + def problem_flow(self, client: Client, root_id: int, created: list[int]) -> None: + """오답노트를 이미지 없이 등록하고 조회하고 지운다. + + 지워도 되돌려지지 않는 것이 있다. 하루 3건까지의 mission_log 행과 미션 진행도, CANCELED 로 바뀐 복습 + 리마인더 행, soft delete 된 problem 행이 스모크 계정에 남는다. XP 는 SMOKE_APP_VERSION 헤더로 막는다. + """ + res = client.request("GET", PRESIGNED_PATH, query={"count": 1, "contentType": "image/png"}) + urls = res.data() + # 서명된 URL 은 10분 동안 버킷에 올릴 수 있는 값이라, 모양이 틀려도 본문을 로그에 싣지 않는다. + if (res.status == 200 and isinstance(urls, list) and len(urls) == 1 and isinstance(urls[0], dict) + and str(urls[0].get("presignedUrl", "")).startswith("https://") and urls[0].get("fileUrl")): + self.ok("쓰기: 이미지 업로드 URL 발급", "HTTP 200, URL 1개", res) + elif res.status == 200: + self.fail("쓰기: 이미지 업로드 URL 발급", "응답 모양이 예상과 다릅니다 (HTTP 200)", res) + else: + self.fail("쓰기: 이미지 업로드 URL 발급", describe_unexpected(res), res) + + memo = self.run_tag + solved_at = datetime.fromtimestamp(self.clock(), KST).replace(tzinfo=None, microsecond=0).isoformat() + body = {"memo": memo, "reference": None, "folderId": root_id, "solvedAt": solved_at, + "problemImageUrls": [], "answerImageUrls": [], "tagIds": []} + res = client.request("POST", PROBLEM_PATH, body, extra_headers={"X-App-Version": SMOKE_APP_VERSION}) + if not self.expect("쓰기: 오답노트 등록", res, 200, is_count): + raise SmokeAbort("오답노트를 등록하지 못해 쓰기 흐름을 멈췄습니다") + problem_id = res.data() + created.append(problem_id) + + self.expect("쓰기: 등록한 오답노트 조회", client.request("GET", f"/api/problems/{problem_id}"), 200, + lambda v: isinstance(v, dict) and v.get("memo") == memo and v.get("folderId") == root_id) + + if self.expect("쓰기: 오답노트 지우기", self.delete_problems(client, [problem_id]), 200): + res = client.request("GET", f"/api/problems/{problem_id}") + if res.status == 404 and res.error_code() == PROBLEM_NOT_FOUND: + self.ok("쓰기: 지운 오답노트가 안 보이는가", f"HTTP 404, errorCode {PROBLEM_NOT_FOUND}", res) + created.remove(problem_id) + else: + self.fail("쓰기: 지운 오답노트가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + @staticmethod def delete_folders(client: Client, folder_ids: list[int]) -> Response: return client.request("DELETE", "/api/folders", {"deleteFolderIdList": folder_ids}) @@ -608,9 +735,13 @@ def delete_folders(client: Client, folder_ids: list[int]) -> Response: def delete_notes(client: Client, note_ids: list[int]) -> Response: return client.request("DELETE", "/api/practiceNotes", {"deletePracticeIdList": note_ids}) - def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: + @staticmethod + def delete_problems(client: Client, problem_ids: list[int]) -> Response: + return client.request("DELETE", "/api/problems", {"deleteProblemIdList": problem_ids}) + + def cleanup(self, client: Client, folders: list[int], notes: list[int], problems: list[int]) -> None: """흐름이 중간에 멈춰 남은 것을 지운다. 여기서도 못 지우면 다음 실행의 흔적 정리가 지운다.""" - if not folders and not notes: + if not folders and not notes and not problems: return leftovers = [] try: @@ -618,6 +749,8 @@ def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: leftovers.append(f"폴더 {folders}") if notes and self.delete_notes(client, notes).status not in (200, 404): leftovers.append(f"복습노트 {notes}") + if problems and self.delete_problems(client, problems).status not in (200, 404): + leftovers.append(f"오답노트 {problems}") except SmokeAbort as e: leftovers.append(str(e)) if leftovers: diff --git a/scripts/smoke/test_smoke_test.py b/scripts/smoke/test_smoke_test.py index 4a92b691..5bd7f1e5 100644 --- a/scripts/smoke/test_smoke_test.py +++ b/scripts/smoke/test_smoke_test.py @@ -15,9 +15,11 @@ import json import os import re +import subprocess import sys import tempfile import threading +import time import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -27,6 +29,7 @@ SERVER_SECRET = base64.b64encode(b"s" * 64).decode() OTHER_SECRET = base64.b64encode(b"x" * 64).decode() +REFRESH_SECRET = base64.b64encode(b"r" * 64).decode() NOW = 1_800_000_000 SMOKE_USER = "42" REAL_USER = "7" @@ -54,7 +57,8 @@ def issued_token(user_id: str) -> str: class FakeOnO: """스모크 테스트가 부르는 API 만 흉내 내는 OnO 서버. - 사용자마다 루트 폴더와 하위 폴더, 복습노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + 사용자마다 루트 폴더와 하위 폴더, 복습노트, 오답노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + refresh token 은 서버가 발급해 저장한 것만 갱신되고(stored_refresh_tokens), 나머지는 1002 다. override(method, path_regex) 로 특정 요청의 응답을 바꿔 실패 상황을 만든다. """ @@ -64,8 +68,11 @@ def __init__(self): self.lock = threading.Lock() self.next_id = 100 self.users = {} + self.stored_refresh_tokens = set() + self.now = NOW # 만료 판정 기준. 스크립트를 별도 프로세스로 돌리는 시험은 실제 시각으로 바꾼다 self.add_user(SMOKE_USER, marker=True) self.add_user(REAL_USER, marker=False, extra_folders=["수학", "__smoke_run_looks_like_but_real_user"]) + self.add_problem(REAL_USER, "__smoke_run_looks_like_but_real_user") outer = self class Handler(BaseHTTPRequestHandler): @@ -116,7 +123,14 @@ def add_user(self, user_id, marker, extra_folders=()): folders[fid] = {"folderId": fid, "folderName": name, "parent": root, "deleted": False} nid = self.new_id() notes = {nid: {"practiceNoteId": nid, "practiceTitle": "복습 세트", "deleted": False}} - self.users[user_id] = {"root": root, "folders": folders, "notes": notes} + self.users[user_id] = {"root": root, "folders": folders, "notes": notes, "problems": {}} + self.add_problem(user_id, "근의 공식") + + def add_problem(self, user_id, memo): + user = self.users[user_id] + pid = self.new_id() + user["problems"][pid] = {"problemId": pid, "memo": memo, "folderId": user["root"], "deleted": False} + return pid def live_folder_names(self, user_id): return sorted(f["folderName"] for f in self.users[user_id]["folders"].values() if not f["deleted"]) @@ -124,6 +138,9 @@ def live_folder_names(self, user_id): def live_note_titles(self, user_id): return sorted(n["practiceTitle"] for n in self.users[user_id]["notes"].values() if not n["deleted"]) + def live_problem_memos(self, user_id): + return sorted(p["memo"] for p in self.users[user_id]["problems"].values() if not p["deleted"]) + def override(self, method, path_regex, response, times=None): self.overrides.append({"method": method, "path": path_regex, "response": response, "times": times}) @@ -145,6 +162,21 @@ def dispatch(self, method, full_path, headers, body): self.add_user(uid, marker=False) return ok({"accessToken": issued_token(uid), "refreshToken": "refresh-" + uid}) + if method == "POST" and path == "/api/auth/refresh": + # JwtTokenService.refreshAccessToken: 서명과 만료를 보고, 저장된 토큰인지 찾는다. + token = (body or {}).get("refreshToken") or "" + try: + claims = verify_token(token, REFRESH_SECRET) + except ValueError: # 형식이 깨진 토큰. binascii.Error 도 ValueError 다 + claims = None + if claims is None: + return error(400, 1001) + if claims["exp"] < self.now: + return error(401, 1006) + if token not in self.stored_refresh_tokens: + return error(401, 1002) + return ok({"accessToken": "Bearer new", "refreshToken": "new"}) + auth = headers.get("Authorization") if not auth: return error(401, 1007) @@ -157,7 +189,7 @@ def dispatch(self, method, full_path, headers, body): return ok(0) return error(404, 5001) user = self.users[uid] - folders, notes = user["folders"], user["notes"] + folders, notes, problems = user["folders"], user["notes"], user["problems"] def folder_view(f): parent = folders.get(f["parent"]) @@ -174,6 +206,15 @@ def folder_view(f): if m: f = folders.get(int(m.group(1))) return ok(folder_view(f)) if f and not f["deleted"] else error(404, 5001) + pm = re.fullmatch(r"/api/problems/(\d+)", path) + if pm: + problem = problems.get(int(pm.group(1))) + if not problem or problem["deleted"]: + return error(404, 4001) + return ok({k: problem[k] for k in ("problemId", "memo", "folderId")}) + if path == "/api/fileUpload/presigned-urls": + return ok([{"presignedUrl": "https://bucket.s3.amazonaws.com/x.png?X-Amz-Signature=secretsig", + "fileUrl": "https://bucket.s3.amazonaws.com/x.png"}]) n = re.fullmatch(r"/api/practiceNotes/(\d+)", path) if n: note = notes.get(int(n.group(1))) @@ -186,7 +227,8 @@ def folder_view(f): "/api/folders/thumbnails/V2": page, "/api/problems/problemCount": 0, "/api/problems/review-due": {"dueCount": 0, "overdueCount": 0, "problems": []}, - "/api/problems/user": [], + "/api/problems/user": [{k: p[k] for k in ("problemId", "memo", "folderId")} + for p in problems.values() if not p["deleted"]], "/api/practiceNotes/thumbnail": [{"practiceNoteId": x["practiceNoteId"], "practiceTitle": x["practiceTitle"]} for x in notes.values() if not x["deleted"]], "/api/practiceNotes/thumbnail/V2": page, @@ -234,6 +276,20 @@ def folder_view(f): for i in ids: notes[i]["deleted"] = True return ok("선택한 복습 노트가 삭제되었습니다.") + if method == "POST" and path == "/api/problems/v2": + folder = folders.get(body.get("folderId")) + if not folder or folder["deleted"]: + return error(404, 5001) + pid = self.new_id() + problems[pid] = {"problemId": pid, "memo": body.get("memo"), "folderId": folder["folderId"], "deleted": False} + return ok(pid) + if method == "DELETE" and path == "/api/problems": + ids = body.get("deleteProblemIdList", []) + if any(i not in problems or problems[i]["deleted"] for i in ids): + return error(404, 4001) + for i in ids: + problems[i]["deleted"] = True + return ok("문제 삭제가 완료되었습니다.") return error(404, 9999) @@ -383,6 +439,56 @@ def test_boolean_data_is_not_a_count(self): self.assertEqual(1, self.run_smoke(user_id=None)) +class RefreshTest(SmokeTestBase): + + def refresh_requests(self): + return [r for r in self.server.requests if r[1] == smoke_test.REFRESH_PATH] + + def test_passes_when_server_answers_not_found(self): + self.assertEqual(0, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + (method, _, headers, body), = self.refresh_requests() + self.assertEqual("POST", method) + self.assertNotIn("Authorization", headers) + claims = verify_token(body["refreshToken"], REFRESH_SECRET) + self.assertEqual("0", claims["sub"], "스모크 계정이 아니라 없는 사용자로 만든다") + self.assertIn("jti", claims) + self.assertNotIn(body["refreshToken"], self.server.stored_refresh_tokens) + self.assert_only_allowed_requests() + self.assertEqual(18, len(self.server.requests)) + + def test_runs_even_without_access_secret_or_user(self): + self.assertEqual(0, self.run_smoke(level="read", secret=None, user_id=None, SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual([("GET", smoke_test.PROBE_PATH), ("POST", smoke_test.REFRESH_PATH)], self.server.paths()) + + def test_reach_level_does_not_check_refresh(self): + self.assertEqual(0, self.run_smoke(level="reach", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual([], self.refresh_requests()) + + def test_fails_when_refresh_key_differs(self): + smoke = smoke_test.SmokeRun(self.env(SMOKE_REFRESH_TOKEN_SECRET=OTHER_SECRET), self.sleeps.append, lambda: NOW) + smoke.execute() + failed = [r for r in smoke.results if not r.passed] + self.assertEqual(1, len(failed)) + self.assertIn("refresh 서명 키", failed[0].detail) + + def test_fails_when_unknown_token_is_accepted(self): + self.server.override("POST", smoke_test.REFRESH_PATH, ok({"accessToken": "Bearer a", "refreshToken": "b"})) + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + + def test_server_error_is_retried_because_nothing_is_written(self): + self.server.override("POST", smoke_test.REFRESH_PATH, (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.refresh_requests())) + + def test_recovers_when_new_container_is_slow_once(self): + self.server.override("POST", smoke_test.REFRESH_PATH, (503, {}, b""), times=1) + self.assertEqual(0, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + + def test_refresh_failure_does_not_stop_reads(self): + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=OTHER_SECRET)) + self.assertIn(("GET", "/api/learning-reports/summary"), self.server.paths()) + + class ReadTest(SmokeTestBase): def test_read_level_passes_and_sends_only_gets(self): @@ -450,13 +556,16 @@ class WriteTest(SmokeTestBase): def test_full_level_creates_and_removes_only_its_own_data(self): before_folders = self.server.live_folder_names(SMOKE_USER) before_notes = self.server.live_note_titles(SMOKE_USER) - real_before = self.server.live_folder_names(REAL_USER) + before_problems = self.server.live_problem_memos(SMOKE_USER) + real_before = (self.server.live_folder_names(REAL_USER), self.server.live_problem_memos(REAL_USER)) self.assertEqual(0, self.run_smoke(level="full")) self.assertEqual(before_folders, self.server.live_folder_names(SMOKE_USER)) self.assertEqual(before_notes, self.server.live_note_titles(SMOKE_USER)) - self.assertEqual(real_before, self.server.live_folder_names(REAL_USER)) + self.assertEqual(before_problems, self.server.live_problem_memos(SMOKE_USER)) + self.assertEqual(real_before, (self.server.live_folder_names(REAL_USER), self.server.live_problem_memos(REAL_USER))) + self.assertIn(("POST", "/api/problems/v2"), self.server.paths()) self.assert_only_allowed_requests() for method, path, headers, body in self.server.requests: if method == "POST" and path == "/api/practiceNotes": @@ -484,11 +593,13 @@ def test_cleans_leftovers_but_never_marker_or_other_folders(self): user["folders"][leftover] = {"folderId": leftover, "folderName": "__smoke_run_old", "parent": user["root"], "deleted": False} note = self.server.new_id() user["notes"][note] = {"practiceNoteId": note, "practiceTitle": "__smoke_run_old", "deleted": False} + self.server.add_problem(SMOKE_USER, "__smoke_run_old") self.assertEqual(0, self.run_smoke(level="full")) self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) self.assertEqual(["복습 세트"], self.server.live_note_titles(SMOKE_USER)) + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) deleted_ids = [i for m, p, _, b in self.server.requests if m == "DELETE" for i in (b.get("deleteFolderIdList") or b.get("deletePracticeIdList") or [])] marker_id = next(f["folderId"] for f in user["folders"].values() if f["folderName"] == "__smoke_account__") @@ -521,6 +632,133 @@ def test_request_budget_stops_the_run(self): self.assertLessEqual(len(self.server.requests), 10) +class ProblemWriteTest(SmokeTestBase): + + def test_register_sends_new_app_version_so_no_xp_accrues(self): + self.assertEqual(0, self.run_smoke(level="full")) + register = [r for r in self.server.requests if r[0] == "POST" and r[1] == "/api/problems/v2"] + self.assertEqual(1, len(register)) + _, _, headers, body = register[0] + self.assertEqual(smoke_test.SMOKE_APP_VERSION, headers.get("X-App-Version")) + self.assertTrue(body["memo"].startswith(smoke_test.RUN_PREFIX)) + self.assertEqual([], body["problemImageUrls"], "이미지를 붙이면 분석 요청과 S3 삭제 메시지가 생길 수 있다") + others = [r for r in self.server.requests if not (r[0] == "POST" and r[1] == "/api/problems/v2")] + self.assertTrue(all("X-App-Version" not in h for _, _, h, _ in others), "다른 요청의 동작은 바꾸지 않는다") + + def test_presigned_url_is_not_logged_when_shape_is_unexpected(self): + self.server.override("GET", "/api/fileUpload/presigned-urls", + ok([{"presignedUrl": "http://x/?X-Amz-Signature=secretsig"}])) + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.assertEqual(1, self.run_smoke(level="full", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output)) + for path in (summary, output): + with open(path, encoding="utf-8") as f: + self.assertNotIn("secretsig", f.read()) + self.assertIn(("POST", "/api/problems/v2"), self.server.paths(), "발급 실패는 등록 확인을 막지 않는다") + + def test_presigned_rate_limit_is_reported(self): + self.server.override("GET", "/api/fileUpload/presigned-urls", error(429, 2005)) + self.assertEqual(1, self.run_smoke(level="full")) + # 호출마다 카운터가 오르는 GET 이라 재시도하지 않는다. + self.assertEqual(1, self.server.paths().count(("GET", "/api/fileUpload/presigned-urls"))) + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_register_failure_stops_flow_without_leftovers(self): + self.server.override("POST", "/api/problems/v2", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(1, self.server.paths("POST").count(("POST", "/api/problems/v2")), "쓰기는 재시도하지 않는다") + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_delete_refused_is_retried_in_cleanup_and_then_by_next_run(self): + self.server.override("DELETE", "/api/problems", error(400, 4002), times=2) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, self.server.paths("DELETE").count(("DELETE", "/api/problems")), "흐름의 삭제 한 번, 마무리 정리 한 번") + self.assertEqual(2, len(self.server.live_problem_memos(SMOKE_USER))) + + self.assertEqual(0, self.run_smoke(level="full"), "다음 실행이 흔적을 지운다") + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_never_deletes_other_problems(self): + self.server.add_problem(SMOKE_USER, "smoke 가 들어가지만 흔적 이름은 아님") + self.assertEqual(0, self.run_smoke(level="full")) + deleted = [i for m, p, _, b in self.server.requests if m == "DELETE" and p == "/api/problems" + for i in b["deleteProblemIdList"]] + kept = [pid for pid, pr in self.server.users[SMOKE_USER]["problems"].items() + if not pr["memo"].startswith(smoke_test.RUN_PREFIX)] + self.assertFalse(set(deleted) & set(kept)) + + +class PreSwitchCheckTest(SmokeTestBase): + """ci-prod.yml 이 nginx 전환 직전에 부르는 pre_switch_check.sh. docker 는 가짜 명령으로 바꿔 끼운다.""" + + HEALTH_UP = ('{"status":"UP","components":{"db":{"status":"UP","details":{"database":"MySQL","validationQuery":"isValid()"}},' + '"diskSpace":{"status":"UP","details":{"total":994662584320,"free":123456789}},' + '"rabbit":{"status":"UP","details":{"version":"3.13.7"}},"redis":{"status":"UP","details":{"version":"7.4.1"}}}}') + + def run_check(self, health_exit=0, health_body=HEALTH_UP, user_id=SMOKE_USER, fake_python_exit=None): + script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pre_switch_check.sh") + with tempfile.TemporaryDirectory() as d: + docker = os.path.join(d, "docker") + body = os.path.join(d, "health.json") + with open(body, "w", encoding="utf-8") as f: + f.write(health_body) + with open(docker, "w", encoding="utf-8") as f: + f.write(f'#!/bin/sh\necho "$@" >> "{d}/calls"\ncat "{body}"\nexit {health_exit}\n') + os.chmod(docker, 0o755) + port = self.server.url.rsplit(":", 1)[1] + self.server.now = int(time.time()) + env = {k: v for k, v in os.environ.items() if not k.startswith(("SMOKE_", "GITHUB_"))} + env.update(DOCKER=docker, SMOKE_ACCESS_TOKEN_SECRET=SERVER_SECRET, SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET) + if fake_python_exit is not None: + # 버전 확인에서 실패하는 python3 를 PATH 맨 앞에 둔다. 3.9 보다 오래된 python3 가 있는 러너와 같다. + with open(os.path.join(d, "python3"), "w", encoding="utf-8") as f: + f.write(f"#!/bin/sh\nexit {fake_python_exit}\n") + os.chmod(os.path.join(d, "python3"), 0o755) + env["PATH"] = d + os.pathsep + env.get("PATH", "") + if user_id: + env["SMOKE_USER_ID"] = user_id + done = subprocess.run(["bash", script, "ono-app-prod-green", port], env=env, + capture_output=True, text=True, timeout=120) + with open(os.path.join(d, "calls"), encoding="utf-8") as f: + calls = f.read() + return done, calls + + def test_passes_and_runs_read_level_against_the_new_port(self): + done, calls = self.run_check() + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("exec ono-app-prod-green wget", calls) + self.assertIn("db UP, diskSpace UP, rabbit UP, redis UP", done.stdout) + self.assertIn("(단계: read)", done.stdout) + self.assertEqual(set(), {m for m, _ in self.server.paths()} - {"GET", "POST"}) + self.assertEqual(["/api/auth/refresh"], [p for m, p in self.server.paths() if m == "POST"]) + + def test_does_not_print_health_details(self): + done, _ = self.run_check() + for secret_ish in ("994662584320", "3.13.7", "isValid()"): + self.assertNotIn(secret_ish, done.stdout + done.stderr) + + def test_fails_without_smoke_when_health_is_down(self): + done, _ = self.run_check(health_exit=1, health_body='{"status":"DOWN"}') + self.assertNotEqual(0, done.returncode) + self.assertEqual([], self.server.requests, "health 가 실패하면 앱에 요청을 보내지 않는다") + + def test_fails_when_smoke_fails(self): + done, _ = self.run_check(user_id=REAL_USER) + self.assertNotEqual(0, done.returncode) + self.assertIn("스모크 테스트 실패", done.stdout) + + def test_skips_smoke_with_warning_when_python_is_too_old(self): + done, _ = self.run_check(fake_python_exit=1) + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("::warning::", done.stdout) + self.assertEqual([], self.server.requests, "스모크를 건너뛰었으니 앱에 요청하지 않는다") + + def test_passes_when_health_details_are_hidden(self): + done, _ = self.run_check(health_body='{"status":"UP"}') + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("show-details 가 꺼져", done.stdout) + + class ClientGuardTest(SmokeTestBase): def client(self, **kwargs): @@ -529,7 +767,9 @@ def client(self, **kwargs): def test_blocks_requests_outside_allowlist_before_sending(self): client = self.client(token="Bearer x", allow_writes=True) for method, path in [("GET", "/api/users"), ("PATCH", "/api/users"), ("DELETE", "/api/users"), - ("POST", "/api/auth/logout"), ("POST", "/api/problems/v2"), ("GET", "/actuator/health")]: + ("POST", "/api/auth/logout"), ("POST", "/api/problems"), ("DELETE", "/api/problems/all"), + ("POST", "/api/problems/1/analysis"), ("POST", "/api/fileUpload/image"), + ("GET", "/actuator/health")]: with self.assertRaises(smoke_test.SmokeAbort): client.request(method, path) self.assertEqual([], self.server.requests) @@ -539,6 +779,27 @@ def test_blocks_writes_unless_full_level(self): self.client(token="Bearer x").request("POST", "/api/folders", {"folderName": "x"}) self.assertEqual([], self.server.requests) + def test_refresh_is_the_only_write_method_allowed_below_full(self): + client = self.client() + probe = smoke_test.mint_refresh_token(REFRESH_SECRET, NOW) + self.assertEqual(401, client.request("POST", smoke_test.REFRESH_PATH, {"refreshToken": probe}, auth=False).status) + for method, path in [("POST", "/api/problems/v2"), ("DELETE", "/api/problems"), + ("GET", "/api/fileUpload/presigned-urls")]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request(method, path, {}) + self.assertEqual(1, len(self.server.requests)) + + def test_refresh_only_accepts_probe_tokens(self): + # 서버에 저장된 진짜 토큰을 보내면 회전(쓰기)이 일어난다. 실제 사용자로 서명된 토큰, 형식이 다른 값, 인증 헤더를 막는다. + real_user_token = smoke_test._sign(REFRESH_SECRET, {"authority": "ROLE_GUEST", "sub": SMOKE_USER, + "iat": NOW, "exp": NOW + 60}) + client = self.client(token="Bearer x", allow_writes=True) + for body, auth in [({"refreshToken": real_user_token}, False), ({"refreshToken": "refresh-42"}, False), + ({}, False), ({"refreshToken": smoke_test.mint_refresh_token(REFRESH_SECRET, NOW)}, True)]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request("POST", smoke_test.REFRESH_PATH, body, auth=auth) + self.assertEqual([], self.server.requests) + def test_blocks_signup_outside_bootstrap(self): with self.assertRaises(smoke_test.SmokeAbort): self.client(allow_writes=True).request("POST", smoke_test.SIGNUP_PATH) @@ -553,6 +814,9 @@ def test_config_errors(self): self.assertEqual(2, smoke_test.run(self.env(SMOKE_LEVEL="everything"), sleep=self.sleeps.append)) self.assertEqual(2, smoke_test.run(self.env(SMOKE_USER_ID="abc"), sleep=self.sleeps.append)) self.assertEqual(2, smoke_test.run(self.env(SMOKE_ACCESS_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_REFRESH_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run({"SMOKE_BASE_URL": "http://ono-dev.seungminki.shop", + "SMOKE_REFRESH_TOKEN_SECRET": REFRESH_SECRET}, sleep=self.sleeps.append)) self.assertEqual([], self.server.requests) From f9d5a0cbc8b4b7572112c72643832e02720262de Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 17:05:36 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[Feat]=20prod=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EA=B0=80=20nginx=20=EC=A0=84=ED=99=98=20=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=83=88=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - healthy 확인과 nginx 전환 사이에서 pre_switch_check.sh 를 부르고, 실패하면 새 컨테이너를 이름으로 지우고 배포를 끝낸다 - 새 컨테이너가 남으면 다음 배포가 현재 색을 잘못 읽을 수 있어서, 지우지 못하면 에러로 남긴다 - 배포를 끝내지 못하면 prod-latest 를 이전 색이 돌리던 이미지로 되돌린다 - 돌고 있던 앱이 없던 복구 배포는 확인이 실패해도 경고만 하고 전환한다 - refresh 서명 키는 서버와 같은지 아직 확인되지 않아 전환 전 확인에는 넘기지 않는다 --- .github/workflows/ci-prod.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 49f80516..bda78f20 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -115,6 +115,13 @@ jobs: shell: bash - name: Blue-Green Deployment + # 전환 전 확인(scripts/smoke/pre_switch_check.sh)이 스모크 계정 토큰을 만들 때 쓴다. + # 서버가 실제로 쓰는 서명 키는 APPLICATION_PROD 안의 jwt.*.secret 이다. 컨테이너에 넘기는 JWT_*_TOKEN_SECRET 환경변수는 + # Spring 이 jwt.accessToken.secret 으로 읽지 않는다. access 키는 배포 뒤 스모크 full 이 통과해서 같은 값인 것이 확인됐고, + # refresh 키는 아직 확인되지 않아서 여기에는 넘기지 않는다. 배포 뒤 smoke-test.yml 이 refresh 까지 보고 알린다. + env: + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" @@ -158,6 +165,19 @@ jobs: echo "Current active environment: $CURRENT (port $CURRENT_PORT)" echo "Deploying to target environment: $TARGET (port $TARGET_PORT)" + # 배포를 끝내지 못하면 prod-latest 를 지금 사용자를 받는 이미지로 되돌린다. 위에서 이미 새 이미지로 옮겼고, + # 그대로 두면 누가 docker-compose up 을 다시 할 때 거절된 빌드가 올라간다. + PREV_IMAGE="" + if [ "$CURRENT" != "none" ]; then + PREV_IMAGE=$(docker inspect --format '{{.Image}}' "ono-app-prod-$CURRENT" 2>/dev/null || true) + fi + restore_latest_tag() { + if [ -n "$PREV_IMAGE" ]; then + $DOCKER tag "$PREV_IMAGE" ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest \ + || echo "::warning::prod-latest 를 이전 이미지로 되돌리지 못했습니다" + fi + } + if [ "$TARGET" = "green" ]; then docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile green up -d app-prod-green else @@ -183,9 +203,29 @@ jobs: if [ "$healthy" = false ]; then echo "ERROR: $TARGET environment failed to become healthy within $timeout seconds" docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET + restore_latest_tag exit 1 fi + # 전환 전 확인. 실패하면 nginx 를 건드리지 않았으니 이전 색이 계속 사용자를 받는다. + if ! bash ./scripts/smoke/pre_switch_check.sh "ono-app-prod-$TARGET" "$TARGET_PORT"; then + if [ "$CURRENT" = "none" ]; then + # 돌고 있는 앱이 없던 복구 배포다. 새 컨테이너를 내려도 나아지는 것이 없고 복구 수단만 막히니 경고만 하고 이어 간다. + echo "::warning::전환 전 확인이 실패했지만 돌고 있던 앱이 없어 그대로 전환합니다. 스모크 결과를 확인해야 합니다" + else + echo "ERROR: $TARGET environment failed the pre-switch check. Nginx stays on $CURRENT" + docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET + # 새 컨테이너는 이름으로 지운다. 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽어, + # 사용자를 받고 있는 색을 다시 만들 수 있다. + docker rm -f "ono-app-prod-$TARGET" || true + if [ -n "$(docker ps -aq --filter "name=^ono-app-prod-$TARGET\$")" ]; then + echo "::error::ono-app-prod-$TARGET 를 지우지 못했습니다. 다음 배포 전에 직접 지워야 합니다. 남아 있으면 다음 배포가 현재 색을 잘못 판단합니다" + fi + restore_latest_tag + exit 1 + fi + fi + echo "Switching Nginx to $TARGET environment (port $TARGET_PORT)..." sudo /opt/ono/scripts/switch-nginx.sh $TARGET_PORT From db69153831c2994e059fe6015a49ecaef3bfc08b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 17:37:29 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[Chore]=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8F=89=EA=B7=A0=20=EC=9D=91=EB=8B=B5=EC=8B=9C?= =?UTF-8?q?=EA=B0=84,=20=EC=99=B8=EB=B6=80=20API=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EB=A5=A0,=20Redis=20=ED=9E=88=ED=8A=B8=EC=9C=A8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EC=9D=98=20=EA=B0=92=EA=B3=BC=20=EC=83=89=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 평균 응답시간은 분모를 clamp_min(…, 1e-9) 에서 > 0 으로 바꾼다. 앱이 재시작되면 같은 라벨의 요청 수는 그대로인데 시간 합만 늘어나는 경우가 있어서, 요청 수 증가량 0 을 1e-9 로 바꿔 나누면 1,250만 ms 같은 값이 나왔다. 이제 그 URI 는 값을 비운다 - 외부 API 성공률은 호출이 없을 때 "최근 1시간 호출 없음" 이 임계값 기준색(빨강) 위에 떴다. null 매핑으로 회색을 준다 - Redis 히트율은 인증 요청마다 로그아웃 토큰과 탈퇴 사용자 블랙리스트를 조회하고 두 키 모두 대부분 없는 것이 정상이라 5% 안팎이 평소 값이다. 경고색을 없애고 패널 설명에 이유를 적는다 Closes #338 --- monitoring/grafana/dashboards/ono-backend-overview.json | 4 ++-- .../grafana/dashboards/ono-dependency-overview.json | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/monitoring/grafana/dashboards/ono-backend-overview.json b/monitoring/grafana/dashboards/ono-backend-overview.json index 90a48b86..faccbb37 100644 --- a/monitoring/grafana/dashboards/ono-backend-overview.json +++ b/monitoring/grafana/dashboards/ono-backend-overview.json @@ -564,14 +564,14 @@ }, "targets": [ { - "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri=~\"/api.*\"}[1h])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])), 1e-9)))", + "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri=~\"/api.*\"}[1h])) / (sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])) > 0)))", "legendFormat": "{{method}} {{uri}}", "refId": "A" } ], "title": "API 평균 응답시간 (URI Top20, 1h)", "type": "timeseries", - "description": "URI 별 평균. 느린 API 를 찾는 용도다." + "description": "URI 별 평균. 느린 API 를 찾는 용도다. 최근 1시간 요청 수가 늘지 않은 URI 는 값을 비운다. 앱이 재시작되면 요청 수는 그대로인데 시간 합만 늘어나는 경우가 있어서, 0 대신 1e-9 로 나누면 수백만 ms 가 나온다." }, { "datasource": { diff --git a/monitoring/grafana/dashboards/ono-dependency-overview.json b/monitoring/grafana/dashboards/ono-dependency-overview.json index 2e1bd0ae..ee284255 100644 --- a/monitoring/grafana/dashboards/ono-dependency-overview.json +++ b/monitoring/grafana/dashboards/ono-dependency-overview.json @@ -211,9 +211,7 @@ "thresholds": { "mode": "absolute", "steps": [ - {"color": "red", "value": null}, - {"color": "orange", "value": 0.6}, - {"color": "green", "value": 0.9} + {"color": "blue", "value": null} ] }, "unit": "percentunit" @@ -243,6 +241,7 @@ "refId": "A" } ], + "description": "Redis 조회 중 키가 있었던 비율. 인증이 필요한 요청마다 로그아웃 토큰 블랙리스트와 탈퇴 사용자 블랙리스트를 조회하는데(RedisTokenService.isBlacklisted, isUserBlacklisted), 두 키 모두 대부분 없는 것이 정상이라 이 값은 낮게 나온다. 낮다고 장애가 아니라서 경고색을 두지 않는다.", "title": "Redis Hit Ratio", "type": "stat" }, @@ -366,6 +365,9 @@ {"color": "green", "value": 0.99} ] }, + "mappings": [ + {"type": "special", "options": {"match": "null", "result": {"text": "최근 1시간 호출 없음", "color": "#808080", "index": 0}}} + ], "noValue": "최근 1시간 호출 없음", "unit": "percentunit" }, From dc6abfebb34049c39c030317adcd4f80e9b7303c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 18:22:34 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[Fix]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=B4=2030=EB=B6=84=20=EB=A7=8C?= =?UTF-8?q?=EC=97=90=20=ED=92=80=EB=A6=AC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=A5=BC=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 성공 시 세션 유휴 만료를 24시간으로 늘린다 (설정이 없어 톰캣 기본값 30분이 적용되고 있었다) - 로그인 유지 쿠키를 붙여 배포로 컨테이너가 바뀌어도 24시간 동안 다시 로그인하지 않는다 - logoutUrl 에 전체 URL 이 들어가 어떤 요청과도 맞지 않던 설정을 경로로 고친다 - 로그인이 풀린 채 관리자 화면을 열면 JSON 401 대신 로그인 화면으로 보낸다 - 관리자 화면의 유저 삭제 엔드포인트를 없앤다 --- .../admin/controller/AdminUserController.java | 7 ---- .../backend/auth/config/SecurityConfig.java | 31 +++++++++++++++-- .../auth/CustomAuthenticationEntryPoint.java | 18 ++++++++++ .../controller/AdminUserControllerTest.java | 34 +++++-------------- .../AdminEndpointAuthorizationTest.java | 21 ++++++++---- 5 files changed, 69 insertions(+), 42 deletions(-) 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 b2eebcb0..f75f5500 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 @@ -14,7 +14,6 @@ import com.aisip.OnO.backend.user.service.UserService; 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; @@ -114,10 +113,4 @@ public String updateUserLevel( userService.updateUserLevel(userId, levelType, levelValue, pointValue); return "success"; } - - @ResponseStatus(HttpStatus.OK) - @DeleteMapping("/user/{userId}") - public void deleteUserInfo(@PathVariable(name = "userId") Long userId) { - userService.deleteUserById(userId); - } } 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 44f9d1b3..0743777e 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 @@ -36,9 +36,21 @@ @RequiredArgsConstructor public class SecurityConfig { + /** 관리자 로그인 유지 시간. 세션 유휴 만료와 로그인 유지 쿠키 수명을 같은 값으로 맞춘다. */ + private static final int ADMIN_LOGIN_TTL_SECONDS = (int) java.time.Duration.ofHours(24).toSeconds(); + + private static final String ADMIN_REMEMBER_ME_COOKIE = "ONO_ADMIN_REMEMBER"; + @Value("${spring.site.url}") private String siteUrl; + /** + * 로그인 유지 쿠키의 서명 키. 재시작해도 같아야 배포 뒤에 로그인이 풀리지 않으므로 + * 설정 파일에 이미 있는 비밀값에서 만든다. 액세스 토큰 서명 키와 섞이지 않게 접두사를 붙인다. + */ + @Value("${jwt.accessToken.secret}") + private String accessTokenSecret; + public final JwtTokenFilter jwtTokenFilter; private final JwtTokenizer jwtTokenizer; @@ -126,6 +138,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 접두사를 포함한 계약이므로 createAccessToken 쪽은 그대로 둔다. String token = jwtTokenizer.createAccessToken(String.valueOf(adminId), Map.of("authority", Authority.ROLE_ADMIN)); response.setHeader("Authorization", token); + // 톰캣 기본 세션 유휴 만료는 30분이라 화면을 잠깐 켜 두기만 해도 로그인이 풀렸다. + request.getSession().setMaxInactiveInterval(ADMIN_LOGIN_TTL_SECONDS); response.sendRedirect(siteUrl + "/admin/main"); // 성공 후 관리자 페이지로 이동 }) .failureHandler((request, response, exception) -> { @@ -133,9 +147,22 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti }) .permitAll() ) + // 세션은 메모리에만 있어서 배포로 컨테이너가 바뀌면 사라진다. + // 로그인 유지 쿠키가 있으면 새 컨테이너에서도 다시 로그인하지 않고 이어서 쓸 수 있다. + .rememberMe(rememberMe -> rememberMe + .key("ono-admin-remember-me:" + accessTokenSecret) + .rememberMeCookieName(ADMIN_REMEMBER_ME_COOKIE) + .tokenValiditySeconds(ADMIN_LOGIN_TTL_SECONDS) + .alwaysRemember(true) + .useSecureCookie(siteUrl.startsWith("https")) + ) .logout(logout -> logout - .logoutUrl(siteUrl + "/logout") - .logoutSuccessUrl(siteUrl + "/login?logout") + // logoutUrl 은 요청 경로와 비교하는 값이다. 전체 URL 을 넣으면 어떤 요청과도 + // 맞지 않아서 로그아웃 버튼을 눌러도 세션이 그대로 남았다. + .logoutUrl("/logout") + .deleteCookies("JSESSIONID", ADMIN_REMEMBER_ME_COOKIE) + .logoutSuccessHandler((request, response, authentication) -> + response.sendRedirect(siteUrl + "/login?logout")) .permitAll() ) .sessionManagement(sessionManagement -> 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 cdaa2df1..a5cd66ec 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 @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -54,6 +55,9 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint private final ObjectMapper objectMapper; + @Value("${spring.site.url}") + private String siteUrl; + @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { @@ -61,6 +65,13 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A return; } + // 관리자 화면은 브라우저가 연다. 로그인이 풀린 채 새로고침하면 JSON 401 이 그대로 보여서 + // 다시 로그인할 방법이 안 보였다. 화면 요청(GET)만 로그인 화면으로 보내고 나머지는 401 을 유지한다. + if (isAdminPageRequest(request)) { + response.sendRedirect(siteUrl + "/login"); + return; + } + ErrorCase errorCase = resolveErrorCase(request); response.setContentType("application/json;charset=UTF-8"); @@ -68,6 +79,13 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A objectMapper.writeValue(response.getWriter(), CommonResponse.error(errorCase)); } + private boolean isAdminPageRequest(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + return "GET".equalsIgnoreCase(request.getMethod()) + && requestURI != null + && (requestURI.equals("/admin") || requestURI.startsWith("/admin/")); + } + private boolean isPublicPath(String requestURI) { if (requestURI == null) { return false; diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java index 31bb741a..29dad037 100644 --- a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java @@ -346,35 +346,17 @@ void rejectsMissingParameters() throws Exception { class DeleteUser { @Test - @DisplayName("사용자를 삭제하면 200을 주고 조회되지 않는다") - void deletesUser() throws Exception { + @DisplayName("관리자 화면에서는 사용자를 삭제할 수 없고 데이터가 그대로 남는다") + void cannotDeleteUserFromAdmin() throws Exception { User target = fixtures.createUser(); - mockMvc.perform(delete("/admin/user/{userId}", target.getId())) - .andExpect(status().isOk()); - - assertThat(userRepository.findById(target.getId())) - .as("소프트 삭제 후에도 조회되면 탈퇴가 동작하지 않은 것이다") - .isEmpty(); - } - - @Test - @DisplayName("삭제해도 다른 사용자는 남는다") - void keepsOtherUsers() throws Exception { - User target = fixtures.createUser(); - User other = fixtures.createOtherUser(); + int status = mockMvc.perform(delete("/admin/user/{userId}", target.getId())) + .andReturn().getResponse().getStatus(); - mockMvc.perform(delete("/admin/user/{userId}", target.getId())) - .andExpect(status().isOk()); - - assertThat(userRepository.findById(other.getId())).isPresent(); - } - - @Test - @DisplayName("없는 사용자를 삭제하면 404로 응답한다") - void returnsNotFoundForUnknownUser() throws Exception { - mockMvc.perform(delete("/admin/user/{userId}", 999_999L)) - .andExpect(status().isNotFound()); + assertThat(status) + .as("삭제 엔드포인트를 없앴으므로 성공 응답이 나가면 안 된다") + .isNotEqualTo(200); + assertThat(userRepository.findById(target.getId())).isPresent(); } } } diff --git a/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java b/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java index 6d632889..1728f5de 100644 --- a/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java +++ b/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java @@ -54,7 +54,6 @@ static Stream adminEndpoints() { new Object[]{HttpMethod.GET, "/admin/user/999999"}, new Object[]{HttpMethod.POST, "/admin/user/999999"}, new Object[]{HttpMethod.POST, "/admin/user/999999/level?levelType=attendance&levelValue=3&pointValue=5"}, - new Object[]{HttpMethod.DELETE, "/admin/user/999999"}, // AdminProblemController new Object[]{HttpMethod.GET, "/admin/problems"}, new Object[]{HttpMethod.GET, "/admin/problem/999999"}, @@ -119,15 +118,23 @@ void guestIsForbidden(HttpMethod method, String uri) throws Exception { @DisplayName("인증 없는 요청") class AsAnonymous { - @ParameterizedTest(name = "{0} {1} 는 비인증 요청에 401") + @ParameterizedTest(name = "{0} {1} 는 비인증 요청을 막는다") @MethodSource("com.aisip.OnO.backend.admin.security.AdminEndpointAuthorizationTest#adminEndpoints") - @DisplayName("토큰 없이 관리자 엔드포인트를 호출하면 401") - void anonymousIsUnauthorized(HttpMethod method, String uri) throws Exception { + @DisplayName("로그인 없이 관리자 화면을 열면 로그인 화면으로 보내고, 화면이 아닌 요청은 401 이다") + void anonymousIsBlocked(HttpMethod method, String uri) throws Exception { clearAuthentication(); - assertThat(mockMvc.perform(call(method, uri)).andReturn().getResponse().getStatus()) - .as("%s %s", method, uri) - .isEqualTo(401); + MvcResult result = mockMvc.perform(call(method, uri)).andReturn(); + + if (method == HttpMethod.GET) { + assertThat(result.getResponse().getStatus()).as("%s %s", method, uri).isEqualTo(302); + assertThat(result.getResponse().getRedirectedUrl()).as("%s %s", method, uri).endsWith("/login"); + } else { + assertThat(result.getResponse().getStatus()).as("%s %s", method, uri).isEqualTo(401); + } + assertThat(result.getResponse().getContentAsString()) + .as("차단됐다면 관리자 화면(HTML)이 렌더링돼서는 안 된다") + .doesNotContain(" Date: Mon, 21 Sep 2026 19:07:59 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[Feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B3=B5=ED=86=B5=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9D=84=20=EB=A7=8C=EB=93=A4=EA=B3=A0=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EA=B3=B5=EC=A7=80,=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온보딩 페이지의 브랜드 초록(#1cba1a)과 회색 바탕 위 흰 카드 구성으로 공통 스타일을 만든다 - 사이드바, 페이지 이동, 탭 밑줄, 숫자 카운트업, 화면 전환 효과를 공통 조각과 스크립트로 둔다 - 페이지 링크가 지금 걸린 필터를 유지하도록 AdminPager 를 추가한다 - 공지는 실사용자 전원에게 뜨므로 걸기 전과 내리기 전에 한 번 더 확인받는다 --- .../controller/AdminFeedbackController.java | 4 + .../OnO/backend/admin/dto/AdminPager.java | 68 ++ .../admin/dto/AdminStudyRoomDetailDto.java | 100 --- .../admin/dto/AdminStudyRoomSummaryDto.java | 28 - src/main/resources/static/css/admin.css | 769 ++++++++++++++++++ src/main/resources/static/js/admin.js | 161 ++++ .../templates/admin-feedback-detail.html | 207 ++--- .../resources/templates/admin-feedback.html | 185 ++--- .../resources/templates/admin-notice.html | 192 +++-- .../templates/fragments/admin-layout.html | 80 ++ src/main/resources/templates/login.html | 138 +--- 11 files changed, 1316 insertions(+), 616 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminPager.java delete mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java delete mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java create mode 100644 src/main/resources/static/css/admin.css create mode 100644 src/main/resources/static/js/admin.js create mode 100644 src/main/resources/templates/fragments/admin-layout.html diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java index b19cde46..ef7211d9 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java @@ -1,7 +1,9 @@ package com.aisip.OnO.backend.admin.controller; +import com.aisip.OnO.backend.admin.dto.AdminPager; import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto; import com.aisip.OnO.backend.feedback.service.FeedbackService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; @@ -22,6 +24,7 @@ public class AdminFeedbackController { public String feedbackList( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + HttpServletRequest request, Model model ) { // 쿼리 파라미터는 사용자 입력이다. page 가 음수이거나 size 가 0 이하면 @@ -38,6 +41,7 @@ public String feedbackList( model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", pageResult.getTotalPages()); model.addAttribute("size", selectedSize); + model.addAttribute("pager", AdminPager.of(request, "page", selectedPage, selectedSize, pageResult.getTotalElements())); int blockSize = 10; int blockStart = (selectedPage / blockSize) * blockSize; diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPager.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPager.java new file mode 100644 index 00000000..22ebab8f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPager.java @@ -0,0 +1,68 @@ +package com.aisip.OnO.backend.admin.dto; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +/** + * 관리자 목록 화면의 페이지 이동 정보. + * + *

링크는 지금 요청의 쿼리스트링을 그대로 두고 페이지 번호만 바꿔서 만든다. + * 그래서 날짜나 유저 필터가 걸린 목록에서 다음 페이지로 넘어가도 필터가 풀리지 않는다. + * 화면에 보이는 번호는 1부터, 쿼리 파라미터는 0부터다. + */ +public record AdminPager( + int page, + int size, + int totalPages, + long totalElements, + long startItem, + long endItem, + String previousUrl, + String nextUrl, + List links +) { + + private static final int BLOCK_SIZE = 10; + + public record Link(int number, String url, boolean current) { + } + + public static AdminPager of( + HttpServletRequest request, + String pageParam, + int page, + int size, + long totalElements + ) { + int totalPages = size <= 0 ? 0 : (int) Math.ceil((double) totalElements / size); + long startItem = totalElements == 0 || page >= totalPages ? 0 : (long) page * size + 1; + long endItem = startItem == 0 ? 0 : Math.min((long) (page + 1) * size, totalElements); + + int blockStart = (page / BLOCK_SIZE) * BLOCK_SIZE; + int blockEnd = Math.min(blockStart + BLOCK_SIZE - 1, Math.max(totalPages - 1, 0)); + + List links = new ArrayList<>(); + for (int i = blockStart; i <= blockEnd && i < totalPages; i++) { + links.add(new Link(i + 1, urlFor(request, pageParam, i), i == page)); + } + + String previousUrl = page > 0 && totalPages > 0 + ? urlFor(request, pageParam, Math.min(page - 1, totalPages - 1)) + : null; + String nextUrl = page < totalPages - 1 ? urlFor(request, pageParam, page + 1) : null; + + return new AdminPager(page, size, totalPages, totalElements, startItem, endItem, previousUrl, nextUrl, links); + } + + private static String urlFor(HttpServletRequest request, String pageParam, int page) { + String query = request.getQueryString(); + return UriComponentsBuilder + .fromUriString(request.getRequestURI() + (query == null ? "" : "?" + query)) + .replaceQueryParam(pageParam, page) + .build() + .toUriString(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java deleted file mode 100644 index 98027eb9..00000000 --- a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.aisip.OnO.backend.admin.dto; - -import com.aisip.OnO.backend.studyroom.entity.StudyRoom; -import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; -import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallengeStatus; -import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@Builder -public class AdminStudyRoomDetailDto { - - private Long id; - private String name; - private Long hostUserId; - private LocalDateTime createdAt; - private long sharedProblemCount; - private List members; - private List challenges; - - @Getter - @Builder - public static class MemberInfo { - private Long userId; - private String userName; - private String role; // 방장 / 멤버 - private LocalDateTime joinedAt; - } - - @Getter - @Builder - public static class ChallengeInfo { - private Long id; - private String title; - private String type; // 개인 / 그룹 / 연속 - private String metric; - private String status; // 진행 중 / 완료 / 실패 / 만료 - private Integer targetValue; - private LocalDateTime startAt; - private LocalDateTime endAt; - private LocalDateTime completedAt; - private boolean isActive; - } - - public static AdminStudyRoomDetailDto from(StudyRoom room, - List members, - List challenges, - long sharedProblemCount) { - return AdminStudyRoomDetailDto.builder() - .id(room.getId()) - .name(room.getName()) - .hostUserId(room.getHostUserId()) - .createdAt(room.getCreatedAt()) - .sharedProblemCount(sharedProblemCount) - .members(members.stream() - .map(m -> MemberInfo.builder() - .userId(m.getUser().getId()) - .userName(m.getUser().getName()) - .role(m.getRole().name().equals("HOST") ? "방장" : "멤버") - .joinedAt(m.getCreatedAt()) - .build()) - .toList()) - .challenges(challenges.stream() - .map(c -> ChallengeInfo.builder() - .id(c.getId()) - .title(c.getTitle()) - .type(translateType(c)) - .metric(c.getMetric().name()) - .status(translateStatus(c.getStatus())) - .targetValue(c.getTargetValue()) - .startAt(c.getStartAt()) - .endAt(c.getEndAt()) - .completedAt(c.getCompletedAt()) - .isActive(c.getStatus() == StudyRoomChallengeStatus.IN_PROGRESS) - .build()) - .toList()) - .build(); - } - - private static String translateType(StudyRoomChallenge c) { - return switch (c.getType()) { - case INDIVIDUAL -> "개인"; - case GROUP -> "그룹"; - case STREAK -> "연속"; - }; - } - - private static String translateStatus(StudyRoomChallengeStatus s) { - return switch (s) { - case IN_PROGRESS -> "진행 중"; - case COMPLETED -> "완료"; - case FAILED -> "실패"; - case EXPIRED -> "만료"; - }; - } -} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java deleted file mode 100644 index 61fdc99e..00000000 --- a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.aisip.OnO.backend.admin.dto; - -import com.aisip.OnO.backend.studyroom.entity.StudyRoom; -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@Builder -public class AdminStudyRoomSummaryDto { - - private Long id; - private String name; - private long memberCount; - private long sharedProblemCount; - private LocalDateTime createdAt; - - public static AdminStudyRoomSummaryDto from(StudyRoom room, long memberCount, long sharedProblemCount) { - return AdminStudyRoomSummaryDto.builder() - .id(room.getId()) - .name(room.getName()) - .memberCount(memberCount) - .sharedProblemCount(sharedProblemCount) - .createdAt(room.getCreatedAt()) - .build(); - } -} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 00000000..3a2bc20e --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,769 @@ +/* + * OnO 관리자 화면 공통 스타일. + * 색은 온보딩 페이지(home.html)의 브랜드 초록을 기준으로 잡았고, 화면 구성은 + * 회색 바탕 위에 흰 카드를 올리는 토스 콘솔 방식을 따른다. + */ + +:root { + --brand: #1cba1a; + --brand-strong: #139b11; + --brand-text: #108a0e; + --brand-weak: #e8f7e7; + --brand-weaker: #f3fbf2; + + --bg: #f2f4f6; + --surface: #ffffff; + --surface-2: #f9fafb; + --surface-3: #f2f4f6; + --line: #e5e8eb; + --line-strong: #d1d6db; + + --t1: #191f28; + --t2: #4e5968; + --t3: #8b95a1; + --t4: #b0b8c1; + + --red: #f04452; + --red-weak: #feeeef; + --blue: #3182f6; + --blue-weak: #e8f3ff; + --orange: #fe9800; + --orange-weak: #fff3e0; + --purple: #7c5cfa; + --purple-weak: #f1edff; + + --radius-l: 20px; + --radius-m: 14px; + --radius-s: 10px; + + --sidebar-w: 236px; + --ease: cubic-bezier(.2, .8, .2, 1); +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--t1); + font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", sans-serif; + font-size: 15px; + line-height: 1.5; + letter-spacing: -.01em; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +img { max-width: 100%; } +button, input, select, textarea { font: inherit; color: inherit; letter-spacing: inherit; } + +.num { font-variant-numeric: tabular-nums; } + +/* 페이지 전환: 같은 출처 안에서 이동할 때 브라우저가 이전 화면과 새 화면을 겹쳐서 부드럽게 바꾼다. */ +@view-transition { navigation: auto; } +::view-transition-old(root) { animation: vt-out .16s var(--ease) both; } +::view-transition-new(root) { animation: vt-in .24s var(--ease) both; } +@keyframes vt-out { to { opacity: 0; } } +@keyframes vt-in { from { opacity: 0; transform: translateY(6px); } } +.sidebar { view-transition-name: sidebar; } +::view-transition-old(sidebar), ::view-transition-new(sidebar) { animation: none; } + +/* ---------- 레이아웃 ---------- */ + +.shell { min-height: 100vh; } + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + padding: 22px 14px 18px; + z-index: 20; +} + +.brand { + display: flex; + align-items: baseline; + gap: 6px; + padding: 0 10px 22px; +} +.brand-mark { + font-size: 22px; + font-weight: 800; + letter-spacing: -.04em; + color: var(--t1); +} +.brand-mark b { color: var(--brand); font-weight: 800; } +.brand-sub { font-size: 13px; color: var(--t3); font-weight: 600; } + +.nav { flex: 1; overflow-y: auto; } +.nav-group + .nav-group { margin-top: 18px; } +.nav-label { + padding: 0 10px 6px; + font-size: 12px; + font-weight: 600; + color: var(--t4); +} +.nav a { + display: flex; + align-items: center; + justify-content: space-between; + height: 38px; + padding: 0 10px; + border-radius: var(--radius-s); + font-size: 15px; + font-weight: 500; + color: var(--t2); + transition: background .15s, color .15s, transform .12s var(--ease); +} +.nav a:hover { background: var(--surface-3); color: var(--t1); } +.nav a:active { transform: scale(.98); } +.nav a.active { background: var(--brand-weak); color: var(--brand-text); font-weight: 700; } + +.sidebar-foot { padding-top: 12px; border-top: 1px solid var(--line); } +.sidebar-foot form { margin: 0; } +.logout { + width: 100%; + height: 38px; + border: 0; + background: transparent; + border-radius: var(--radius-s); + color: var(--t3); + font-size: 14px; + font-weight: 500; + text-align: left; + padding: 0 10px; + cursor: pointer; + transition: background .15s, color .15s; +} +.logout:hover { background: var(--surface-3); color: var(--t1); } + +.main { + margin-left: var(--sidebar-w); + padding: 36px 40px 80px; + max-width: calc(var(--sidebar-w) + 1280px); +} + +/* ---------- 페이지 머리 ---------- */ + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} +.page-head h1 { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -.03em; + line-height: 1.3; +} +.page-head .desc { margin: 6px 0 0; color: var(--t3); font-size: 15px; } +.crumb { + display: inline-flex; + align-items: center; + gap: 4px; + margin-bottom: 10px; + color: var(--t3); + font-size: 14px; + font-weight: 500; + transition: color .15s; +} +.crumb:hover { color: var(--t1); } +.crumb::before { content: "‹"; font-size: 18px; line-height: 1; margin-top: -2px; } + +.section { margin-top: 32px; } +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.section-head h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.02em; } +.section-head .hint { color: var(--t3); font-size: 13px; } + +/* ---------- 카드 ---------- */ + +.card { + background: var(--surface); + border-radius: var(--radius-l); + padding: 24px; +} +.card + .card { margin-top: 12px; } +.card-title { + margin: 0 0 16px; + font-size: 17px; + font-weight: 700; + letter-spacing: -.02em; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.card-title small { font-size: 13px; font-weight: 500; color: var(--t3); } +.card.flush { padding: 0; overflow: hidden; } +.card.flush > .card-title { padding: 22px 24px 0; } + +.grid { display: grid; gap: 12px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.span-2 { grid-column: span 2; } + +/* ---------- 지표 ---------- */ + +.kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} +.kpi { + display: block; + background: var(--surface); + border-radius: var(--radius-l); + padding: 20px 22px; + transition: transform .15s var(--ease), background .15s; +} +a.kpi:hover { background: var(--surface-2); } +a.kpi:active { transform: scale(.98); } +.kpi-label { font-size: 14px; color: var(--t3); font-weight: 500; } +.kpi-value { + margin-top: 6px; + font-size: 28px; + font-weight: 700; + letter-spacing: -.03em; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.kpi-value .unit { font-size: 17px; font-weight: 600; color: var(--t2); margin-left: 2px; } +.kpi-sub { margin-top: 6px; font-size: 13px; color: var(--t3); } +.kpi-sub .up { color: var(--brand-text); font-weight: 600; } +.kpi-sub .down { color: var(--red); font-weight: 600; } + +/* 카드 안에 넣는 작은 지표 묶음 */ +.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 20px 16px; } +.stat-label { font-size: 13px; color: var(--t3); } +.stat-value { margin-top: 2px; font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.stat-value small { font-size: 13px; font-weight: 500; color: var(--t3); margin-left: 3px; } + +/* ---------- 목록형 행 ---------- */ + +.rows { display: flex; flex-direction: column; } +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + min-height: 48px; +} +.row + .row { border-top: 1px solid var(--surface-3); } +a.row { margin: 0 -12px; padding: 12px; border-radius: var(--radius-s); transition: background .12s; } +a.row + a.row { border-top: 0; } +a.row:hover { background: var(--surface-2); } +.row-main { min-width: 0; } +.row-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-sub { font-size: 13px; color: var(--t3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-value { flex: none; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--t2); } + +.kv { display: grid; grid-template-columns: 120px 1fr; gap: 12px 16px; margin: 0; } +.kv dt { color: var(--t3); font-size: 14px; } +.kv dd { margin: 0; font-weight: 500; word-break: break-all; } + +/* ---------- 표 ---------- */ + +.table-wrap { overflow-x: auto; } +table.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.table th { + text-align: left; + font-weight: 500; + color: var(--t3); + font-size: 13px; + padding: 12px 16px; + white-space: nowrap; + border-bottom: 1px solid var(--line); + background: var(--surface); +} +.table td { + padding: 13px 16px; + border-bottom: 1px solid var(--surface-3); + vertical-align: middle; +} +.table tbody tr:last-child td { border-bottom: 0; } +.table tbody tr { transition: background .12s; } +.table tbody tr[data-href] { cursor: pointer; } +.table tbody tr[data-href]:hover { background: var(--surface-2); } +.table .r { text-align: right; font-variant-numeric: tabular-nums; } +.table .c { text-align: center; } +.table .nowrap { white-space: nowrap; } +.table .muted { color: var(--t3); } +.table .ellipsis { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.table tfoot td { font-weight: 700; border-top: 1px solid var(--line); border-bottom: 0; background: var(--surface-2); } +.table a.link { color: var(--t1); font-weight: 600; } +.table a.link:hover { color: var(--brand-text); } +.table a.cell-link { display: inline-block; min-width: 28px; font-weight: 600; color: var(--t1); border-radius: 6px; padding: 2px 6px; margin: -2px -6px; transition: background .12s, color .12s; } +.table a.cell-link:hover { background: var(--brand-weak); color: var(--brand-text); } +.table .zero { color: var(--t4); } + +.who { display: flex; flex-direction: column; min-width: 0; } +.who b { font-weight: 600; } +.who span { font-size: 12px; color: var(--t3); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---------- 칩, 배지 ---------- */ + +.chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + background: var(--surface-3); + color: var(--t2); +} +.chip.green { background: var(--brand-weak); color: var(--brand-text); } +.chip.red { background: var(--red-weak); color: var(--red); } +.chip.blue { background: var(--blue-weak); color: var(--blue); } +.chip.orange { background: var(--orange-weak); color: #c76b00; } +.chip.purple { background: var(--purple-weak); color: var(--purple); } + +.level { + display: inline-flex; + align-items: baseline; + gap: 4px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.level small { font-weight: 500; font-size: 12px; color: var(--t3); } + +/* ---------- 버튼, 입력 ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 40px; + padding: 0 16px; + border: 0; + border-radius: var(--radius-s); + background: var(--surface-3); + color: var(--t1); + font-size: 14px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background .15s, transform .12s var(--ease), color .15s; +} +.btn:hover { background: #e5e8eb; } +.btn:active { transform: scale(.97); } +.btn.primary { background: var(--brand); color: #fff; } +.btn.primary:hover { background: var(--brand-strong); } +.btn.danger { background: var(--red-weak); color: var(--red); } +.btn.danger:hover { background: #fcdadd; } +.btn.small { height: 32px; padding: 0 12px; font-size: 13px; border-radius: 8px; } +.btn[disabled] { opacity: .4; pointer-events: none; } + +.field { display: flex; flex-direction: column; gap: 6px; } +.field label { font-size: 13px; font-weight: 500; color: var(--t2); } +.input, .select, .textarea { + height: 40px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-s); + background: var(--surface); + font-size: 14px; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 110px; line-height: 1.6; } +.input:focus, .select:focus, .textarea:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-weak); } +.select { padding-right: 32px; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b95a1' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; } + +.toolbar { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} +.toolbar .grow { flex: 1; min-width: 200px; } +.toolbar form { display: contents; } + +/* 필터가 걸려 있을 때 보여 주는 줄 */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + font-size: 14px; + color: var(--t2); +} +.filter-bar .chip { height: 28px; padding: 0 12px; font-size: 13px; } +.filter-bar a.clear { color: var(--t3); font-size: 13px; text-decoration: underline; text-underline-offset: 3px; } + +/* ---------- 세그먼트, 탭 ---------- */ + +.seg { + position: relative; + display: inline-flex; + padding: 3px; + background: var(--surface-3); + border-radius: 12px; + gap: 2px; +} +.seg a, .seg button { + position: relative; + z-index: 1; + height: 34px; + padding: 0 14px; + border: 0; + background: transparent; + border-radius: 9px; + font-size: 14px; + font-weight: 600; + color: var(--t3); + display: inline-flex; + align-items: center; + cursor: pointer; + transition: color .2s; + white-space: nowrap; +} +.seg a:hover, .seg button:hover { color: var(--t1); } +.seg .on { color: var(--t1); } +.seg-thumb { + position: absolute; + z-index: 0; + top: 3px; + bottom: 3px; + left: 0; + border-radius: 9px; + background: var(--surface); + box-shadow: 0 1px 3px rgba(0, 0, 0, .08); + transition: transform .28s var(--ease), width .28s var(--ease); +} + +.tabs { + position: relative; + display: flex; + gap: 4px; + border-bottom: 1px solid var(--line); + margin-bottom: 20px; + overflow-x: auto; + scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tabs button { + position: relative; + flex: none; + height: 46px; + padding: 0 14px; + border: 0; + background: transparent; + font-size: 15px; + font-weight: 600; + color: var(--t3); + cursor: pointer; + transition: color .2s; +} +.tabs button:hover { color: var(--t1); } +.tabs button.on { color: var(--t1); } +.tabs button .count { margin-left: 4px; color: var(--t4); font-weight: 500; font-variant-numeric: tabular-nums; } +.tabs button.on .count { color: var(--brand-text); } +.tabs-ink { + position: absolute; + bottom: -1px; + left: 0; + height: 2px; + background: var(--t1); + border-radius: 2px; + transition: transform .28s var(--ease), width .28s var(--ease); +} +.tab-panel[hidden] { display: none; } +.tab-panel.entering { animation: panel-in .26s var(--ease) both; } +@keyframes panel-in { from { opacity: 0; transform: translateY(6px); } } + +/* ---------- 페이지네이션 ---------- */ + +.pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-top: 1px solid var(--surface-3); + flex-wrap: wrap; +} +.pager-info { font-size: 13px; color: var(--t3); } +.pager-pages { display: flex; gap: 2px; } +.pager-pages a, .pager-pages span { + min-width: 34px; + height: 34px; + padding: 0 8px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + font-size: 14px; + font-weight: 600; + color: var(--t2); + font-variant-numeric: tabular-nums; + transition: background .12s, color .12s; +} +.pager-pages a:hover { background: var(--surface-3); color: var(--t1); } +.pager-pages .on { background: var(--t1); color: #fff; } +.pager-pages .off { color: var(--t4); } + +/* ---------- 막대, 분포 ---------- */ + +.bars { display: flex; flex-direction: column; gap: 12px; } +.bar-row { display: grid; grid-template-columns: minmax(80px, 140px) 1fr 64px; align-items: center; gap: 12px; font-size: 14px; } +.bar-row .name { color: var(--t2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bar-row .val { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; } +.bar-track { height: 10px; border-radius: 999px; background: var(--surface-3); overflow: hidden; } +.bar-fill { + height: 100%; + border-radius: 999px; + background: var(--brand); + transform-origin: left center; + animation: bar-grow .7s var(--ease) both; +} +.bar-fill.blue { background: var(--blue); } +.bar-fill.red { background: var(--red); } +.bar-fill.orange { background: var(--orange); } +.bar-fill.purple { background: var(--purple); } +.bar-fill.gray { background: var(--t4); } +@keyframes bar-grow { from { transform: scaleX(0); } } + +/* 한 줄 비율 막대 (정답, 오답, 부분 정답처럼 합이 100% 인 것) */ +.stack { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--surface-3); } +.stack span { height: 100%; } +.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 13px; color: var(--t2); } +.legend i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; } + +.chart-box { position: relative; height: 260px; } +.chart-box.tall { height: 320px; } + +/* ---------- 이미지, 아이템 ---------- */ + +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } +.thumb { + display: block; + border-radius: var(--radius-m); + overflow: hidden; + background: var(--surface-3); + aspect-ratio: 3 / 4; + transition: transform .15s var(--ease); +} +.thumb:hover { transform: translateY(-2px); } +.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.thumb-cap { font-size: 12px; color: var(--t3); margin-top: 6px; } + +.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } +.item { + background: var(--surface); + border-radius: var(--radius-m); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--line); +} +.item.inactive { opacity: .5; } +.item-img { + aspect-ratio: 1 / 1; + border-radius: 10px; + background: var(--surface-3); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.item-img img { width: 100%; height: 100%; object-fit: contain; } +.item-img .noimg { font-size: 12px; color: var(--t4); } +.item-name { font-weight: 600; font-size: 14px; line-height: 1.35; } +.item-meta { font-size: 12px; color: var(--t3); } + +.medal { + display: flex; + gap: 14px; + align-items: center; +} +.medal-img { + flex: none; + width: 52px; + height: 52px; + border-radius: 14px; + background: var(--surface-3); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: var(--t3); +} + +/* ---------- 본문 텍스트 ---------- */ + +.prose { font-size: 15px; line-height: 1.75; color: var(--t2); white-space: pre-wrap; word-break: break-word; } +.prose-label { font-size: 13px; font-weight: 600; color: var(--t3); margin: 18px 0 6px; } +.prose-label:first-child { margin-top: 0; } +.quote { + background: var(--surface-2); + border-radius: var(--radius-m); + padding: 14px 16px; + font-size: 14px; + line-height: 1.7; + color: var(--t2); + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- 알림, 빈 상태 ---------- */ + +.notice-box { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 14px 16px; + border-radius: var(--radius-m); + font-size: 14px; + font-weight: 500; + margin-bottom: 16px; +} +.notice-box.ok { background: var(--brand-weak); color: var(--brand-text); } +.notice-box.err { background: var(--red-weak); color: var(--red); } +.notice-box.info { background: var(--surface-3); color: var(--t2); } + +.empty { + padding: 48px 16px; + text-align: center; + color: var(--t3); + font-size: 14px; +} + +.muted { color: var(--t3); } +.small { font-size: 13px; } +.strong { font-weight: 700; } +.green { color: var(--brand-text); } +.red { color: var(--red); } +.mt-0 { margin-top: 0; } +.mt-12 { margin-top: 12px; } +.mt-20 { margin-top: 20px; } +.gap-8 { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + +/* ---------- 모달 ---------- */ + +.modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, .36); + opacity: 0; + pointer-events: none; + transition: opacity .2s; +} +.modal.open { opacity: 1; pointer-events: auto; } +.modal-sheet { + width: 100%; + max-width: 380px; + background: var(--surface); + border-radius: 24px; + padding: 26px 24px 20px; + transform: translateY(16px) scale(.98); + transition: transform .28s var(--ease); +} +.modal.open .modal-sheet { transform: none; } +.modal-sheet h3 { margin: 0 0 18px; font-size: 20px; font-weight: 700; letter-spacing: -.02em; } +.modal-actions { display: flex; gap: 8px; margin-top: 22px; } +.modal-actions .btn { flex: 1; height: 48px; font-size: 15px; border-radius: 14px; } + +.toast { + position: fixed; + left: 50%; + bottom: 32px; + z-index: 60; + transform: translate(-50%, 20px); + opacity: 0; + padding: 14px 20px; + border-radius: 14px; + background: rgba(25, 31, 40, .92); + color: #fff; + font-size: 14px; + font-weight: 600; + transition: opacity .2s, transform .3s var(--ease); + pointer-events: none; +} +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +/* ---------- 등장 효과 ---------- */ + +.reveal { animation: reveal .42s var(--ease) both; animation-delay: calc(var(--i, 0) * 40ms); } +@keyframes reveal { from { opacity: 0; transform: translateY(10px); } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0ms !important; transition-duration: .01ms !important; } + @view-transition { navigation: none; } +} + +/* ---------- 좁은 화면 ---------- */ + +@media (max-width: 1100px) { + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 860px) { + .sidebar { + position: sticky; + top: 0; + inset: auto; + width: auto; + height: auto; + flex-direction: row; + align-items: center; + padding: 10px 12px; + border-right: 0; + border-bottom: 1px solid var(--line); + gap: 8px; + } + .brand { padding: 0 6px 0 4px; flex: none; } + .brand-sub { display: none; } + .nav { display: flex; overflow-x: auto; scrollbar-width: none; gap: 2px; } + .nav::-webkit-scrollbar { display: none; } + .nav-group { display: contents; } + .nav-label { display: none; } + .nav a { flex: none; height: 34px; font-size: 14px; } + .sidebar-foot { border-top: 0; padding-top: 0; flex: none; } + .logout { width: auto; height: 34px; font-size: 13px; } + .main { margin-left: 0; padding: 22px 16px 60px; } + .grid-2, .grid-3, .grid-4 { grid-template-columns: minmax(0, 1fr); } + .span-2 { grid-column: auto; } + .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kpi { padding: 16px; } + .kpi-value { font-size: 22px; } + .page-head h1 { font-size: 22px; } + .card { padding: 20px 16px; } + .kv { grid-template-columns: 96px 1fr; } + .bar-row { grid-template-columns: 90px 1fr 48px; } +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js new file mode 100644 index 00000000..14a92be1 --- /dev/null +++ b/src/main/resources/static/js/admin.js @@ -0,0 +1,161 @@ +// OnO 관리자 화면 공통 동작. 프레임워크 없이 서버가 그린 HTML 위에 움직임만 얹는다. +(function () { + 'use strict'; + + var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // 행 전체를 눌러 상세로 이동한다. 행 안의 링크나 버튼을 누르면 그쪽이 우선이다. + function bindRowLinks() { + document.addEventListener('click', function (e) { + var row = e.target.closest('tr[data-href]'); + if (!row || e.target.closest('a, button, input, select, label')) return; + var href = row.getAttribute('data-href'); + if (e.metaKey || e.ctrlKey) { + window.open(href, '_blank'); + } else { + window.location.href = href; + } + }); + } + + // 카드와 표가 위에서부터 차례로 올라오게 한다. + function reveal() { + if (reduceMotion) return; + var targets = document.querySelectorAll('[data-reveal] > *'); + targets.forEach(function (el, i) { + el.style.setProperty('--i', Math.min(i, 12)); + el.classList.add('reveal'); + }); + } + + // 숫자는 0 에서 목표값까지 짧게 올라간다. 소수점 자릿수는 원래 글자를 따른다. + function countUp() { + var els = document.querySelectorAll('[data-count]'); + els.forEach(function (el) { + var raw = el.textContent.trim().replace(/,/g, ''); + var target = parseFloat(raw); + if (!isFinite(target) || reduceMotion || target === 0) return; + var decimals = (raw.split('.')[1] || '').length; + var start = null; + var duration = 700; + var format = function (v) { + return v.toLocaleString('ko-KR', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); + }; + el.textContent = format(0); + function step(ts) { + if (start === null) start = ts; + var p = Math.min((ts - start) / duration, 1); + var eased = 1 - Math.pow(1 - p, 3); + el.textContent = format(target * eased); + if (p < 1) requestAnimationFrame(step); + else el.textContent = format(target); + } + requestAnimationFrame(step); + }); + } + + // 밑줄이 선택된 탭 아래로 미끄러진다. 주소의 #hash 로 탭을 기억해서 새로고침해도 유지된다. + function bindTabs() { + document.querySelectorAll('[data-tabs]').forEach(function (tabs) { + var buttons = tabs.querySelectorAll('button[data-tab]'); + var ink = document.createElement('span'); + ink.className = 'tabs-ink'; + tabs.appendChild(ink); + + function moveInk(btn) { + ink.style.width = btn.offsetWidth + 'px'; + ink.style.transform = 'translateX(' + btn.offsetLeft + 'px)'; + } + + function select(name, animate) { + var found = false; + buttons.forEach(function (b) { + var on = b.getAttribute('data-tab') === name; + b.classList.toggle('on', on); + if (on) { moveInk(b); found = true; } + }); + if (!found) return false; + document.querySelectorAll('[data-panel]').forEach(function (p) { + var on = p.getAttribute('data-panel') === name; + p.hidden = !on; + p.classList.remove('entering'); + if (on && animate && !reduceMotion) { + void p.offsetWidth; + p.classList.add('entering'); + } + }); + return true; + } + + buttons.forEach(function (b) { + b.addEventListener('click', function () { + var name = b.getAttribute('data-tab'); + select(name, true); + history.replaceState(null, '', '#' + name); + }); + }); + + var initial = location.hash.replace('#', ''); + if (!initial || !select(initial, false)) { + select(buttons[0].getAttribute('data-tab'), false); + } + window.addEventListener('resize', function () { + var on = tabs.querySelector('button.on'); + if (on) moveInk(on); + }); + }); + } + + // 세그먼트 컨트롤의 흰 배경이 선택된 칸으로 움직인다. + function bindSegments() { + document.querySelectorAll('.seg').forEach(function (seg) { + var on = seg.querySelector('.on'); + if (!on) return; + var thumb = document.createElement('span'); + thumb.className = 'seg-thumb'; + seg.insertBefore(thumb, seg.firstChild); + function place(el) { + thumb.style.width = el.offsetWidth + 'px'; + thumb.style.transform = 'translateX(' + el.offsetLeft + 'px)'; + } + place(on); + seg.querySelectorAll('a, button').forEach(function (el) { + el.addEventListener('click', function () { + seg.querySelectorAll('.on').forEach(function (x) { x.classList.remove('on'); }); + el.classList.add('on'); + place(el); + }); + }); + }); + } + + window.OnoAdmin = { + toast: function (message) { + var t = document.createElement('div'); + t.className = 'toast'; + t.textContent = message; + document.body.appendChild(t); + requestAnimationFrame(function () { t.classList.add('show'); }); + setTimeout(function () { + t.classList.remove('show'); + setTimeout(function () { t.remove(); }, 300); + }, 2200); + }, + openModal: function (id) { document.getElementById(id).classList.add('open'); }, + closeModal: function (id) { document.getElementById(id).classList.remove('open'); } + }; + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + document.querySelectorAll('.modal.open').forEach(function (m) { m.classList.remove('open'); }); + } + }); + + document.addEventListener('DOMContentLoaded', function () { + bindRowLinks(); + reveal(); + countUp(); + bindTabs(); + bindSegments(); + }); +})(); diff --git a/src/main/resources/templates/admin-feedback-detail.html b/src/main/resources/templates/admin-feedback-detail.html index 300e2f5e..eec7b195 100644 --- a/src/main/resources/templates/admin-feedback-detail.html +++ b/src/main/resources/templates/admin-feedback-detail.html @@ -1,168 +1,77 @@ - - - - OnO 관리자 - 피드백 상세 - - - - -

- -
-
- - - - - + + +
+ +
+
-

피드백 상세

-

- #1 · - - - · IP: - -

+ 피드백 +

응답 #1

+

제출

-
- - -
-

1. 만족도 & 사용 패턴

-
-
-
사용 목적
-
-
-
-
-
사용 빈도
-
-
-
-
-
NPS 점수
-
-
-
-
+
+
+
추천 점수
+
+ 9/ 10 +
- - -
-

2. 오답 등록 & 관리

-
-
-
등록·조회 불편 사항
-
-
-
-
-
분류 방식
-
-
-
-
-
템플릿 적합도
-
-
-
-
+
+
템플릿 만족도
+
4/ 5
- - -
-

3. 복습 세트

-
-
-
복습 세트 사용 여부
-
-
-
-
-
알림 효과
-
-
-
-
-
미사용 이유
-
-
-
-
+
+
챌린지 동기부여
+
4/ 5
+
+
문제 공유 유용성
+
4/ 5
+
+
- -
-

4. 소셜 & 스터디룸

-
-
-
스터디룸 사용 여부
-
-
-
-
-
미사용 이유
-
-
-
-
-
챌린지 동기 부여
-
-
-
-
-
오답 공유 유용도
-
-
-
+
+
+

사용 방식

+
+
사용 목적
-
+
사용 빈도
-
+
자주 쓰는 기능
-
+
분류 방식
-
+
등록할 때 불편
-
- - -
-

5. 개선 요청 & 자유 의견

-
-
-
자주 쓰는 기능
-
-
-
-
-
없애고 싶은 불편함
-
-
-
-
-
원하는 기능
-
-
-
+
+

복습과 스터디룸

+
+
복습 알림 효과
-
+
복습노트
+
+ 응답 없음 + 사용 중 + 사용하지 않음 +
+
안 쓰는 이유
-
+
스터디룸
-
+
안 쓰는 이유
-
-
-
- - - - - 목록으로 돌아가기 - +
+

자유 의견

+
불편한 점
+
-
+
바라는 기능
+
-
+

응답 IP

-
+
+
+ diff --git a/src/main/resources/templates/admin-feedback.html b/src/main/resources/templates/admin-feedback.html index b52199af..fefb7207 100644 --- a/src/main/resources/templates/admin-feedback.html +++ b/src/main/resources/templates/admin-feedback.html @@ -1,153 +1,66 @@ - - - - OnO 관리자 - 유저 피드백 - - - - - -
- -
-

유저 피드백

-

총 0건의 응답

-
- - -
-
-
- - - -
-
-

총 응답 수

-

0

-
+
+
+
전체 응답
+
0건
-
-
- - - -
-
-

평균 NPS

-

- - - 응답 없음 - / 10 -

+
+
평균 추천 점수
+
+ 0.0/ 10
+
-
- -
-
- - - - - - - - - - - +
+
+
ID제출일시사용 목적빈도NPS자주 쓰는 기능
+ + + + + + + + + - - - - - - - - - - - - - + + + + + + + + +
제출추천 점수사용 목적사용 빈도자주 쓰는 기능불편한 점
1--- - - - - - - - 상세 보기 -
아직 피드백이 없습니다
2026.09.21 12:00 + 9 + - + 수능주 1~2회스터디룸-
+
아직 받은 피드백이 없어요.
+
- - -
-
- 페이지 1 / 1 -
-
- 이전 - 이전 - - << - - - - 1 - - - 1 - - - - >> - - 다음 - 다음 -
-
-
+ +
+ diff --git a/src/main/resources/templates/admin-notice.html b/src/main/resources/templates/admin-notice.html index 70034e62..5e53c0bc 100644 --- a/src/main/resources/templates/admin-notice.html +++ b/src/main/resources/templates/admin-notice.html @@ -1,121 +1,113 @@ - - - - - OnO 관리자 - 공지 관리 - - - - -
-
-

공지 관리

-

등록한 공지는 유저가 앱 메인에 들어올 때 팝업으로 보입니다. 동시에 한 건만 노출됩니다.

+
+
등록했어요
+
오류
-
-
- - -
-

현재 노출 중인 공지

- -
- 지금 노출 중인 공지가 없습니다. +
+
+

지금 걸려 있는 공지

+
걸려 있는 공지가 없어요.
+
+
+ 안내 + 까지 +
+

제목

+
내용
+
+ +
+
-
-
-
-
제목
-
+
+

새 공지 걸려 있는 공지는 자동으로 내려가요

+
+
+ +
-
-
내용
-
+
+ +
-
-
유형
-
+
+
+ + +
+
+ + +
-
-
노출 종료
-
+
+
-
- - -
+ +
- -
-

새 공지 등록

-

이미 노출 중인 공지가 있으면 자동으로 내려가고 새 공지로 바뀝니다.

- -
-
- - -
- -
- - -
- -
-
- - -
- -
- - -
-
+ - -
+ + + + diff --git a/src/main/resources/templates/fragments/admin-layout.html b/src/main/resources/templates/fragments/admin-layout.html new file mode 100644 index 00000000..02bad5c6 --- /dev/null +++ b/src/main/resources/templates/fragments/admin-layout.html @@ -0,0 +1,80 @@ + + + + + + + + + OnO 관리자 + + + + + + + + + + + + +
+ 1–20 / 총 100개 + 결과가 없어요 +
+ ‹ + ‹ + + 1 + 2 + + › + › +
+
+ + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 6072d2a6..990cb0e5 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -1,112 +1,44 @@ - + - OnO 관리자 로그인 - + + 로그인 | OnO 관리자 + + + - -
- -
-
- - - -
-

OnO 관리자

-

관리자 계정으로 로그인하세요

-
+ + - \ No newline at end of file + From f88ede0830bcab50e61d526f05fc15e708d0fd50 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 19:07:59 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[Feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=9C=A0=EC=A0=80,=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EA=B8=B0=EB=A1=9D,=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=EB=A3=B8,=20=EC=84=B1=EC=9E=A5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=EB=B3=BC=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저 목록에 검색과 가입 경로 필터, 정렬 7종을 넣고 상세를 치장, 업적, 미션, 스터디룸까지 탭 7개로 나눈다 - 복습 기록을 mission_log 대신 problem_solve 기준으로 바꾸고 복습노트 상세 화면을 추가한다 - 오답노트와 복습 기록, 복습노트 목록에 날짜와 유저, 상태 필터를 넣는다 - 스터디룸 상세에 챌린지, 공유 문제와 댓글, 피드, 주간 리포트를 추가한다 - 미션, 업적, 치장 현황 화면을 새로 만든다 - 통계를 사용자, 학습, AI 분석, 성장, 스터디룸으로 나누고 재방문율과 고착도, 직전 기간 비교를 넣는다 - 날짜별 표의 숫자를 누르면 그날 목록으로 이동한다 - 권한 테스트에 새 엔드포인트와 공지 엔드포인트를 넣는다 --- .../controller/AdminAnalysisController.java | 125 +-- .../admin/controller/AdminController.java | 13 +- .../controller/AdminGrowthController.java | 181 ++++ .../AdminPracticeNoteController.java | 174 +++- .../controller/AdminProblemController.java | 117 ++- .../controller/AdminStudyRoomController.java | 101 +- .../admin/controller/AdminUserController.java | 134 ++- .../backend/admin/dto/AdminGrowthViews.java | 125 +++ .../backend/admin/dto/AdminLearningDto.java | 244 +++++ .../OnO/backend/admin/dto/AdminStatsDto.java | 226 +++++ .../admin/dto/AdminStudyRoomViews.java | 154 +++ .../OnO/backend/admin/dto/AdminUserRows.java | 189 ++++ .../AdminGrowthQueryRepository.java | 275 ++++++ .../AdminLearningQueryRepository.java | 528 ++++++++++ .../repository/AdminStatsQueryRepository.java | 447 +++++++++ .../repository/AdminStudyRoomLabels.java | 119 +++ .../AdminStudyRoomQueryRepository.java | 346 +++++++ .../repository/AdminUserQueryRepository.java | 511 ++++++++++ .../admin/service/AdminStatsService.java | 241 +++++ .../templates/admin-achievements.html | 97 ++ .../resources/templates/admin-cosmetics.html | 112 +++ .../resources/templates/admin-missions.html | 142 +++ .../templates/admin-practice-note-detail.html | 133 +++ .../templates/admin-study-room-detail.html | 382 +++++--- .../templates/admin-study-rooms.html | 170 ++-- src/main/resources/templates/admin.html | 276 +++--- src/main/resources/templates/analysis.html | 923 ++++++++---------- src/main/resources/templates/daily-users.html | 147 +-- .../templates/fragments/admin-learning.html | 53 + .../templates/fragments/admin-stats.html | 19 + src/main/resources/templates/image.html | 44 +- .../resources/templates/practice-logs.html | 228 +++-- .../resources/templates/practice-notes.html | 204 ++-- src/main/resources/templates/problem.html | 428 ++++---- src/main/resources/templates/problems.html | 291 +++--- src/main/resources/templates/user.html | 805 +++++++-------- src/main/resources/templates/users.html | 293 +++--- .../AdminAnalysisControllerTest.java | 213 +++- .../admin/controller/AdminControllerTest.java | 52 +- .../controller/AdminGrowthControllerTest.java | 303 ++++++ .../AdminPracticeNoteControllerTest.java | 270 ++++- .../AdminProblemControllerTest.java | 243 ++++- .../AdminStudyRoomControllerTest.java | 200 +++- .../controller/AdminUserControllerTest.java | 349 ++++++- .../AdminEndpointAuthorizationTest.java | 17 +- 45 files changed, 7973 insertions(+), 2671 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/admin/controller/AdminGrowthController.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminGrowthViews.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminLearningDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStatsDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomViews.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserRows.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminGrowthQueryRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminLearningQueryRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminStatsQueryRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomLabels.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomQueryRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/repository/AdminUserQueryRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/service/AdminStatsService.java create mode 100644 src/main/resources/templates/admin-achievements.html create mode 100644 src/main/resources/templates/admin-cosmetics.html create mode 100644 src/main/resources/templates/admin-missions.html create mode 100644 src/main/resources/templates/admin-practice-note-detail.html create mode 100644 src/main/resources/templates/fragments/admin-learning.html create mode 100644 src/main/resources/templates/fragments/admin-stats.html create mode 100644 src/test/java/com/aisip/OnO/backend/admin/controller/AdminGrowthControllerTest.java 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 f55e1908..ddc000eb 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,9 +1,8 @@ package com.aisip.OnO.backend.admin.controller; +import com.aisip.OnO.backend.admin.repository.AdminStatsQueryRepository; +import com.aisip.OnO.backend.admin.service.AdminStatsService; 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; import com.aisip.OnO.backend.user.service.UserService; @@ -19,7 +18,6 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; @Slf4j @@ -29,9 +27,12 @@ public class AdminAnalysisController { private final UserService userService; - private final ProblemService problemService; private final MissionLogService missionLogService; - private final PracticeNoteService practiceNoteService; + private final AdminStatsService adminStatsService; + private final AdminStatsQueryRepository adminStatsQueryRepository; + + /** 한 번에 볼 수 있는 최대 기간. 날짜별 표와 차트가 한 화면에 들어가는 선이다. */ + private static final int MAX_RANGE_DAYS = 366; @GetMapping("/analysis") public String getAllAnalysis( @@ -41,13 +42,6 @@ public String getAllAnalysis( @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; @@ -57,90 +51,39 @@ public String getAllAnalysis( selectedStartDate = selectedEndDate; selectedEndDate = temp; } + if (ChronoUnit.DAYS.between(selectedStartDate, selectedEndDate) + 1 > MAX_RANGE_DAYS) { + selectedStartDate = selectedEndDate.minusDays(MAX_RANGE_DAYS - 1); + } + + AdminStatsService.Period period = adminStatsService.period(selectedStartDate, selectedEndDate); + AdminStatsService.Daily daily = adminStatsService.daily(selectedStartDate, selectedEndDate); - // 선택 기간 날짜별 출석 유저 수 및 신규 가입자 수 - 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(); - 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) - .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 periodRateLimitExceededAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 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("allRateLimitExceededAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 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("periodRateLimitExceededAnalysisCount", periodRateLimitExceededAnalysisCount); - model.addAttribute("periodAnalysisFailureRate", periodAnalysisFailureRate); - model.addAttribute("averageDailyVisitors", averageDailyVisitors); model.addAttribute("startDate", selectedStartDate); model.addAttribute("endDate", selectedEndDate); + model.addAttribute("days", period.days()); + model.addAttribute("previousStartDate", period.previous().start()); + model.addAttribute("previousEndDate", period.previous().end()); + model.addAttribute("today", today); model.addAttribute("quickStart7Days", today.minusDays(6)); model.addAttribute("quickStart30Days", today.minusDays(29)); model.addAttribute("quickStart90Days", today.minusDays(89)); - model.addAttribute("today", today); + model.addAttribute("quickStartMonth", today.withDayOfMonth(1)); + + model.addAttribute("dailyActiveUsers", daily.activeUsers()); + model.addAttribute("dailyNewUsers", daily.newUsers()); + model.addAttribute("dailyProblems", daily.problems()); + model.addAttribute("dailySolves", daily.solves()); + model.addAttribute("dailyPracticeNotes", daily.practiceNotes()); + model.addAttribute("dailyRows", daily.rowsNewestFirst()); + model.addAttribute("dailyTotals", daily.totals()); + + model.addAttribute("userStats", adminStatsService.userStats(period, daily, today)); + model.addAttribute("learningStats", adminStatsService.learningStats(period, daily)); + model.addAttribute("analysisStats", adminStatsService.analysisStats(period)); + model.addAttribute("growthStats", adminStatsService.growthStats(period, daily)); + model.addAttribute("roomStats", adminStatsService.roomStats(period)); + model.addAttribute("topProblemWriters", adminStatsQueryRepository.topProblemWriters(selectedStartDate, selectedEndDate, 10)); + model.addAttribute("topSolvers", adminStatsQueryRepository.topSolvers(selectedStartDate, selectedEndDate, 10)); return "analysis"; } diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminController.java index 8a9312af..4da4e594 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminController.java @@ -1,19 +1,30 @@ package com.aisip.OnO.backend.admin.controller; +import com.aisip.OnO.backend.admin.service.AdminStatsService; +import com.aisip.OnO.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @Slf4j @RequiredArgsConstructor @Controller @RequestMapping("/admin") public class AdminController { + private final AdminStatsService adminStatsService; + private final NoticeService noticeService; + @GetMapping("/main") - public String adminPage() { + public String adminPage(Model model) { + LocalDate today = LocalDate.now(); + model.addAttribute("today", today); + model.addAttribute("home", adminStatsService.home(today)); + model.addAttribute("activeNotice", noticeService.findActiveNoticeForAdmin()); return "admin"; } diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminGrowthController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminGrowthController.java new file mode 100644 index 00000000..878c9ad7 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminGrowthController.java @@ -0,0 +1,181 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.AchievementEarned; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.AchievementOverview; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.AchievementRow; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.CosmeticItemRow; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.CosmeticOverview; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.MissionDaily; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.MissionOverview; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.MissionRow; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.SetSummary; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.SlotGroup; +import com.aisip.OnO.backend.admin.repository.AdminGrowthQueryRepository; +import com.aisip.OnO.backend.admin.repository.AdminGrowthQueryRepository.AchievementKeyStat; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.mission.service.MissionPeriodKey; +import lombok.RequiredArgsConstructor; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 성장 요소(미션, 업적, 치장) 현황. 전부 읽기 전용이다. + */ +@Controller +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminGrowthController { + + private static final int RECENT_ACHIEVEMENT_LIMIT = 30; + private static final int MISSION_TREND_DAYS = 30; + + private final AdminGrowthQueryRepository growthQueryRepository; + + @GetMapping("/cosmetics") + public String cosmetics(@RequestParam(name = "slot", required = false) String slot, Model model) { + List items = growthQueryRepository.findCosmeticItems(); + + Map> bySlot = items.stream() + .collect(Collectors.groupingBy(CosmeticItemRow::slot, LinkedHashMap::new, Collectors.toList())); + + // enum 순서(그리는 층 순서)대로 늘어놓고, enum 에 없는 자리(예전 배포에서 지운 값)는 맨 뒤에 둔다. + List groups = new ArrayList<>(); + for (CosmeticSlot s : CosmeticSlot.values()) { + List slotItems = bySlot.remove(s.name()); + if (slotItems != null) { + groups.add(toGroup(s.name(), s.getNameKo(), slotItems)); + } + } + bySlot.forEach((name, slotItems) -> groups.add(toGroup(name, name, slotItems))); + + CosmeticItemRow top = items.stream() + .filter(i -> i.equippedUsers() > 0) + .max(Comparator.comparingLong(CosmeticItemRow::equippedUsers)) + .orElse(null); + + CosmeticOverview overview = new CosmeticOverview( + items.size(), + items.stream().filter(CosmeticItemRow::active).count(), + growthQueryRepository.countUsersWithLoadout(), + top == null ? null : top.nameKo(), + top == null ? 0 : top.equippedUsers() + ); + + List sets = items.stream() + .filter(i -> i.setId() != null) + .collect(Collectors.groupingBy(CosmeticItemRow::setId, LinkedHashMap::new, Collectors.toList())) + .entrySet().stream() + .map(e -> new SetSummary( + e.getKey(), + e.getValue().stream().map(CosmeticItemRow::setNameKo).filter(n -> n != null).findFirst().orElse(e.getKey()), + e.getValue().size(), + e.getValue().stream().mapToLong(CosmeticItemRow::equippedUsers).sum())) + .toList(); + + String selectedSlot = groups.stream().anyMatch(g -> g.slot().equals(slot)) ? slot : null; + + model.addAttribute("overview", overview); + model.addAttribute("groups", groups); + model.addAttribute("visibleGroups", selectedSlot == null + ? groups + : groups.stream().filter(g -> g.slot().equals(selectedSlot)).toList()); + model.addAttribute("selectedSlot", selectedSlot); + model.addAttribute("maxSlotEquipped", groups.stream().mapToLong(SlotGroup::equippedUsers).max().orElse(0)); + model.addAttribute("sets", sets); + return "admin-cosmetics"; + } + + @GetMapping("/achievements") + public String achievements(Model model) { + long totalUsers = growthQueryRepository.countLiveUsers(); + Map stats = growthQueryRepository.countAchievementsByKey(); + + List rows = Arrays.stream(Achievement.values()) + .map(a -> { + AchievementKeyStat stat = stats.get(a.getKey()); + long earned = stat == null ? 0 : stat.earnedUsers(); + return new AchievementRow( + a.getKey(), + a.getNameKo(), + a.getDescriptionKo(), + a.getThreshold(), + earned, + totalUsers == 0 ? 0 : (double) earned * 100 / totalUsers, + stat == null ? null : stat.lastEarnedAt()); + }) + .toList(); + + // 키 대신 이름을 보여 준다. 지금 enum 에 없는 키면 저장된 키를 그대로 둔다. + List recent = growthQueryRepository.findRecentAchievements(RECENT_ACHIEVEMENT_LIMIT).stream() + .map(e -> new AchievementEarned( + e.userId(), + e.userName(), + Achievement.fromKey(e.achievementName()).map(Achievement::getNameKo).orElse(e.achievementName()), + e.earnedAt())) + .toList(); + + LocalDateTime sevenDaysAgo = MissionPeriodKey.today().minusDays(6).atStartOfDay(); + model.addAttribute("overview", new AchievementOverview( + growthQueryRepository.countEarnedAchievements(), + growthQueryRepository.countUsersWithAchievement(), + growthQueryRepository.countAchievementsEarnedSince(sevenDaysAgo), + totalUsers)); + model.addAttribute("achievements", rows); + model.addAttribute("recent", recent); + return "admin-achievements"; + } + + @GetMapping("/missions") + public String missions(Model model) { + LocalDate today = MissionPeriodKey.today(); + String dailyKey = MissionPeriodKey.daily(today); + String weeklyKey = MissionPeriodKey.weekly(today); + + List missions = growthQueryRepository.findMissions(dailyKey, weeklyKey); + + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime tomorrowStart = today.plusDays(1).atStartOfDay(); + MissionOverview overview = new MissionOverview( + growthQueryRepository.countActiveMissions(), + growthQueryRepository.countMissionsCompletedBetween(todayStart, tomorrowStart), + growthQueryRepository.countMissionsClaimedBetween(todayStart, tomorrowStart), + growthQueryRepository.countMissionsClaimedBetween(today.minusDays(6).atStartOfDay(), tomorrowStart) + ); + + LocalDate trendStart = today.minusDays(MISSION_TREND_DAYS - 1L); + Map daily = growthQueryRepository.countMissionsDaily(trendStart, today); + List trend = new ArrayList<>(); + for (LocalDate d = today; !d.isBefore(trendStart); d = d.minusDays(1)) { + long[] v = daily.getOrDefault(d, new long[2]); + trend.add(new MissionDaily(d, v[0], v[1])); + } + + model.addAttribute("overview", overview); + model.addAttribute("dailyMissions", missions.stream().filter(m -> !"WEEKLY".equals(m.category())).toList()); + model.addAttribute("weeklyMissions", missions.stream().filter(m -> "WEEKLY".equals(m.category())).toList()); + model.addAttribute("dailyKey", dailyKey); + model.addAttribute("weeklyKey", weeklyKey); + model.addAttribute("trend", trend); + model.addAttribute("maxTrend", trend.stream().mapToLong(t -> Math.max(t.completed(), t.claimed())).max().orElse(0)); + model.addAttribute("legacyLogs", growthQueryRepository.countLegacyLogsSince(trendStart.atStartOfDay())); + return "admin-missions"; + } + + private SlotGroup toGroup(String slot, String label, List items) { + return new SlotGroup(slot, label, items, items.stream().mapToLong(CosmeticItemRow::equippedUsers).sum()); + } +} 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 index 87fbed99..11e8b0f4 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteController.java @@ -1,78 +1,180 @@ 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 com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteProblemRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.SolveRow; +import com.aisip.OnO.backend.admin.dto.AdminPager; +import com.aisip.OnO.backend.admin.repository.AdminLearningQueryRepository; +import com.aisip.OnO.backend.admin.repository.AdminLearningQueryRepository.Filter; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.entity.PracticeNotification; +import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @Slf4j @RequiredArgsConstructor @Controller @RequestMapping("/admin") public class AdminPracticeNoteController { - private final PracticeNoteService practiceNoteService; - private final MissionLogService missionLogService; + /** 복습노트 상세에서 보여 줄 최근 복습 기록 수. */ + private static final int RECENT_SOLVE_LIMIT = 50; + + private final AdminLearningQueryRepository learningQueryRepository; + private final PracticeNoteRepository practiceNoteRepository; @GetMapping("/practice-notes") public String getPracticeNotes( @RequestParam(defaultValue = "0", name = "notePage") int notePage, @RequestParam(defaultValue = "20", name = "size") int size, + @RequestParam(name = "date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(name = "userId", required = false) Long userId, + HttpServletRequest request, Model model ) { - int selectedNotePage = Math.max(notePage, 0); int selectedSize = Math.max(size, 1); + Filter filter = new Filter(date, userId, null); + + long total = learningQueryRepository.countNotes(filter); + int totalPages = (int) Math.ceil((double) total / selectedSize); + int selectedPage = clampPage(notePage, totalPages); - 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()); + List notes = withWeekDays( + learningQueryRepository.findNotes(filter, selectedPage * selectedSize, selectedSize)); + + model.addAttribute("practiceNotes", notes); + model.addAttribute("totalPracticeNotes", total); + model.addAttribute("notePage", selectedPage); + model.addAttribute("noteTotalPages", totalPages); model.addAttribute("size", selectedSize); + model.addAttribute("pager", AdminPager.of(request, "notePage", selectedPage, selectedSize, total)); + model.addAttribute("summary", learningQueryRepository.summarizeNotes()); + addFilterAttributes(model, date, userId, null); return "practice-notes"; } + @GetMapping("/practice-notes/{noteId}") + public String getPracticeNoteDetail(@PathVariable(name = "noteId") Long noteId, Model model) { + NoteRow note = learningQueryRepository.findNote(noteId) + .orElseThrow(() -> new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTE_NOT_FOUND)); + note = withWeekDays(List.of(note)).get(0); + + List problems = learningQueryRepository.findNoteProblems(noteId); + List problemIds = problems.stream().map(NoteProblemRow::problemId).toList(); + List recentSolves = note.userId() == null + ? List.of() + : learningQueryRepository.findRecentSolvesOfProblems(note.userId(), problemIds, RECENT_SOLVE_LIMIT); + + model.addAttribute("note", note); + model.addAttribute("problems", problems); + model.addAttribute("recentSolves", recentSolves); + + return "admin-practice-note-detail"; + } + + /** + * problem_solve 기반 복습 기록. + * + *

예전에는 mission_log 의 NOTE_PRACTICE 를 셌는데, 그 행은 복습노트를 처음 완료할 때 한 번만 + * 쌓여서 실제 복습 횟수보다 훨씬 적게 보였다. 사용자가 문제를 다시 풀 때마다 남기는 기록은 + * problem_solve 다. + */ @GetMapping("/practice-logs") public String getPracticeLogs( - @RequestParam(defaultValue = "0", name = "logPage") int logPage, + @RequestParam(defaultValue = "0", name = "page") int page, @RequestParam(defaultValue = "20", name = "size") int size, + @RequestParam(name = "date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(name = "userId", required = false) Long userId, + @RequestParam(name = "answerStatus", required = false) String answerStatus, + HttpServletRequest request, Model model ) { - int selectedLogPage = Math.max(logPage, 0); int selectedSize = Math.max(size, 1); + String selectedStatus = normalizeAnswerStatus(answerStatus); + Filter filter = new Filter(date, userId, selectedStatus); + + long total = learningQueryRepository.countSolves(filter); + int totalPages = (int) Math.ceil((double) total / selectedSize); + int selectedPage = clampPage(page, totalPages); - 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("solves", learningQueryRepository.findSolves(filter, selectedPage * selectedSize, selectedSize)); + model.addAttribute("totalSolves", total); + model.addAttribute("currentPage", selectedPage); + model.addAttribute("totalPages", totalPages); model.addAttribute("size", selectedSize); + model.addAttribute("pager", AdminPager.of(request, "page", selectedPage, selectedSize, total)); + model.addAttribute("summary", learningQueryRepository.summarizeSolves(filter)); + model.addAttribute("todaySolveCount", learningQueryRepository.countSolvesOn(LocalDate.now())); + model.addAttribute("answerStatuses", AnswerStatus.values()); + addFilterAttributes(model, date, userId, selectedStatus); return "practice-logs"; } + + private void addFilterAttributes(Model model, LocalDate date, Long userId, String status) { + model.addAttribute("filterDate", date); + model.addAttribute("filterUserId", userId); + model.addAttribute("filterUserName", userId == null ? null : learningQueryRepository.findUserName(userId)); + model.addAttribute("filterStatus", status); + model.addAttribute("filtered", date != null || userId != null || status != null); + } + + /** 범위를 넘은 page 로 빈 화면을 보여 주면 데이터가 사라진 줄 안다. 마지막 페이지로 되돌린다. */ + private int clampPage(int page, int totalPages) { + int selected = Math.max(page, 0); + return selected >= totalPages ? Math.max(totalPages - 1, 0) : selected; + } + + private String normalizeAnswerStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return Arrays.stream(AnswerStatus.values()) + .map(Enum::name) + .filter(name -> name.equalsIgnoreCase(status.trim())) + .findFirst() + .orElse(null); + } + + /** + * 알림 요일은 JPA 가 List 를 직렬화해 넣은 varbinary 라 SQL 로는 읽을 수 없다. + * 화면에 보이는 노트만 엔티티로 한 번에 읽어 요일을 붙인다. + */ + private List withWeekDays(List notes) { + if (notes.isEmpty()) { + return notes; + } + Map entities = practiceNoteRepository + .findAllById(notes.stream().map(NoteRow::noteId).toList()) + .stream() + .collect(Collectors.toMap(PracticeNote::getId, Function.identity())); + + return notes.stream() + .map(note -> { + PracticeNote entity = entities.get(note.noteId()); + PracticeNotification notification = entity == null ? null : entity.getPracticeNotification(); + return note.withWeekDays(notification == null ? null : notification.getWeekDays()); + }) + .toList(); + } } 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 54d6b4e0..a4a33e66 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,18 +1,20 @@ 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 com.aisip.OnO.backend.admin.dto.AdminLearningDto.ProblemDetail; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.ProblemRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.SolveRow; +import com.aisip.OnO.backend.admin.dto.AdminPager; +import com.aisip.OnO.backend.admin.repository.AdminLearningQueryRepository; +import com.aisip.OnO.backend.admin.repository.AdminLearningQueryRepository.Filter; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -20,6 +22,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.time.LocalDate; +import java.util.Arrays; import java.util.List; @Slf4j @@ -28,60 +32,93 @@ @RequestMapping("/admin") public class AdminProblemController { - private final ProblemService problemService; - private final UserService userService; - private final FolderService folderService; - private final ProblemSolveService problemSolveService; + private final AdminLearningQueryRepository learningQueryRepository; + private final ObjectMapper objectMapper; @GetMapping("/problems") public String getAllProblems( @RequestParam(defaultValue = "0", name = "page") int page, @RequestParam(defaultValue = "20", name = "size") int size, + @RequestParam(name = "date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(name = "userId", required = false) Long userId, + @RequestParam(name = "status", required = false) String status, + HttpServletRequest request, Model model ) { - int selectedPage = Math.max(page, 0); int selectedSize = Math.max(size, 1); - Page problemPage = problemService.findAdminProblems(selectedPage, selectedSize); - int totalPages = problemPage.getTotalPages(); + String selectedStatus = normalizeStatus(status); + Filter filter = new Filter(date, userId, selectedStatus); - if (totalPages > 0 && selectedPage >= totalPages) { - selectedPage = totalPages - 1; - problemPage = problemService.findAdminProblems(selectedPage, selectedSize); - totalPages = problemPage.getTotalPages(); + long total = learningQueryRepository.countProblems(filter); + int totalPages = (int) Math.ceil((double) total / selectedSize); + int selectedPage = Math.max(page, 0); + // 범위를 넘은 page 로 빈 화면을 보여 주면 데이터가 사라진 줄 안다. 마지막 페이지로 되돌린다. + if (selectedPage >= totalPages) { + selectedPage = Math.max(totalPages - 1, 0); } - int pageBlockStart = (selectedPage / 10) * 10; - int pageBlockEnd = Math.min(pageBlockStart + 9, Math.max(totalPages - 1, 0)); + List problems = learningQueryRepository.findProblems(filter, selectedPage * selectedSize, selectedSize); - model.addAttribute("problems", problemPage.getContent()); + model.addAttribute("problems", problems); + model.addAttribute("totalProblems", total); model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", totalPages); - 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); + model.addAttribute("pager", AdminPager.of(request, "page", selectedPage, selectedSize, total)); + model.addAttribute("summary", learningQueryRepository.summarizeProblems(LocalDate.now())); + model.addAttribute("statuses", AnalysisStatus.values()); + model.addAttribute("filterDate", date); + model.addAttribute("filterUserId", userId); + model.addAttribute("filterUserName", userId == null ? null : learningQueryRepository.findUserName(userId)); + model.addAttribute("filterStatus", selectedStatus); + model.addAttribute("filtered", date != null || userId != null || selectedStatus != null); return "problems"; } @GetMapping("/problem/{problemId}") public String getProblemDetail(@PathVariable(name = "problemId") Long problemId, Model model) { - ProblemResponseDto problem = problemService.findProblemForAdmin(problemId); - model.addAttribute("problem", problem); + ProblemDetail problem = learningQueryRepository.findProblemDetail(problemId) + .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); + List problemSolves = learningQueryRepository.findSolvesOfProblem(problemId); - // 폴더 및 작성자 정보 조회 - FolderResponseDto folder = folderService.findFolderForAdmin(problem.folderId()); - UserResponseDto user = userService.findUser(folder.userId()); - List problemSolves = problemSolveService.getAdminProblemSolvesByProblemId(problemId); - model.addAttribute("folder", folder); - model.addAttribute("user", user); + model.addAttribute("problem", problem); + model.addAttribute("keyPoints", parseKeyPoints(problem.keyPoints())); + model.addAttribute("images", learningQueryRepository.findProblemImages(problemId)); + model.addAttribute("tags", learningQueryRepository.findProblemTags(problemId)); + model.addAttribute("practiceNotes", learningQueryRepository.findNotesContainingProblem(problemId)); + model.addAttribute("sharedRooms", learningQueryRepository.findRoomsSharingProblem(problemId)); model.addAttribute("problemSolves", problemSolves); model.addAttribute("problemSolveCount", problemSolves.size()); return "problem"; } + + /** 모르는 상태값은 필터를 걸지 않은 것으로 본다. 잘못된 주소로 500 을 내지 않기 위해서다. */ + private String normalizeStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return Arrays.stream(AnalysisStatus.values()) + .map(Enum::name) + .filter(name -> name.equalsIgnoreCase(status.trim())) + .findFirst() + .orElse(null); + } + + /** 핵심 포인트는 JSON 배열 문자열이거나, "• " 로 시작하는 줄을 이어 붙인 평문이다. */ + private List parseKeyPoints(String keyPoints) { + if (keyPoints == null || keyPoints.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(keyPoints, new TypeReference>() { + }); + } catch (Exception e) { + return Arrays.stream(keyPoints.split("\\r?\\n|\\\\n")) + .map(line -> line.replaceFirst("^\\s*[•\\-*]\\s*", "").trim()) + .filter(line -> !line.isEmpty()) + .toList(); + } + } } diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java index 949a95ab..6c1517e1 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java @@ -1,20 +1,16 @@ package com.aisip.OnO.backend.admin.controller; -import com.aisip.OnO.backend.admin.dto.AdminStudyRoomDetailDto; -import com.aisip.OnO.backend.admin.dto.AdminStudyRoomSummaryDto; +import com.aisip.OnO.backend.admin.dto.AdminPager; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Detail; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.RoomHeader; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.RoomRow; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.SharedProblem; +import com.aisip.OnO.backend.admin.repository.AdminStudyRoomQueryRepository; import com.aisip.OnO.backend.common.exception.ApplicationException; -import com.aisip.OnO.backend.studyroom.entity.StudyRoom; -import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; -import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.mission.service.MissionPeriodKey; import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; -import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; -import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; -import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; -import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -22,79 +18,74 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Controller @RequestMapping("/admin/study-rooms") @RequiredArgsConstructor public class AdminStudyRoomController { - private final StudyRoomRepository studyRoomRepository; - private final StudyRoomMemberRepository studyRoomMemberRepository; - private final StudyRoomChallengeRepository studyRoomChallengeRepository; - private final StudyRoomSharedProblemRepository studyRoomSharedProblemRepository; + /** 한 페이지 크기 상한. 너무 크게 넘기면 방마다 도는 집계 서브쿼리가 그만큼 늘어난다. */ + private static final int MAX_PAGE_SIZE = 200; + + private final AdminStudyRoomQueryRepository studyRoomQueryRepository; @GetMapping public String list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + HttpServletRequest request, Model model ) { - // 쿼리 파라미터는 사용자 입력이다. page 가 음수이거나 size 가 0 이하면 - // PageRequest.of 가 IllegalArgumentException 을 던져 관리자 화면이 500 이 됐다. + // 쿼리 파라미터는 사용자 입력이다. page 가 음수이거나 size 가 0 이하면 500 이 나지 않도록 // 다른 관리자 목록 화면과 같은 방식으로 유효 범위에 맞춰 보정한다. int selectedPage = Math.max(page, 0); - int selectedSize = Math.max(size, 1); - - Page pageResult = studyRoomRepository.findAll( - PageRequest.of(selectedPage, selectedSize, Sort.by("createdAt").descending())); - - List roomIds = pageResult.stream().map(StudyRoom::getId).toList(); + int selectedSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); - Map memberCountMap = studyRoomMemberRepository.countMembersByRoomIds(roomIds) - .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + long totalCount = studyRoomQueryRepository.countRooms(); + List rooms = studyRoomQueryRepository.findRooms(selectedPage * selectedSize, selectedSize); + AdminPager pager = AdminPager.of(request, "page", selectedPage, selectedSize, totalCount); - Map sharedProblemCountMap = studyRoomSharedProblemRepository.countSharedProblemsByRoomIds(roomIds) - .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); - - List rooms = pageResult.stream() - .map(r -> AdminStudyRoomSummaryDto.from( - r, - memberCountMap.getOrDefault(r.getId(), 0L), - sharedProblemCountMap.getOrDefault(r.getId(), 0L))) - .toList(); + LocalDateTime weekStart = MissionPeriodKey.today() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .atStartOfDay(); + model.addAttribute("overview", studyRoomQueryRepository.overview(weekStart)); model.addAttribute("rooms", rooms); - model.addAttribute("totalCount", pageResult.getTotalElements()); + model.addAttribute("totalCount", totalCount); model.addAttribute("currentPage", selectedPage); - model.addAttribute("totalPages", pageResult.getTotalPages()); + model.addAttribute("totalPages", pager.totalPages()); model.addAttribute("size", selectedSize); - - int blockSize = 10; - int blockStart = (selectedPage / blockSize) * blockSize; - int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1); - model.addAttribute("pageBlockStart", blockStart); - model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart)); - model.addAttribute("hasPreviousBlock", blockStart > 0); - model.addAttribute("hasNextBlock", blockEnd < pageResult.getTotalPages() - 1); + model.addAttribute("pager", pager); return "admin-study-rooms"; } @GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { - // IllegalArgumentException 은 GlobalExceptionHandler 의 마지막 Exception 핸들러로 떨어져 - // 500 + Discord 에러 알림이 됐다. 없는 리소스 조회는 404 다. - StudyRoom room = studyRoomRepository.findById(id) + // 없는 리소스 조회는 500 이 아니라 404 다. + RoomHeader room = studyRoomQueryRepository.findRoom(id) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); - List members = studyRoomMemberRepository.findAllWithUserByRoomId(id); - List challenges = studyRoomChallengeRepository.findAllByRoomIdOrderByEndAtAsc(id); - long sharedProblemCount = studyRoomSharedProblemRepository.countByRoomId(id); - - model.addAttribute("room", AdminStudyRoomDetailDto.from(room, members, challenges, sharedProblemCount)); + List sharedProblems = studyRoomQueryRepository.findSharedProblems(id); + long totalCommentCount = sharedProblems.stream().mapToLong(SharedProblem::commentCount).sum(); + + Detail detail = new Detail( + room, + studyRoomQueryRepository.findInviteCodes(id, MissionPeriodKey.now()), + studyRoomQueryRepository.findMembers(id), + studyRoomQueryRepository.findChallenges(id), + sharedProblems, + studyRoomQueryRepository.findRecentFeeds(id), + studyRoomQueryRepository.findWeeklyReports(id), + totalCommentCount, + studyRoomQueryRepository.countReactions(id) + ); + + model.addAttribute("room", detail); return "admin-study-room-detail"; } } 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 f75f5500..71133ece 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,24 +1,21 @@ 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; -import com.aisip.OnO.backend.mission.service.MissionLogService; -import com.aisip.OnO.backend.practicenote.dto.PracticeNoteDetailResponseDto; -import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; -import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; -import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.admin.dto.AdminPager; +import com.aisip.OnO.backend.admin.dto.AdminUserRows; +import com.aisip.OnO.backend.admin.repository.AdminUserQueryRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.user.dto.UserRegisterDto; -import com.aisip.OnO.backend.user.dto.UserResponseDto; +import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.service.UserService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -27,69 +24,72 @@ @RequestMapping("/admin") public class AdminUserController { + /** 한 페이지에 너무 많이 요청하면 집계 쿼리가 운영 DB 를 오래 붙잡는다. */ + private static final int MAX_PAGE_SIZE = 500; + + private static final int STREAK_LOOKBACK_DAYS = 400; + private final UserService userService; - private final ProblemService problemService; - private final FolderService folderService; - private final PracticeNoteService practiceNoteService; - private final MissionLogService missionLogService; + private final AdminUserQueryRepository adminUserQueryRepository; @GetMapping("/users") + @Transactional(readOnly = true) 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, + @RequestParam(required = false, name = "q") String q, + @RequestParam(required = false, name = "platform") String platform, + HttpServletRequest request, Model model ) { 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()); + int selectedSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + String keyword = blankToNull(q); + String selectedPlatform = blankToNull(platform); + + long totalUsers = adminUserQueryRepository.countUsers(keyword, selectedPlatform); + List users = adminUserQueryRepository.findUsers( + keyword, selectedPlatform, sortBy, direction, (long) selectedPage * selectedSize, selectedSize); + + LocalDate today = LocalDate.now(); + model.addAttribute("summary", adminUserQueryRepository.summarize(today.atStartOfDay(), today.minusDays(6).atStartOfDay())); + model.addAttribute("platforms", adminUserQueryRepository.findPlatforms()); + model.addAttribute("users", users); + model.addAttribute("totalUsers", totalUsers); model.addAttribute("currentPage", selectedPage); - model.addAttribute("totalPages", totalPages); - 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); + model.addAttribute("q", keyword); + model.addAttribute("platform", selectedPlatform); + model.addAttribute("pager", AdminPager.of(request, "page", selectedPage, selectedSize, totalUsers)); return "users"; } @GetMapping("/user/{userId}") + @Transactional(readOnly = true) public String getUserDetailsById(@PathVariable(name = "userId") Long userId, Model model) { - UserResponseDto user = userService.findUser(userId); - model.addAttribute("user", user); - - // 문제 정보 - List problems = problemService.findUserProblems(userId); - model.addAttribute("problems", problems); - Long problemCount = problemService.findProblemCountByUser(userId); - model.addAttribute("problemCount", problemCount); - - // 폴더 정보 - List folders = folderService.findAllUserFolders(userId); - model.addAttribute("folders", folders); - model.addAttribute("folderCount", folders.size()); - - // 복습노트 정보 - List practiceNotes = practiceNoteService.findAllPracticesByUser(userId); - model.addAttribute("practiceNotes", practiceNotes); - model.addAttribute("practiceNoteCount", practiceNotes.size()); - - // 미션 기록 정보 - List missionLogs = missionLogService.findAllByUserId(userId); - model.addAttribute("missionLogs", missionLogs); - model.addAttribute("missionLogCount", missionLogs.size()); + AdminUserRows.Profile profile = adminUserQueryRepository.findProfile(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + + model.addAttribute("user", profile); + model.addAttribute("counts", adminUserQueryRepository.countOwnedData(userId)); + model.addAttribute("loginStreak", currentStreak(adminUserQueryRepository.findRecentLoginDates(userId, STREAK_LOOKBACK_DAYS))); + model.addAttribute("problems", adminUserQueryRepository.findProblems(userId)); + model.addAttribute("solves", adminUserQueryRepository.findSolves(userId)); + model.addAttribute("practiceNotes", adminUserQueryRepository.findPracticeNotes(userId)); + model.addAttribute("folders", adminUserQueryRepository.findFolders(userId)); + model.addAttribute("tags", adminUserQueryRepository.findTags(userId)); + model.addAttribute("missionProgress", adminUserQueryRepository.findMissionProgress(userId)); + model.addAttribute("missionLogs", adminUserQueryRepository.findMissionLogs(userId)); + model.addAttribute("studyRooms", adminUserQueryRepository.findStudyRooms(userId)); + model.addAttribute("cosmetics", adminUserQueryRepository.findEquippedCosmetics(userId)); + model.addAttribute("achievements", adminUserQueryRepository.findAchievements(userId)); + model.addAttribute("moods", adminUserQueryRepository.findMoods(userId, 14)); + model.addAttribute("listLimit", AdminUserQueryRepository.detailListLimit()); return "user"; } @@ -113,4 +113,32 @@ public String updateUserLevel( userService.updateUserLevel(userId, levelType, levelValue, pointValue); return "success"; } + + /** + * 오늘이나 어제까지 끊기지 않고 이어진 출석 일수. + * 오늘 아직 앱을 안 열었다고 연속 기록을 0 으로 보여 주면 실제와 다르게 읽히므로 어제부터 세는 것도 인정한다. + */ + static int currentStreak(List loginDatesDesc) { + if (loginDatesDesc.isEmpty()) { + return 0; + } + LocalDate today = LocalDate.now(); + LocalDate expected = loginDatesDesc.get(0); + if (expected.isBefore(today.minusDays(1))) { + return 0; + } + int streak = 0; + for (LocalDate date : loginDatesDesc) { + if (!date.equals(expected)) { + break; + } + streak++; + expected = expected.minusDays(1); + } + return streak; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } } diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminGrowthViews.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminGrowthViews.java new file mode 100644 index 00000000..faab5276 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminGrowthViews.java @@ -0,0 +1,125 @@ +package com.aisip.OnO.backend.admin.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 성장 화면(미션, 업적, 치장)이 쓰는 조회 결과. + */ +public final class AdminGrowthViews { + + private AdminGrowthViews() { + } + + // ---------- 치장 ---------- + + public record CosmeticOverview( + long totalItems, + long activeItems, + long usersWithLoadout, + String topItemName, + long topItemEquipped + ) { + } + + public record CosmeticItemRow( + String itemKey, + String slot, + String nameKo, + String imageUrl, + Integer requiredLevel, + String requiredAbilityLabel, + String setId, + String setNameKo, + boolean fullBody, + boolean active, + long equippedUsers + ) { + /** 번들 안 에셋(assets/...)은 서버에서 띄울 수 없어서, 절대 URL 일 때만 이미지를 그린다. */ + public boolean hasRemoteImage() { + return imageUrl != null && (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")); + } + + public String unlockLabel() { + if (requiredLevel == null) { + return "기본 지급"; + } + return requiredAbilityLabel + " Lv." + requiredLevel; + } + } + + public record SlotGroup(String slot, String label, List items, long equippedUsers) { + } + + public record SetSummary(String setId, String setNameKo, long itemCount, long equippedUsers) { + } + + // ---------- 업적 ---------- + + public record AchievementOverview( + long totalEarned, + long usersWithAny, + long earnedLast7Days, + long totalUsers + ) { + } + + public record AchievementRow( + String key, + String nameKo, + String descriptionKo, + int threshold, + long earnedUsers, + double rate, + LocalDateTime lastEarnedAt + ) { + } + + public record AchievementEarned(Long userId, String userName, String achievementName, LocalDateTime earnedAt) { + } + + // ---------- 미션 ---------- + + public record MissionOverview( + long activeMissions, + long todayCompleted, + long todayClaimed, + long claimedLast7Days + ) { + } + + public record MissionRow( + Long id, + String code, + String title, + String description, + String category, + String metricLabel, + int target, + String rewardLabel, + boolean active, + String currentPeriodKey, + long currentParticipants, + long currentCompleted, + long currentClaimed, + long totalParticipants, + long totalCompleted, + long totalClaimed + ) { + /** 이번 기간에 한 번이라도 진행한 사람 가운데 목표를 채운 비율. */ + public double currentCompletionRate() { + return currentParticipants == 0 ? 0 : (double) currentCompleted * 100 / currentParticipants; + } + + public double totalCompletionRate() { + return totalParticipants == 0 ? 0 : (double) totalCompleted * 100 / totalParticipants; + } + } + + public record MissionDaily(LocalDate date, long completed, long claimed) { + } + + public record LegacyLogCount(String label, long count, long points) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminLearningDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminLearningDto.java new file mode 100644 index 00000000..60c86568 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminLearningDto.java @@ -0,0 +1,244 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.problemsolve.entity.ImprovementType; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 관리자 학습 기록 화면(오답노트, 복습 기록, 복습노트)이 쓰는 조회 결과. + * + *

화면 전용 모양이라 한 파일에 모아 둔다. 도메인 DTO 를 고치지 않고 관리자 화면에 필요한 + * 열만 SQL 로 바로 채운다. + */ +public final class AdminLearningDto { + + private AdminLearningDto() { + } + + public record ProblemRow( + Long problemId, + Long userId, + String userName, + String userEmail, + Long folderId, + String folderName, + String reference, + String memo, + String analysisStatus, + String subject, + String problemType, + long solveCount, + long tagCount, + LocalDateTime createdAt + ) { + } + + public record ProblemSummary(long total, long today, long failed, long rateLimited, long processing) { + } + + public record ProblemDetail( + Long problemId, + Long userId, + String userName, + String userEmail, + boolean userDeleted, + Long folderId, + String folderName, + String reference, + String memo, + LocalDateTime createdAt, + LocalDateTime solvedAt, + LocalDate nextReviewAt, + Integer reviewInterval, + Integer consecutiveCorrectCount, + String analysisStatus, + String subject, + String problemType, + String keyPoints, + String solution, + String commonMistakes, + String studyTips, + String errorMessage + ) { + } + + public record ImageRow(String url, String type) { + } + + public record TagRow(Long tagId, String name) { + } + + public record NoteRef(Long noteId, String title, long practiceCount, LocalDateTime lastSolvedAt) { + } + + public record SharedRoomRow( + Long sharedProblemId, + Long roomId, + String roomName, + Long sharedByUserId, + String sharedByName, + String comment, + long commentCount, + long reactionCount, + LocalDateTime sharedAt + ) { + } + + public record SolveRow( + Long solveId, + LocalDateTime practicedAt, + Long userId, + String userName, + String userEmail, + Long problemId, + String problemReference, + String problemMemo, + String answerStatus, + Integer timeSpentSeconds, + String reflection, + String improvements, + String moodEmojiKey, + boolean migratedFromLegacy, + long imageCount, + List imageUrls + ) { + public SolveRow withImages(List urls) { + return new SolveRow(solveId, practicedAt, userId, userName, userEmail, problemId, problemReference, + problemMemo, answerStatus, timeSpentSeconds, reflection, improvements, moodEmojiKey, + migratedFromLegacy, imageCount, List.copyOf(urls)); + } + + /** 소요 시간을 "3분 20초" 처럼 보여 준다. 기록하지 않았으면 null. */ + public String timeSpentText() { + return AdminLearningDto.durationText(timeSpentSeconds); + } + + /** improvements 는 ["FASTER_SOLVING", ...] 꼴의 JSON 배열 문자열이다. 모르는 값은 그대로 보여 준다. */ + public List improvementTexts() { + if (improvements == null || improvements.isBlank()) { + return List.of(); + } + return java.util.Arrays.stream(improvements.replaceAll("[\\[\\]\"]", "").split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(value -> { + try { + return ImprovementType.valueOf(value).getDescription(); + } catch (IllegalArgumentException e) { + return value; + } + }) + .toList(); + } + } + + public record SolveSummary( + long total, + long correct, + long wrong, + long partial, + long unknown, + Double averageTimeSeconds, + long withReflection + ) { + /** 결과를 고르지 않은 레거시 이관분(UNKNOWN)은 분모에서 뺀다. */ + public double correctRate() { + long judged = correct + wrong + partial; + return judged == 0 ? 0.0 : (double) correct * 100 / judged; + } + + public String averageTimeText() { + return averageTimeSeconds == null ? null : AdminLearningDto.durationText((int) Math.round(averageTimeSeconds)); + } + } + + public record NoteRow( + Long noteId, + String title, + Long userId, + String userName, + String userEmail, + long problemCount, + long practiceCount, + LocalDateTime lastSolvedAt, + String lastMoodEmojiKey, + String repeatType, + Integer intervalDays, + Integer hour, + Integer minute, + List weekDays, + LocalDateTime createdAt + ) { + public NoteRow withWeekDays(List days) { + return new NoteRow(noteId, title, userId, userName, userEmail, problemCount, practiceCount, + lastSolvedAt, lastMoodEmojiKey, repeatType, intervalDays, hour, minute, + days == null ? List.of() : List.copyOf(days), createdAt); + } + + public boolean hasNotification() { + return hour != null && minute != null; + } + + /** "매주 월, 수 21:00" 처럼 알림 설정을 한 줄로 요약한다. */ + public String notificationText() { + if (!hasNotification()) { + return null; + } + String time = String.format("%02d:%02d", hour, minute); + if ("weekly".equalsIgnoreCase(repeatType) && weekDays != null && !weekDays.isEmpty()) { + String days = weekDays.stream().sorted().map(AdminLearningDto::weekDayText).collect(Collectors.joining(", ")); + return "매주 " + days + " " + time; + } + if (intervalDays != null && intervalDays > 1 && !"daily".equalsIgnoreCase(repeatType)) { + return intervalDays + "일마다 " + time; + } + return "매일 " + time; + } + } + + public record NoteSummary(long total, long withNotification, long practiced, long totalPracticeCount) { + } + + public record NoteProblemRow( + Long problemId, + String reference, + String memo, + String subject, + long solveCount, + String lastAnswerStatus, + LocalDateTime lastPracticedAt, + LocalDateTime createdAt + ) { + } + + static String durationText(Integer seconds) { + if (seconds == null) { + return null; + } + if (seconds < 60) { + return seconds + "초"; + } + int minutes = seconds / 60; + int rest = seconds % 60; + if (minutes >= 60) { + return (minutes / 60) + "시간 " + (minutes % 60) + "분"; + } + return rest == 0 ? minutes + "분" : minutes + "분 " + rest + "초"; + } + + static String weekDayText(int day) { + return switch (day) { + case 1 -> "월"; + case 2 -> "화"; + case 3 -> "수"; + case 4 -> "목"; + case 5 -> "금"; + case 6 -> "토"; + case 7 -> "일"; + default -> String.valueOf(day); + }; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStatsDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStatsDto.java new file mode 100644 index 00000000..08406bbe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStatsDto.java @@ -0,0 +1,226 @@ +package com.aisip.OnO.backend.admin.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 통계 화면과 관리자 홈이 쓰는 값 묶음. + * + *

화면 하나에서만 쓰는 작은 record 가 많아서 파일을 나누지 않고 한곳에 모았다. + */ +public final class AdminStatsDto { + + private AdminStatsDto() { + } + + /** 화면 표기용. DB 에는 enum 이름이 그대로 들어 있다. */ + public static String analysisStatusLabel(String status) { + if (status == null) { + return "없음"; + } + return switch (status) { + case "COMPLETED" -> "완료"; + case "FAILED" -> "실패"; + case "PROCESSING" -> "분석 중"; + case "NOT_STARTED" -> "대기"; + case "NO_IMAGE" -> "이미지 없음"; + case "RATE_LIMIT_EXCEEDED" -> "요청 한도 초과"; + default -> status; + }; + } + + public static String platformLabel(String platform) { + if (platform == null) { + return "알 수 없음"; + } + return switch (platform.toUpperCase()) { + case "GUEST" -> "게스트"; + case "GOOGLE" -> "구글"; + case "APPLE" -> "애플"; + case "KAKAO" -> "카카오"; + case "ADMIN" -> "관리자"; + default -> platform; + }; + } + + public static String answerStatusLabel(String status) { + if (status == null) { + return "기록 없음"; + } + return switch (status) { + case "CORRECT" -> "정답"; + case "WRONG" -> "오답"; + case "PARTIAL" -> "부분 정답"; + default -> status; + }; + } + + /** 이번 기간 값과 바로 앞 같은 길이 기간의 값. 증감률은 앞 기간이 0 이면 계산하지 않는다. */ + public record Metric(double value, double previous) { + + public Double deltaPercent() { + if (previous == 0) { + return null; + } + return (value - previous) * 100.0 / previous; + } + } + + /** 이름과 건수. 분포 막대에 쓴다. */ + public record LabelCount(String label, long count) { + + public double percentOf(long total) { + return total == 0 ? 0.0 : count * 100.0 / total; + } + } + + /** 가입 코호트 중 N일째에 다시 들어온 비율. 아직 N일이 지나지 않은 가입자는 코호트에서 뺀다. */ + public record Retention(long cohort, long retained) { + + public double rate() { + return cohort == 0 ? 0.0 : retained * 100.0 / cohort; + } + } + + public record DailyRow( + LocalDate date, + long activeUsers, + long newUsers, + long problems, + long solves, + long practiceNotes, + long missionsCompleted, + long roomActivity + ) { + } + + public record RankRow(Long userId, String name, String email, long count) { + } + + public record UserStats( + long totalUsers, + long guestUsers, + long notificationEnabledUsers, + long fcmUsers, + Metric signups, + Metric activeUsers, + Metric averageDau, + long wau, + long mau, + double stickiness, + Retention d1, + Retention d7, + List signupsByPlatform + ) { + + public double guestRate() { + return totalUsers == 0 ? 0.0 : guestUsers * 100.0 / totalUsers; + } + + public double notificationEnabledRate() { + return totalUsers == 0 ? 0.0 : notificationEnabledUsers * 100.0 / totalUsers; + } + } + + public record LearningStats( + long totalProblems, + long totalSolves, + long totalPracticeNotes, + Metric problems, + long problemWriters, + Metric solves, + long solvers, + long correct, + long wrong, + long partial, + Double averageSolveSeconds, + long solvesWithReflection, + Metric practiceNotes, + long practiceNoteFirstCompletions, + long tagsCreated, + long foldersCreated, + long calendarMoods, + long solveMoods + ) { + + public double problemsPerActiveUser(long activeUsers) { + return activeUsers == 0 ? 0.0 : problems.value() / activeUsers; + } + + public double correctRate() { + long answered = correct + wrong + partial; + return answered == 0 ? 0.0 : correct * 100.0 / answered; + } + + public double reflectionRate() { + return solves.value() == 0 ? 0.0 : solvesWithReflection * 100.0 / solves.value(); + } + } + + public record AnalysisStats( + List allStatuses, + List periodStatuses, + long allTotal, + long periodTotal, + List subjects + ) { + + public long periodCount(String status) { + return periodStatuses.stream() + .filter(s -> s.label().equals(status)) + .mapToLong(LabelCount::count) + .sum(); + } + + /** 끝난 분석(완료, 실패) 가운데 실패 비율. 처리 중이거나 대기인 건은 분모에 넣지 않는다. */ + public double periodFailureRate() { + long completed = periodCount("COMPLETED"); + long failed = periodCount("FAILED"); + return completed + failed == 0 ? 0.0 : failed * 100.0 / (completed + failed); + } + } + + public record GrowthStats( + long missionsCompleted, + long missionsClaimed, + List topMissions, + long achievementsEarned, + List topAchievements, + long cosmeticUsers, + List levelDistribution + ) { + + public long levelTotal() { + return levelDistribution.stream().mapToLong(LabelCount::count).sum(); + } + } + + public record RoomStats( + long totalRooms, + long roomsCreated, + long membersJoined, + long sharedProblems, + long comments, + long reactions, + long challengesCreated, + long challengesCompleted, + long challengesFailed + ) { + } + + public record RecentUser(Long userId, String name, String email, String platform, LocalDateTime createdAt) { + } + + public record RecentProblem(Long problemId, Long userId, String userName, String reference, String memo, + String analysisStatus, LocalDateTime createdAt) { + } + + public record RecentSolve(Long solveId, Long problemId, Long userId, String userName, String answerStatus, + LocalDateTime practicedAt) { + } + + public record RecentFeedback(Long feedbackId, Integer npsScore, String mostUsedFeature, String painPoints, + LocalDateTime submittedAt) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomViews.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomViews.java new file mode 100644 index 00000000..51235ed8 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomViews.java @@ -0,0 +1,154 @@ +package com.aisip.OnO.backend.admin.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 스터디룸 화면이 쓰는 조회 결과. + * + *

화면 하나에 쓰이는 작은 행이 많아서 파일을 나누지 않고 한곳에 모았다. + * 전부 SQL 결과를 그대로 옮겨 담는 값이라 엔티티를 거치지 않는다. + */ +public final class AdminStudyRoomViews { + + private AdminStudyRoomViews() { + } + + public record Overview( + long totalRooms, + long activeRoomsThisWeek, + long totalMembers, + long sharedProblems, + long inProgressChallenges + ) { + } + + public record RoomRow( + Long id, + String name, + String thumbnailUrl, + Long hostUserId, + String hostName, + long memberCount, + long sharedProblemCount, + long commentCount, + long inProgressChallengeCount, + long completedChallengeCount, + LocalDateTime lastActivityAt, + LocalDateTime createdAt + ) { + } + + public record RoomHeader( + Long id, + String name, + String thumbnailUrl, + Long hostUserId, + String hostName, + LocalDateTime createdAt + ) { + } + + public record InviteCode(String code, LocalDateTime createdAt, LocalDateTime expiredAt, boolean expired) { + } + + public record Member( + Long userId, + String userName, + String email, + boolean host, + Integer weeklyGoal, + LocalDateTime joinedAt, + long sharedProblemCount + ) { + } + + public record Challenge( + Long id, + String title, + String typeLabel, + String metricLabel, + String periodLabel, + Integer targetValue, + String status, + String statusLabel, + LocalDateTime startAt, + LocalDateTime endAt, + LocalDateTime completedAt, + String createdByName + ) { + } + + public record SharedProblem( + Long id, + Long problemId, + Long sharedByUserId, + String sharedByName, + String comment, + long reactionCount, + long commentCount, + LocalDateTime sharedAt, + List comments + ) { + public SharedProblem withComments(List comments) { + return new SharedProblem(id, problemId, sharedByUserId, sharedByName, comment, + reactionCount, commentCount, sharedAt, comments); + } + } + + public record Comment( + Long sharedProblemId, + Long authorId, + String authorName, + String content, + long reactionCount, + LocalDateTime createdAt + ) { + } + + public record Feed( + Long id, + String eventLabel, + Long userId, + String userName, + String summary, + long reactionCount, + LocalDateTime createdAt + ) { + } + + public record WeeklyReport( + LocalDate weekStart, + LocalDate weekEnd, + String topMemberName, + Integer topMemberProblemCount, + String longestStreakName, + Integer longestStreakDays, + Integer totalProblems, + Integer challengesCompleted, + String cheerMessage, + long readCount + ) { + } + + public record Detail( + RoomHeader room, + List inviteCodes, + List members, + List challenges, + List sharedProblems, + List feeds, + List weeklyReports, + long totalCommentCount, + long totalReactionCount + ) { + public long inProgressChallengeCount() { + return challenges.stream().filter(c -> "IN_PROGRESS".equals(c.status())).count(); + } + + public long completedChallengeCount() { + return challenges.stream().filter(c -> "COMPLETED".equals(c.status())).count(); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserRows.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserRows.java new file mode 100644 index 00000000..72b5dec7 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminUserRows.java @@ -0,0 +1,189 @@ +package com.aisip.OnO.backend.admin.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 관리자 유저 목록과 상세 화면이 쓰는 조회 결과. + * + *

화면 한 장을 채우는 행 종류가 많아서 파일을 하나씩 만들지 않고 여기에 모았다. + * 전부 {@code AdminUserQueryRepository} 가 SQL 결과를 그대로 옮겨 담는 값이다. + */ +public final class AdminUserRows { + + private AdminUserRows() { + } + + /** 유저 목록의 한 줄. */ + public record ListRow( + Long userId, + String name, + String email, + String platform, + Long totalStudyLevel, + Long totalStudyPoint, + long problemCount, + long solveCount, + long practiceNoteCount, + LocalDateTime lastActiveAt, + LocalDateTime createdAt + ) { + } + + /** 유저 목록 위의 요약. 검색 조건과 상관없이 전체 기준이다. */ + public record Summary( + long totalUsers, + long todaySignups, + long guestUsers, + long activeUsersLast7Days + ) { + public double guestRate() { + return totalUsers == 0 ? 0.0 : (double) guestUsers * 100 / totalUsers; + } + } + + public record Profile( + Long userId, + String name, + String email, + String platform, + String profileImageUrl, + boolean notificationEnabled, + LocalDateTime lastActiveAt, + LocalDate lastNotifiedAt, + LocalDateTime createdAt, + Level attendance, + Level noteWrite, + Level problemPractice, + Level notePractice, + Level totalStudy + ) { + /** 앱 번들 경로(assets/...)나 로컬 주소는 브라우저에서 열리지 않아 이미지로 그리지 않는다. */ + public boolean hasViewableProfileImage() { + return profileImageUrl != null && profileImageUrl.startsWith("https://"); + } + } + + /** 능력치 하나의 레벨과 그 레벨 안에서 쌓인 포인트. */ + public record Level(Long level, Long point) { + } + + public record Counts( + long problemCount, + long solveCount, + long correctSolveCount, + long practiceNoteCount, + long folderCount, + long tagCount, + long studyRoomCount, + long achievementCount, + long fcmTokenCount, + long loginDayCount + ) { + public double correctRate() { + return solveCount == 0 ? 0.0 : (double) correctSolveCount * 100 / solveCount; + } + } + + public record ProblemRow( + Long problemId, + String memo, + String reference, + String folderName, + String analysisStatus, + String subject, + long solveCount, + LocalDate nextReviewAt, + LocalDateTime createdAt + ) { + } + + public record SolveRow( + Long solveId, + Long problemId, + String problemReference, + String answerStatus, + Integer timeSpentSeconds, + String reflection, + String moodEmojiKey, + LocalDateTime practicedAt + ) { + } + + public record PracticeNoteRow( + Long practiceNoteId, + String title, + long problemCount, + Long practiceCount, + LocalDateTime lastSolvedAt, + String repeatType, + LocalDateTime createdAt + ) { + } + + public record FolderRow( + Long folderId, + String name, + String parentName, + long problemCount, + LocalDateTime createdAt + ) { + } + + public record TagRow(Long tagId, String name, long problemCount) { + } + + public record MissionProgressRow( + String code, + String title, + String category, + String periodKey, + int currentValue, + int targetValue, + LocalDateTime completedAt, + LocalDateTime claimedAt, + String rewardType, + Integer rewardValue + ) { + } + + public record MissionLogRow( + Long missionLogId, + String missionType, + Long point, + Long referenceId, + LocalDateTime createdAt + ) { + } + + public record StudyRoomRow( + Long roomId, + String name, + String role, + long memberCount, + Integer weeklyGoal, + LocalDateTime joinedAt + ) { + } + + public record CosmeticRow( + String slot, + String slotName, + String itemKey, + String itemName, + String setName, + LocalDateTime updatedAt + ) { + } + + public record AchievementRow( + String key, + String name, + String description, + LocalDateTime earnedAt + ) { + } + + public record MoodRow(LocalDate studyDate, String emojiKey) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminGrowthQueryRepository.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminGrowthQueryRepository.java new file mode 100644 index 00000000..ca4ab661 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminGrowthQueryRepository.java @@ -0,0 +1,275 @@ +package com.aisip.OnO.backend.admin.repository; + +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.AchievementEarned; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.CosmeticItemRow; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.LegacyLogCount; +import com.aisip.OnO.backend.admin.dto.AdminGrowthViews.MissionRow; +import com.aisip.OnO.backend.mission.entity.MissionType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 관리자 성장 화면(미션, 업적, 치장) 전용 조회. + * + *

유저 수는 탈퇴하지 않았고 관리자 계정이 아닌 사람만 센다. 관리자 계정은 부팅할 때 자동으로 + * 만들어져서 비율의 분모에 끼면 실제 사용자 비율이 조금씩 틀어진다. + */ +@Repository +@RequiredArgsConstructor +public class AdminGrowthQueryRepository { + + private static final String LIVE_USER = "u.deleted_at IS NULL AND (u.platform IS NULL OR u.platform <> 'ADMIN')"; + + private final NamedParameterJdbcTemplate jdbc; + + public long countLiveUsers() { + return count("SELECT COUNT(*) FROM user u WHERE " + LIVE_USER, new MapSqlParameterSource()); + } + + // ---------- 치장 ---------- + + /** 아이템마다 지금 착용 중인 유저 수. 착용 기록은 아이템 키로만 이어져 있어 키로 붙인다. */ + public List findCosmeticItems() { + return jdbc.query(""" + SELECT ci.item_key, ci.slot, ci.name_ko, ci.image_url, ci.required_level, ci.required_ability, + ci.set_id, ci.set_name_ko, ci.full_body, ci.active, + COUNT(u.id) AS equipped_users + FROM cosmetic_item ci + LEFT JOIN user_cosmetic_loadout l ON l.item_key = ci.item_key AND l.slot = ci.slot + LEFT JOIN user u ON u.id = l.user_id AND %s + GROUP BY ci.id, ci.item_key, ci.slot, ci.name_ko, ci.image_url, ci.required_level, + ci.required_ability, ci.set_id, ci.set_name_ko, ci.full_body, ci.active + ORDER BY ci.required_level IS NULL DESC, ci.required_level ASC, ci.id ASC + """.formatted(LIVE_USER), new MapSqlParameterSource(), (rs, i) -> { + int level = rs.getInt("required_level"); + Integer requiredLevel = rs.wasNull() ? null : level; + return new CosmeticItemRow( + rs.getString("item_key"), + rs.getString("slot"), + rs.getString("name_ko"), + rs.getString("image_url"), + requiredLevel, + AdminStudyRoomLabels.ability(rs.getString("required_ability")), + rs.getString("set_id"), + rs.getString("set_name_ko"), + rs.getBoolean("full_body"), + rs.getBoolean("active"), + rs.getLong("equipped_users")); + }); + } + + public long countUsersWithLoadout() { + return count(""" + SELECT COUNT(DISTINCT l.user_id) + FROM user_cosmetic_loadout l + JOIN user u ON u.id = l.user_id AND %s + """.formatted(LIVE_USER), new MapSqlParameterSource()); + } + + // ---------- 업적 ---------- + + public record AchievementKeyStat(long earnedUsers, LocalDateTime lastEarnedAt) { + } + + /** 업적 키별 획득 유저 수와 마지막 획득 시각. */ + public Map countAchievementsByKey() { + Map result = new HashMap<>(); + jdbc.query(""" + SELECT ua.achievement_key, COUNT(*) AS earned, MAX(ua.earned_at) AS last_earned_at + FROM user_achievement ua + JOIN user u ON u.id = ua.user_id AND %s + GROUP BY ua.achievement_key + """.formatted(LIVE_USER), new MapSqlParameterSource(), rs -> { + result.put(rs.getString("achievement_key"), new AchievementKeyStat( + rs.getLong("earned"), rs.getObject("last_earned_at", LocalDateTime.class))); + }); + return result; + } + + public long countEarnedAchievements() { + return count(""" + SELECT COUNT(*) FROM user_achievement ua JOIN user u ON u.id = ua.user_id AND %s + """.formatted(LIVE_USER), new MapSqlParameterSource()); + } + + public long countUsersWithAchievement() { + return count(""" + SELECT COUNT(DISTINCT ua.user_id) FROM user_achievement ua JOIN user u ON u.id = ua.user_id AND %s + """.formatted(LIVE_USER), new MapSqlParameterSource()); + } + + public long countAchievementsEarnedSince(LocalDateTime since) { + return count(""" + SELECT COUNT(*) FROM user_achievement ua JOIN user u ON u.id = ua.user_id AND %s + WHERE ua.earned_at >= :since + """.formatted(LIVE_USER), new MapSqlParameterSource("since", since)); + } + + /** 최근에 받은 업적. 이름은 enum 에서 붙이도록 키를 그대로 돌려준다. */ + public List findRecentAchievements(int limit) { + return jdbc.query(""" + SELECT ua.user_id, u.name, ua.achievement_key, ua.earned_at + FROM user_achievement ua + JOIN user u ON u.id = ua.user_id AND %s + ORDER BY ua.earned_at DESC + LIMIT :limit + """.formatted(LIVE_USER), new MapSqlParameterSource("limit", limit), (rs, i) -> new AchievementEarned( + rs.getLong("user_id"), + rs.getString("name"), + rs.getString("achievement_key"), + rs.getObject("earned_at", LocalDateTime.class) + )); + } + + // ---------- 미션 ---------- + + /** + * 미션 정의와 진행 집계. + * + *

이번 기간은 {@code period_key} 로 가른다. 일일 미션 키(yyyy-MM-dd)와 주간 미션 키(yyyy-Www)는 + * 모양이 달라서 두 키를 한 IN 조건에 넣어도 서로 섞이지 않는다. + */ + public List findMissions(String dailyKey, String weeklyKey) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("dailyKey", dailyKey) + .addValue("weeklyKey", weeklyKey); + return jdbc.query(""" + SELECT d.id, d.code, d.title, d.description, d.category, d.metric, d.target, + d.reward_type, d.reward_value, d.active, + COALESCE(SUM(CASE WHEN mp.period_key IN (:dailyKey, :weeklyKey) THEN 1 ELSE 0 END), 0) AS cur_participants, + COALESCE(SUM(CASE WHEN mp.period_key IN (:dailyKey, :weeklyKey) AND mp.completed_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS cur_completed, + COALESCE(SUM(CASE WHEN mp.period_key IN (:dailyKey, :weeklyKey) AND mp.claimed_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS cur_claimed, + COUNT(mp.id) AS total_participants, + COALESCE(SUM(CASE WHEN mp.completed_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS total_completed, + COALESCE(SUM(CASE WHEN mp.claimed_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS total_claimed + FROM mission_definition d + LEFT JOIN mission_progress mp ON mp.mission_id = d.id AND mp.deleted_at IS NULL + WHERE d.deleted_at IS NULL + GROUP BY d.id, d.code, d.title, d.description, d.category, d.metric, d.target, + d.reward_type, d.reward_value, d.active, d.sort_order + ORDER BY d.category ASC, d.sort_order ASC, d.id ASC + """, params, (rs, i) -> { + String category = rs.getString("category"); + return new MissionRow( + rs.getLong("id"), + rs.getString("code"), + rs.getString("title"), + rs.getString("description"), + category, + metricLabel(rs.getString("metric")), + rs.getInt("target"), + rewardLabel(rs.getString("reward_type"), rs.getInt("reward_value")), + rs.getBoolean("active"), + "WEEKLY".equals(category) ? weeklyKey : dailyKey, + rs.getLong("cur_participants"), + rs.getLong("cur_completed"), + rs.getLong("cur_claimed"), + rs.getLong("total_participants"), + rs.getLong("total_completed"), + rs.getLong("total_claimed")); + }); + } + + public long countActiveMissions() { + return count("SELECT COUNT(*) FROM mission_definition WHERE deleted_at IS NULL AND active = TRUE", + new MapSqlParameterSource()); + } + + public long countMissionsCompletedBetween(LocalDateTime from, LocalDateTime to) { + return count(""" + SELECT COUNT(*) FROM mission_progress + WHERE deleted_at IS NULL AND completed_at >= :from AND completed_at < :to + """, new MapSqlParameterSource().addValue("from", from).addValue("to", to)); + } + + public long countMissionsClaimedBetween(LocalDateTime from, LocalDateTime to) { + return count(""" + SELECT COUNT(*) FROM mission_progress + WHERE deleted_at IS NULL AND claimed_at >= :from AND claimed_at < :to + """, new MapSqlParameterSource().addValue("from", from).addValue("to", to)); + } + + /** 날짜별 완료 건수와 수령 건수. 두 시각이 다른 날일 수 있어 따로 센다. */ + public Map countMissionsDaily(LocalDate from, LocalDate toInclusive) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("from", from.atStartOfDay()) + .addValue("to", toInclusive.plusDays(1).atStartOfDay()); + Map result = new HashMap<>(); + jdbc.query(""" + SELECT DATE(completed_at) AS d, COUNT(*) AS c FROM mission_progress + WHERE deleted_at IS NULL AND completed_at >= :from AND completed_at < :to + GROUP BY DATE(completed_at) + """, params, rs -> { + result.computeIfAbsent(rs.getObject("d", LocalDate.class), k -> new long[2])[0] = rs.getLong("c"); + }); + jdbc.query(""" + SELECT DATE(claimed_at) AS d, COUNT(*) AS c FROM mission_progress + WHERE deleted_at IS NULL AND claimed_at >= :from AND claimed_at < :to + GROUP BY DATE(claimed_at) + """, params, rs -> { + result.computeIfAbsent(rs.getObject("d", LocalDate.class), k -> new long[2])[1] = rs.getLong("c"); + }); + return result; + } + + /** 예전 방식 적립(mission_log) 을 유형별로 센다. mission_type 은 enum 순서값으로 저장돼 있다. */ + public List countLegacyLogsSince(LocalDateTime since) { + Map byType = new HashMap<>(); + jdbc.query(""" + SELECT mission_type, COUNT(*) AS c, COALESCE(SUM(point), 0) AS p + FROM mission_log + WHERE deleted_at IS NULL AND created_at >= :since + GROUP BY mission_type + """, new MapSqlParameterSource("since", since), rs -> { + byType.put(rs.getInt("mission_type"), new long[]{rs.getLong("c"), rs.getLong("p")}); + }); + return java.util.Arrays.stream(MissionType.values()) + .map(type -> { + long[] v = byType.getOrDefault(type.ordinal(), new long[2]); + return new LegacyLogCount(legacyLabel(type), v[0], v[1]); + }) + .toList(); + } + + private long count(String sql, MapSqlParameterSource params) { + Long value = jdbc.queryForObject(sql, params, Long.class); + return value == null ? 0 : value; + } + + private static String metricLabel(String metric) { + if (metric == null) return "-"; + return switch (metric) { + case "LOGIN_DAY" -> "출석"; + case "PROBLEM_CREATED" -> "오답노트 작성"; + case "SOLVE_RECORDED" -> "복습 기록"; + case "SOLVE_CORRECT" -> "복습 정답"; + case "PRACTICE_NOTE_COMPLETED" -> "복습노트 완료"; + case "MOOD_LOGGED" -> "기분 기록"; + default -> metric; + }; + } + + private static String rewardLabel(String rewardType, int rewardValue) { + if ("XP".equals(rewardType)) { + return rewardValue + " XP"; + } + return (rewardType == null ? "" : rewardType + " ") + rewardValue; + } + + private static String legacyLabel(MissionType type) { + return switch (type) { + case USER_LOGIN -> "출석"; + case PROBLEM_WRITE -> "오답노트 작성"; + case PROBLEM_PRACTICE -> "문제 복습"; + case NOTE_PRACTICE -> "복습노트 첫 완료"; + }; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminLearningQueryRepository.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminLearningQueryRepository.java new file mode 100644 index 00000000..0c47808c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminLearningQueryRepository.java @@ -0,0 +1,528 @@ +package com.aisip.OnO.backend.admin.repository; + +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.ImageRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteProblemRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteRef; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.NoteSummary; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.ProblemDetail; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.ProblemRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.ProblemSummary; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.SharedRoomRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.SolveRow; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.SolveSummary; +import com.aisip.OnO.backend.admin.dto.AdminLearningDto.TagRow; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 관리자 학습 기록 화면 전용 조회. + * + *

목록마다 작성자, 폴더, 분석 상태, 복습 횟수를 같이 보여 줘야 하는데 엔티티를 따라가면 + * 행마다 쿼리가 나간다. 한 번의 SQL 로 필요한 열만 가져오고, 행별 개수는 페이지 크기만큼만 + * 도는 상관 서브쿼리로 센다(problem_solve, problem_tag_mapping 모두 problem_id 인덱스가 있다). + * + *

소프트 삭제된 행은 제외한다. 작성자는 탈퇴했어도 누구의 기록인지 알아야 해서 user 조인에는 + * deleted_at 조건을 걸지 않는다. + */ +@Repository +@RequiredArgsConstructor +public class AdminLearningQueryRepository { + + private final NamedParameterJdbcTemplate jdbc; + + /** 목록 필터. 값이 null 이면 그 조건은 걸지 않는다. */ + public record Filter(LocalDate date, Long userId, String status) { + public static Filter none() { + return new Filter(null, null, null); + } + } + + // ---------- 오답노트 ---------- + + private static final String PROBLEM_FROM = """ + FROM problem p + LEFT JOIN `user` u ON u.id = p.user_id + LEFT JOIN folder f ON f.id = p.folder_id + LEFT JOIN problem_analysis pa ON pa.problem_id = p.id AND pa.deleted_at IS NULL + WHERE p.deleted_at IS NULL + """; + + public long countProblems(Filter filter) { + MapSqlParameterSource params = new MapSqlParameterSource(); + String where = problemWhere(filter, params); + return queryLong("SELECT COUNT(*) " + PROBLEM_FROM + where, params); + } + + public List findProblems(Filter filter, int offset, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("offset", offset) + .addValue("limit", limit); + String where = problemWhere(filter, params); + String sql = """ + SELECT p.id, p.user_id, u.name AS user_name, u.email AS user_email, + p.folder_id, f.name AS folder_name, p.reference, p.memo, + pa.status, pa.subject, pa.problem_type, p.created_at, + (SELECT COUNT(*) FROM problem_solve s + WHERE s.problem_id = p.id AND s.deleted_at IS NULL) AS solve_count, + (SELECT COUNT(*) FROM problem_tag_mapping t + WHERE t.problem_id = p.id AND t.deleted_at IS NULL) AS tag_count + """ + PROBLEM_FROM + where + " ORDER BY p.id DESC LIMIT :limit OFFSET :offset"; + + return jdbc.query(sql, params, (rs, i) -> new ProblemRow( + rs.getLong("id"), + nullableLong(rs, "user_id"), + rs.getString("user_name"), + rs.getString("user_email"), + nullableLong(rs, "folder_id"), + rs.getString("folder_name"), + rs.getString("reference"), + rs.getString("memo"), + rs.getString("status"), + rs.getString("subject"), + rs.getString("problem_type"), + rs.getLong("solve_count"), + rs.getLong("tag_count"), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + private String problemWhere(Filter filter, MapSqlParameterSource params) { + StringBuilder where = new StringBuilder(); + if (filter.date() != null) { + where.append(" AND p.created_at >= :from AND p.created_at < :to"); + params.addValue("from", filter.date().atStartOfDay()).addValue("to", filter.date().plusDays(1).atStartOfDay()); + } + if (filter.userId() != null) { + where.append(" AND p.user_id = :userId"); + params.addValue("userId", filter.userId()); + } + if (filter.status() != null) { + where.append(" AND pa.status = :status"); + params.addValue("status", filter.status()); + } + return where.toString(); + } + + public ProblemSummary summarizeProblems(LocalDate today) { + String sql = """ + SELECT COUNT(*) AS total, + COALESCE(SUM(p.created_at >= :todayStart), 0) AS today, + COALESCE(SUM(pa.status = 'FAILED'), 0) AS failed, + COALESCE(SUM(pa.status = 'RATE_LIMIT_EXCEEDED'), 0) AS rate_limited, + COALESCE(SUM(pa.status = 'PROCESSING'), 0) AS processing + FROM problem p + LEFT JOIN problem_analysis pa ON pa.problem_id = p.id AND pa.deleted_at IS NULL + WHERE p.deleted_at IS NULL + """; + return jdbc.queryForObject(sql, new MapSqlParameterSource("todayStart", today.atStartOfDay()), + (rs, i) -> new ProblemSummary( + rs.getLong("total"), + rs.getLong("today"), + rs.getLong("failed"), + rs.getLong("rate_limited"), + rs.getLong("processing") + )); + } + + public Optional findProblemDetail(Long problemId) { + String sql = """ + SELECT p.id, p.user_id, u.name AS user_name, u.email AS user_email, + (u.id IS NULL OR u.deleted_at IS NOT NULL) AS user_deleted, + p.folder_id, f.name AS folder_name, p.reference, p.memo, p.created_at, p.solved_at, + p.next_review_at, p.review_interval, p.consecutive_correct_count, + pa.status, pa.subject, pa.problem_type, pa.key_points, pa.solution, + pa.common_mistakes, pa.study_tips, pa.error_message + FROM problem p + LEFT JOIN `user` u ON u.id = p.user_id + LEFT JOIN folder f ON f.id = p.folder_id + LEFT JOIN problem_analysis pa ON pa.problem_id = p.id AND pa.deleted_at IS NULL + WHERE p.id = :problemId AND p.deleted_at IS NULL + """; + List rows = jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), (rs, i) -> { + java.sql.Date nextReviewAt = rs.getDate("next_review_at"); + return new ProblemDetail( + rs.getLong("id"), + nullableLong(rs, "user_id"), + rs.getString("user_name"), + rs.getString("user_email"), + rs.getBoolean("user_deleted"), + nullableLong(rs, "folder_id"), + rs.getString("folder_name"), + rs.getString("reference"), + rs.getString("memo"), + toDateTime(rs.getTimestamp("created_at")), + toDateTime(rs.getTimestamp("solved_at")), + nextReviewAt == null ? null : nextReviewAt.toLocalDate(), + nullableInt(rs, "review_interval"), + nullableInt(rs, "consecutive_correct_count"), + rs.getString("status"), + rs.getString("subject"), + rs.getString("problem_type"), + rs.getString("key_points"), + rs.getString("solution"), + rs.getString("common_mistakes"), + rs.getString("study_tips"), + rs.getString("error_message") + ); + }); + return rows.stream().findFirst(); + } + + public List findProblemImages(Long problemId) { + String sql = """ + SELECT image_url, image_type FROM image_data + WHERE problem_id = :problemId AND deleted_at IS NULL + ORDER BY image_type, id + """; + return jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), + (rs, i) -> new ImageRow(rs.getString("image_url"), rs.getString("image_type"))); + } + + public List findProblemTags(Long problemId) { + String sql = """ + SELECT t.id, t.name FROM problem_tag_mapping m + JOIN tag t ON t.id = m.tag_id AND t.deleted_at IS NULL + WHERE m.problem_id = :problemId AND m.deleted_at IS NULL + ORDER BY t.name + """; + return jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), + (rs, i) -> new TagRow(rs.getLong("id"), rs.getString("name"))); + } + + public List findNotesContainingProblem(Long problemId) { + String sql = """ + SELECT n.id, n.title, n.practice_count, n.last_solved_at + FROM problem_practice_note_mapping m + JOIN practice_note n ON n.id = m.practice_note_id AND n.deleted_at IS NULL + WHERE m.problem_id = :problemId AND m.deleted_at IS NULL + ORDER BY n.id DESC + """; + return jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), (rs, i) -> new NoteRef( + rs.getLong("id"), + rs.getString("title"), + rs.getLong("practice_count"), + toDateTime(rs.getTimestamp("last_solved_at")) + )); + } + + public List findRoomsSharingProblem(Long problemId) { + String sql = """ + SELECT sp.id, sp.room_id, r.name AS room_name, sp.shared_by_user_id, u.name AS shared_by_name, + sp.comment, sp.created_at, + (SELECT COUNT(*) FROM study_room_shared_problem_comment c + WHERE c.shared_problem_id = sp.id AND c.deleted_at IS NULL) AS comment_count, + (SELECT COUNT(*) FROM study_room_shared_problem_reaction x + WHERE x.shared_problem_id = sp.id AND x.deleted_at IS NULL) AS reaction_count + FROM study_room_shared_problem sp + LEFT JOIN study_room r ON r.id = sp.room_id + LEFT JOIN `user` u ON u.id = sp.shared_by_user_id + WHERE sp.problem_id = :problemId AND sp.deleted_at IS NULL + ORDER BY sp.id DESC + """; + return jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), (rs, i) -> new SharedRoomRow( + rs.getLong("id"), + nullableLong(rs, "room_id"), + rs.getString("room_name"), + nullableLong(rs, "shared_by_user_id"), + rs.getString("shared_by_name"), + rs.getString("comment"), + rs.getLong("comment_count"), + rs.getLong("reaction_count"), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + // ---------- 복습 기록 (problem_solve) ---------- + + private static final String SOLVE_SELECT = """ + SELECT s.id, s.practiced_at, s.user_id, u.name AS user_name, u.email AS user_email, + s.problem_id, p.reference, p.memo, s.answer_status, s.time_spent_seconds, + s.reflection, s.improvements, s.mood_emoji_key, s.migrated_from_legacy, + (SELECT COUNT(*) FROM problem_solve_image_data i + WHERE i.problem_solve_id = s.id AND i.deleted_at IS NULL) AS image_count + FROM problem_solve s + LEFT JOIN `user` u ON u.id = s.user_id + LEFT JOIN problem p ON p.id = s.problem_id + """; + + private static final RowMapper SOLVE_ROW = (rs, i) -> new SolveRow( + rs.getLong("id"), + toDateTime(rs.getTimestamp("practiced_at")), + nullableLong(rs, "user_id"), + rs.getString("user_name"), + rs.getString("user_email"), + nullableLong(rs, "problem_id"), + rs.getString("reference"), + rs.getString("memo"), + rs.getString("answer_status"), + nullableInt(rs, "time_spent_seconds"), + rs.getString("reflection"), + rs.getString("improvements"), + rs.getString("mood_emoji_key"), + rs.getBoolean("migrated_from_legacy"), + rs.getLong("image_count"), + List.of() + ); + + public long countSolves(Filter filter) { + MapSqlParameterSource params = new MapSqlParameterSource(); + return queryLong("SELECT COUNT(*) FROM problem_solve s WHERE s.deleted_at IS NULL" + solveWhere(filter, params), params); + } + + public List findSolves(Filter filter, int offset, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("offset", offset) + .addValue("limit", limit); + String sql = SOLVE_SELECT + " WHERE s.deleted_at IS NULL" + solveWhere(filter, params) + + " ORDER BY s.practiced_at DESC, s.id DESC LIMIT :limit OFFSET :offset"; + return jdbc.query(sql, params, SOLVE_ROW); + } + + public SolveSummary summarizeSolves(Filter filter) { + MapSqlParameterSource params = new MapSqlParameterSource(); + String sql = """ + SELECT COUNT(*) AS total, + COALESCE(SUM(s.answer_status = 'CORRECT'), 0) AS correct, + COALESCE(SUM(s.answer_status = 'WRONG'), 0) AS wrong, + COALESCE(SUM(s.answer_status = 'PARTIAL'), 0) AS partial, + COALESCE(SUM(s.answer_status = 'UNKNOWN'), 0) AS unknown, + AVG(s.time_spent_seconds) AS avg_time, + COALESCE(SUM(s.reflection IS NOT NULL AND s.reflection <> ''), 0) AS with_reflection + FROM problem_solve s + WHERE s.deleted_at IS NULL + """ + solveWhere(filter, params); + return jdbc.queryForObject(sql, params, (rs, i) -> { + double avg = rs.getDouble("avg_time"); + Double averageTime = rs.wasNull() ? null : avg; + return new SolveSummary( + rs.getLong("total"), + rs.getLong("correct"), + rs.getLong("wrong"), + rs.getLong("partial"), + rs.getLong("unknown"), + averageTime, + rs.getLong("with_reflection") + ); + }); + } + + public long countSolvesOn(LocalDate date) { + return countSolves(new Filter(date, null, null)); + } + + private String solveWhere(Filter filter, MapSqlParameterSource params) { + StringBuilder where = new StringBuilder(); + if (filter.date() != null) { + where.append(" AND s.practiced_at >= :from AND s.practiced_at < :to"); + params.addValue("from", filter.date().atStartOfDay()).addValue("to", filter.date().plusDays(1).atStartOfDay()); + } + if (filter.userId() != null) { + where.append(" AND s.user_id = :userId"); + params.addValue("userId", filter.userId()); + } + if (filter.status() != null) { + where.append(" AND s.answer_status = :status"); + params.addValue("status", filter.status()); + } + return where.toString(); + } + + /** 한 문제의 복습 기록 전체. 이미지까지 붙여서 돌려준다. */ + public List findSolvesOfProblem(Long problemId) { + String sql = SOLVE_SELECT + """ + WHERE s.problem_id = :problemId AND s.deleted_at IS NULL + ORDER BY s.practiced_at DESC, s.id DESC + """; + return attachImages(jdbc.query(sql, new MapSqlParameterSource("problemId", problemId), SOLVE_ROW)); + } + + /** 복습노트 상세에서 보여 줄, 노트 주인이 노트 속 문제들에 남긴 최근 복습 기록. */ + public List findRecentSolvesOfProblems(Long userId, Collection problemIds, int limit) { + if (problemIds.isEmpty()) { + return List.of(); + } + String sql = SOLVE_SELECT + """ + WHERE s.problem_id IN (:problemIds) AND s.user_id = :userId AND s.deleted_at IS NULL + ORDER BY s.practiced_at DESC, s.id DESC + LIMIT :limit + """; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("problemIds", problemIds) + .addValue("userId", userId) + .addValue("limit", limit); + return jdbc.query(sql, params, SOLVE_ROW); + } + + private List attachImages(List solves) { + if (solves.isEmpty()) { + return solves; + } + List ids = solves.stream().map(SolveRow::solveId).toList(); + Map> imagesBySolve = new LinkedHashMap<>(); + jdbc.query(""" + SELECT problem_solve_id, image_url FROM problem_solve_image_data + WHERE problem_solve_id IN (:ids) AND deleted_at IS NULL + ORDER BY problem_solve_id, image_order, id + """, + new MapSqlParameterSource("ids", ids), + rs -> { + imagesBySolve.computeIfAbsent(rs.getLong("problem_solve_id"), k -> new java.util.ArrayList<>()) + .add(rs.getString("image_url")); + }); + return solves.stream() + .map(s -> s.withImages(imagesBySolve.getOrDefault(s.solveId(), List.of()))) + .toList(); + } + + // ---------- 복습노트 ---------- + + private static final String NOTE_SELECT = """ + SELECT n.id, n.title, n.user_id, u.name AS user_name, u.email AS user_email, + n.practice_count, n.last_solved_at, n.last_session_mood_emoji_key, + n.repeat_type, n.interval_days, n.hour, n.minute, n.created_at, + (SELECT COUNT(*) FROM problem_practice_note_mapping m + WHERE m.practice_note_id = n.id AND m.deleted_at IS NULL) AS problem_count + FROM practice_note n + LEFT JOIN `user` u ON u.id = n.user_id + """; + + // week_days 는 JPA 가 List 를 직렬화한 varbinary 라 SQL 로는 읽지 않는다. 요일은 호출하는 쪽에서 + // 엔티티로 한 번에 읽어 withWeekDays 로 붙인다. + private static final RowMapper NOTE_ROW = (rs, i) -> new NoteRow( + rs.getLong("id"), + rs.getString("title"), + nullableLong(rs, "user_id"), + rs.getString("user_name"), + rs.getString("user_email"), + rs.getLong("problem_count"), + rs.getLong("practice_count"), + toDateTime(rs.getTimestamp("last_solved_at")), + rs.getString("last_session_mood_emoji_key"), + rs.getString("repeat_type"), + nullableInt(rs, "interval_days"), + nullableInt(rs, "hour"), + nullableInt(rs, "minute"), + List.of(), + toDateTime(rs.getTimestamp("created_at")) + ); + + public long countNotes(Filter filter) { + MapSqlParameterSource params = new MapSqlParameterSource(); + return queryLong("SELECT COUNT(*) FROM practice_note n WHERE n.deleted_at IS NULL" + noteWhere(filter, params), params); + } + + public List findNotes(Filter filter, int offset, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("offset", offset) + .addValue("limit", limit); + String sql = NOTE_SELECT + " WHERE n.deleted_at IS NULL" + noteWhere(filter, params) + + " ORDER BY n.id DESC LIMIT :limit OFFSET :offset"; + return jdbc.query(sql, params, NOTE_ROW); + } + + public Optional findNote(Long noteId) { + String sql = NOTE_SELECT + " WHERE n.id = :noteId AND n.deleted_at IS NULL"; + return jdbc.query(sql, new MapSqlParameterSource("noteId", noteId), NOTE_ROW).stream().findFirst(); + } + + public NoteSummary summarizeNotes() { + String sql = """ + SELECT COUNT(*) AS total, + COALESCE(SUM(n.hour IS NOT NULL AND n.minute IS NOT NULL), 0) AS with_notification, + COALESCE(SUM(n.practice_count > 0), 0) AS practiced, + COALESCE(SUM(n.practice_count), 0) AS total_practice_count + FROM practice_note n + WHERE n.deleted_at IS NULL + """; + return jdbc.queryForObject(sql, new MapSqlParameterSource(), (rs, i) -> new NoteSummary( + rs.getLong("total"), + rs.getLong("with_notification"), + rs.getLong("practiced"), + rs.getLong("total_practice_count") + )); + } + + private String noteWhere(Filter filter, MapSqlParameterSource params) { + StringBuilder where = new StringBuilder(); + if (filter.date() != null) { + where.append(" AND n.created_at >= :from AND n.created_at < :to"); + params.addValue("from", filter.date().atStartOfDay()).addValue("to", filter.date().plusDays(1).atStartOfDay()); + } + if (filter.userId() != null) { + where.append(" AND n.user_id = :userId"); + params.addValue("userId", filter.userId()); + } + return where.toString(); + } + + public List findNoteProblems(Long noteId) { + String sql = """ + SELECT p.id, p.reference, p.memo, p.created_at, pa.subject, + (SELECT COUNT(*) FROM problem_solve s + WHERE s.problem_id = p.id AND s.deleted_at IS NULL) AS solve_count, + (SELECT s.answer_status FROM problem_solve s + WHERE s.problem_id = p.id AND s.deleted_at IS NULL + ORDER BY s.practiced_at DESC, s.id DESC LIMIT 1) AS last_status, + (SELECT MAX(s.practiced_at) FROM problem_solve s + WHERE s.problem_id = p.id AND s.deleted_at IS NULL) AS last_practiced_at + FROM problem_practice_note_mapping m + JOIN problem p ON p.id = m.problem_id AND p.deleted_at IS NULL + LEFT JOIN problem_analysis pa ON pa.problem_id = p.id AND pa.deleted_at IS NULL + WHERE m.practice_note_id = :noteId AND m.deleted_at IS NULL + ORDER BY m.id + """; + return jdbc.query(sql, new MapSqlParameterSource("noteId", noteId), (rs, i) -> new NoteProblemRow( + rs.getLong("id"), + rs.getString("reference"), + rs.getString("memo"), + rs.getString("subject"), + rs.getLong("solve_count"), + rs.getString("last_status"), + toDateTime(rs.getTimestamp("last_practiced_at")), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + // ---------- 공통 ---------- + + /** 필터 칩에 보여 줄 유저 이름. 없는 유저면 null. */ + public String findUserName(Long userId) { + List names = jdbc.queryForList("SELECT name FROM `user` WHERE id = :userId", + new MapSqlParameterSource("userId", userId), String.class); + return names.isEmpty() ? null : names.get(0); + } + + private long queryLong(String sql, MapSqlParameterSource params) { + Long value = jdbc.queryForObject(sql, params, Long.class); + return value == null ? 0L : value; + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static Integer nullableInt(ResultSet rs, String column) throws SQLException { + int value = rs.getInt(column); + return rs.wasNull() ? null : value; + } + + private static LocalDateTime toDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStatsQueryRepository.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStatsQueryRepository.java new file mode 100644 index 00000000..eca07167 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStatsQueryRepository.java @@ -0,0 +1,447 @@ +package com.aisip.OnO.backend.admin.repository; + +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.LabelCount; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RankRow; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentFeedback; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentProblem; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentSolve; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentUser; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.Retention; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 관리자 통계와 홈 화면 전용 조회. + * + *

여러 도메인 테이블을 가로질러 세기만 하는 쿼리라 엔티티를 거치지 않고 SQL 로 바로 센다. + * 기간 조건은 모두 {@code col >= :start AND col < :end} 꼴이다. {@code DATE(col) BETWEEN} 으로 쓰면 + * 컬럼에 함수가 씌워져 인덱스를 못 탄다. + * + *

소프트 삭제를 쓰는 테이블은 엔티티의 {@code @SQLRestriction} 이 여기에는 적용되지 않으므로 + * {@code deleted_at IS NULL} 을 직접 붙인다. + */ +@Repository +@RequiredArgsConstructor +public class AdminStatsQueryRepository { + + /** mission_log.mission_type 은 enum 순서값으로 저장된다. MissionType 선언 순서와 같다. */ + private static final int USER_LOGIN = 0; + private static final int NOTE_PRACTICE = 3; + + private final NamedParameterJdbcTemplate jdbc; + + // ---------- 일별 추이 ---------- + + public Map dailyActiveUsers(LocalDate start, LocalDate end) { + return daily(""" + SELECT DATE(created_at) d, COUNT(DISTINCT user_id) c FROM mission_log + WHERE mission_type = :login AND deleted_at IS NULL AND created_at >= :start AND created_at < :end + GROUP BY DATE(created_at) + """, start, end, range(start, end).addValue("login", USER_LOGIN)); + } + + public Map dailyNewUsers(LocalDate start, LocalDate end) { + return dailyCount("`user`", "created_at", start, end); + } + + public Map dailyProblems(LocalDate start, LocalDate end) { + return dailyCount("problem", "created_at", start, end); + } + + /** 실제 복습 기록. mission_log 의 NOTE_PRACTICE 는 복습노트를 처음 끝낸 날만 남아서 복습량을 못 보여 준다. */ + public Map dailySolves(LocalDate start, LocalDate end) { + return dailyCount("problem_solve", "practiced_at", start, end); + } + + public Map dailyPracticeNotes(LocalDate start, LocalDate end) { + return dailyCount("practice_note", "created_at", start, end); + } + + public Map dailyMissionsCompleted(LocalDate start, LocalDate end) { + return dailyCount("mission_progress", "completed_at", start, end); + } + + /** 스터디룸에서 실제로 무언가를 남긴 건수. 문제 공유와 댓글을 합친다. */ + public Map dailyRoomActivity(LocalDate start, LocalDate end) { + return daily(""" + SELECT d, SUM(c) c FROM ( + SELECT DATE(created_at) d, COUNT(*) c FROM study_room_shared_problem + WHERE deleted_at IS NULL AND created_at >= :start AND created_at < :end GROUP BY DATE(created_at) + UNION ALL + SELECT DATE(created_at) d, COUNT(*) c FROM study_room_shared_problem_comment + WHERE deleted_at IS NULL AND created_at >= :start AND created_at < :end GROUP BY DATE(created_at) + ) t GROUP BY d + """, start, end, range(start, end)); + } + + // ---------- 사용자 ---------- + + public long countUsers() { + return count("SELECT COUNT(*) FROM `user` WHERE deleted_at IS NULL", new MapSqlParameterSource()); + } + + public long countGuestUsers() { + return count("SELECT COUNT(*) FROM `user` WHERE deleted_at IS NULL AND UPPER(platform) = 'GUEST'", + new MapSqlParameterSource()); + } + + public long countNotificationEnabledUsers() { + return count("SELECT COUNT(*) FROM `user` WHERE deleted_at IS NULL AND notification_enabled = 1", + new MapSqlParameterSource()); + } + + public long countUsersWithFcmToken() { + return count(""" + SELECT COUNT(DISTINCT f.user_id) FROM fcm_token f + JOIN `user` u ON u.id = f.user_id AND u.deleted_at IS NULL + WHERE f.deleted_at IS NULL + """, new MapSqlParameterSource()); + } + + public long countSignups(LocalDate start, LocalDate end) { + return countInRange("`user`", "created_at", start, end); + } + + public List signupsByPlatform(LocalDate start, LocalDate end) { + return labelCounts(""" + SELECT UPPER(COALESCE(platform, '알 수 없음')) label, COUNT(*) c FROM `user` + WHERE deleted_at IS NULL AND created_at >= :start AND created_at < :end + GROUP BY UPPER(COALESCE(platform, '알 수 없음')) ORDER BY c DESC + """, range(start, end)); + } + + /** 기간 안에 한 번이라도 로그인한 서로 다른 유저 수. 출석 미션은 하루 한 번만 남으므로 방문 횟수는 따로 세지 않는다. */ + public long countActiveUsers(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(DISTINCT user_id) FROM mission_log + WHERE mission_type = :login AND deleted_at IS NULL AND created_at >= :start AND created_at < :end + """, range(start, end).addValue("login", USER_LOGIN)); + } + + /** + * 기간에 가입한 유저 가운데 가입 N일 뒤 하루 동안 로그인한 비율. + * + *

N일째가 아직 끝나지 않은 가입자까지 넣으면 최근 가입자가 모두 이탈로 잡혀 비율이 낮아진다. + * 그래서 {@code today - N} 전에 가입한 사람만 코호트로 본다. + */ + public Retention retention(LocalDate start, LocalDate end, int dayOffset, LocalDate today) { + LocalDate cohortEndExclusive = end.plusDays(1).isBefore(today.minusDays(dayOffset)) + ? end.plusDays(1) + : today.minusDays(dayOffset); + if (!cohortEndExclusive.isAfter(start)) { + return new Retention(0, 0); + } + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("start", start.atStartOfDay()) + .addValue("end", cohortEndExclusive.atStartOfDay()) + .addValue("login", USER_LOGIN) + .addValue("offset", dayOffset); + return jdbc.queryForObject(""" + SELECT COUNT(*) cohort, + COALESCE(SUM(EXISTS ( + SELECT 1 FROM mission_log m + WHERE m.user_id = u.id AND m.mission_type = :login AND m.deleted_at IS NULL + AND m.created_at >= DATE(u.created_at) + INTERVAL :offset DAY + AND m.created_at < DATE(u.created_at) + INTERVAL (:offset + 1) DAY + )), 0) retained + FROM `user` u + WHERE u.deleted_at IS NULL AND UPPER(COALESCE(u.platform, '')) <> 'ADMIN' + AND u.created_at >= :start AND u.created_at < :end + """, params, (rs, i) -> new Retention(rs.getLong("cohort"), rs.getLong("retained"))); + } + + public List levelDistribution() { + return labelCounts(""" + SELECT CASE + WHEN COALESCE(total_study_level, 1) <= 1 THEN 'Lv.1' + WHEN total_study_level <= 3 THEN 'Lv.2~3' + WHEN total_study_level <= 5 THEN 'Lv.4~5' + WHEN total_study_level <= 9 THEN 'Lv.6~9' + ELSE 'Lv.10 이상' + END label, + COUNT(*) c + FROM `user` + WHERE deleted_at IS NULL AND UPPER(COALESCE(platform, '')) <> 'ADMIN' + GROUP BY label + ORDER BY MIN(COALESCE(total_study_level, 1)) + """, new MapSqlParameterSource()); + } + + // ---------- 학습 ---------- + + public long countAll(String table) { + return count("SELECT COUNT(*) FROM " + table + " WHERE deleted_at IS NULL", new MapSqlParameterSource()); + } + + public long countProblems(LocalDate start, LocalDate end) { + return countInRange("problem", "created_at", start, end); + } + + public long countProblemWriters(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(DISTINCT user_id) FROM problem + WHERE deleted_at IS NULL AND created_at >= :start AND created_at < :end + """, range(start, end)); + } + + public long countSolves(LocalDate start, LocalDate end) { + return countInRange("problem_solve", "practiced_at", start, end); + } + + public long countSolvers(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(DISTINCT user_id) FROM problem_solve + WHERE deleted_at IS NULL AND practiced_at >= :start AND practiced_at < :end + """, range(start, end)); + } + + public Map solvesByAnswerStatus(LocalDate start, LocalDate end) { + Map result = new LinkedHashMap<>(); + labelCounts(""" + SELECT COALESCE(answer_status, 'UNKNOWN') label, COUNT(*) c FROM problem_solve + WHERE deleted_at IS NULL AND practiced_at >= :start AND practiced_at < :end + GROUP BY COALESCE(answer_status, 'UNKNOWN') + """, range(start, end)).forEach(row -> result.put(row.label(), row.count())); + return result; + } + + public Double averageSolveSeconds(LocalDate start, LocalDate end) { + return jdbc.queryForObject(""" + SELECT AVG(time_spent_seconds) FROM problem_solve + WHERE deleted_at IS NULL AND time_spent_seconds IS NOT NULL AND time_spent_seconds > 0 + AND practiced_at >= :start AND practiced_at < :end + """, range(start, end), Double.class); + } + + public long countSolvesWithReflection(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(*) FROM problem_solve + WHERE deleted_at IS NULL AND reflection IS NOT NULL AND TRIM(reflection) <> '' + AND practiced_at >= :start AND practiced_at < :end + """, range(start, end)); + } + + public long countSolveMoods(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(*) FROM problem_solve + WHERE deleted_at IS NULL AND mood_emoji_key IS NOT NULL + AND practiced_at >= :start AND practiced_at < :end + """, range(start, end)); + } + + public long countPracticeNotes(LocalDate start, LocalDate end) { + return countInRange("practice_note", "created_at", start, end); + } + + /** 복습노트를 처음 끝낸 횟수. 같은 노트를 다시 끝내도 mission_log 는 한 번만 남는다. */ + public long countPracticeNoteFirstCompletions(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(*) FROM mission_log + WHERE mission_type = :notePractice AND deleted_at IS NULL AND created_at >= :start AND created_at < :end + """, range(start, end).addValue("notePractice", NOTE_PRACTICE)); + } + + public long countInRange(String table, String column, LocalDate start, LocalDate end) { + return count("SELECT COUNT(*) FROM " + table + " WHERE deleted_at IS NULL AND " + + column + " >= :start AND " + column + " < :end", range(start, end)); + } + + // ---------- AI 분석 ---------- + + public List analysisStatuses() { + return labelCounts(""" + SELECT COALESCE(pa.status, 'UNKNOWN') label, COUNT(*) c FROM problem_analysis pa + JOIN problem p ON p.id = pa.problem_id AND p.deleted_at IS NULL + WHERE pa.deleted_at IS NULL + GROUP BY COALESCE(pa.status, 'UNKNOWN') ORDER BY c DESC + """, new MapSqlParameterSource()); + } + + /** 기간은 문제가 등록된 시점 기준이다. 분석은 등록 직후에 돌기 때문에 등록일로 묶어야 날짜별 실패율이 맞는다. */ + public List analysisStatuses(LocalDate start, LocalDate end) { + return labelCounts(""" + SELECT COALESCE(pa.status, 'UNKNOWN') label, COUNT(*) c FROM problem_analysis pa + JOIN problem p ON p.id = pa.problem_id AND p.deleted_at IS NULL + WHERE pa.deleted_at IS NULL AND p.created_at >= :start AND p.created_at < :end + GROUP BY COALESCE(pa.status, 'UNKNOWN') ORDER BY c DESC + """, range(start, end)); + } + + public List analysisSubjects(LocalDate start, LocalDate end, int limit) { + return labelCounts(""" + SELECT pa.subject label, COUNT(*) c FROM problem_analysis pa + JOIN problem p ON p.id = pa.problem_id AND p.deleted_at IS NULL + WHERE pa.deleted_at IS NULL AND pa.subject IS NOT NULL AND TRIM(pa.subject) <> '' + AND p.created_at >= :start AND p.created_at < :end + GROUP BY pa.subject ORDER BY c DESC LIMIT :limit + """, range(start, end).addValue("limit", limit)); + } + + // ---------- 성장 ---------- + + public long countMissionsClaimed(LocalDate start, LocalDate end) { + return countInRange("mission_progress", "claimed_at", start, end); + } + + public List topCompletedMissions(LocalDate start, LocalDate end, int limit) { + return labelCounts(""" + SELECT COALESCE(d.title, CONCAT('미션 #', mp.mission_id)) label, COUNT(*) c + FROM mission_progress mp + LEFT JOIN mission_definition d ON d.id = mp.mission_id + WHERE mp.deleted_at IS NULL AND mp.completed_at >= :start AND mp.completed_at < :end + GROUP BY mp.mission_id, d.title ORDER BY c DESC LIMIT :limit + """, range(start, end).addValue("limit", limit)); + } + + /** 업적 키별 획득 수. 화면 이름은 Achievement enum 에 있어서 컨트롤러가 바꿔 붙인다. */ + public List achievementsEarned(LocalDate start, LocalDate end) { + return labelCounts(""" + SELECT achievement_key label, COUNT(*) c FROM user_achievement + WHERE earned_at >= :start AND earned_at < :end + GROUP BY achievement_key ORDER BY c DESC + """, range(start, end)); + } + + public long countCosmeticUsers() { + return count(""" + SELECT COUNT(DISTINCT l.user_id) FROM user_cosmetic_loadout l + JOIN `user` u ON u.id = l.user_id AND u.deleted_at IS NULL + """, new MapSqlParameterSource()); + } + + // ---------- 스터디룸 ---------- + + public long countRoomReactions(LocalDate start, LocalDate end) { + return countInRange("study_room_shared_problem_reaction", "created_at", start, end) + + countInRange("study_room_shared_problem_comment_reaction", "created_at", start, end) + + countInRange("study_room_feed_reaction", "created_at", start, end); + } + + public long countChallengesCompleted(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(*) FROM study_room_challenge + WHERE deleted_at IS NULL AND status = 'COMPLETED' AND completed_at >= :start AND completed_at < :end + """, range(start, end)); + } + + /** 실패한 챌린지에는 완료 시각이 없어서 마감 시각으로 기간을 가른다. */ + public long countChallengesFailed(LocalDate start, LocalDate end) { + return count(""" + SELECT COUNT(*) FROM study_room_challenge + WHERE deleted_at IS NULL AND status = 'FAILED' AND end_at >= :start AND end_at < :end + """, range(start, end)); + } + + // ---------- 참여 상위 유저 ---------- + + public List topProblemWriters(LocalDate start, LocalDate end, int limit) { + return topUsers("problem", "created_at", start, end, limit); + } + + public List topSolvers(LocalDate start, LocalDate end, int limit) { + return topUsers("problem_solve", "practiced_at", start, end, limit); + } + + private List topUsers(String table, String column, LocalDate start, LocalDate end, int limit) { + return jdbc.query("SELECT t.user_id, u.name, u.email, COUNT(*) c FROM " + table + " t " + + "LEFT JOIN `user` u ON u.id = t.user_id " + + "WHERE t.deleted_at IS NULL AND t." + column + " >= :start AND t." + column + " < :end " + + "GROUP BY t.user_id, u.name, u.email ORDER BY c DESC LIMIT :limit", + range(start, end).addValue("limit", limit), + (rs, i) -> new RankRow(rs.getLong("user_id"), rs.getString("name"), rs.getString("email"), rs.getLong("c"))); + } + + // ---------- 홈 ---------- + + public List recentUsers(int limit) { + return jdbc.query(""" + SELECT id, name, email, platform, created_at FROM `user` + WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT :limit + """, new MapSqlParameterSource("limit", limit), + (rs, i) -> new RecentUser(rs.getLong("id"), rs.getString("name"), rs.getString("email"), + rs.getString("platform"), rs.getObject("created_at", LocalDateTime.class))); + } + + public List recentProblems(int limit) { + return jdbc.query(""" + SELECT p.id, p.user_id, u.name, p.reference, p.memo, pa.status, p.created_at + FROM problem p + LEFT JOIN `user` u ON u.id = p.user_id + LEFT JOIN problem_analysis pa ON pa.problem_id = p.id AND pa.deleted_at IS NULL + WHERE p.deleted_at IS NULL ORDER BY p.created_at DESC, p.id DESC LIMIT :limit + """, new MapSqlParameterSource("limit", limit), + (rs, i) -> new RecentProblem(rs.getLong("id"), rs.getLong("user_id"), rs.getString("name"), + rs.getString("reference"), rs.getString("memo"), rs.getString("status"), + rs.getObject("created_at", LocalDateTime.class))); + } + + public List recentSolves(int limit) { + return jdbc.query(""" + SELECT s.id, s.problem_id, s.user_id, u.name, s.answer_status, s.practiced_at + FROM problem_solve s + LEFT JOIN `user` u ON u.id = s.user_id + WHERE s.deleted_at IS NULL ORDER BY s.practiced_at DESC, s.id DESC LIMIT :limit + """, new MapSqlParameterSource("limit", limit), + (rs, i) -> new RecentSolve(rs.getLong("id"), rs.getLong("problem_id"), rs.getLong("user_id"), + rs.getString("name"), rs.getString("answer_status"), + rs.getObject("practiced_at", LocalDateTime.class))); + } + + public List recentFeedbacks(int limit) { + return jdbc.query(""" + SELECT id, nps_score, most_used_feature, pain_points, submitted_at FROM user_feedback + ORDER BY submitted_at DESC, id DESC LIMIT :limit + """, new MapSqlParameterSource("limit", limit), + (rs, i) -> new RecentFeedback(rs.getLong("id"), (Integer) rs.getObject("nps_score"), + rs.getString("most_used_feature"), rs.getString("pain_points"), + rs.getObject("submitted_at", LocalDateTime.class))); + } + + // ---------- 공통 ---------- + + private Map dailyCount(String table, String column, LocalDate start, LocalDate end) { + return daily("SELECT DATE(" + column + ") d, COUNT(*) c FROM " + table + + " WHERE deleted_at IS NULL AND " + column + " >= :start AND " + column + " < :end" + + " GROUP BY DATE(" + column + ")", + start, end, range(start, end)); + } + + /** 기록이 없는 날도 0 으로 채운다. 날짜가 비면 차트 선이 끊기고 표에서 날짜가 빠진다. */ + private Map daily(String sql, LocalDate start, LocalDate end, MapSqlParameterSource params) { + Map result = new LinkedHashMap<>(); + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + result.put(d, 0L); + } + jdbc.query(sql, params, rs -> { + LocalDate date = rs.getObject("d", LocalDate.class); + if (date != null && result.containsKey(date)) { + result.put(date, rs.getLong("c")); + } + }); + return result; + } + + private List labelCounts(String sql, MapSqlParameterSource params) { + return jdbc.query(sql, params, (rs, i) -> new LabelCount(rs.getString("label"), rs.getLong("c"))); + } + + private long count(String sql, MapSqlParameterSource params) { + Long value = jdbc.queryForObject(sql, params, Long.class); + return value == null ? 0L : value; + } + + /** 끝 날짜를 포함하도록 다음 날 0시를 배타 상한으로 쓴다. */ + private MapSqlParameterSource range(LocalDate start, LocalDate end) { + return new MapSqlParameterSource() + .addValue("start", start.atStartOfDay()) + .addValue("end", end.plusDays(1).atStartOfDay()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomLabels.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomLabels.java new file mode 100644 index 00000000..dcf17faa --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomLabels.java @@ -0,0 +1,119 @@ +package com.aisip.OnO.backend.admin.repository; + +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 스터디룸 enum 값을 관리자 화면에 보일 한국어로 바꾼다. + * + *

DB 에서 문자열로 바로 읽기 때문에 enum 에 없는 값(예전 배포에서 지운 상수)이 올 수 있다. + * 그때 예외를 던지지 않고 원래 값을 그대로 보여 준다. + */ +final class AdminStudyRoomLabels { + + private AdminStudyRoomLabels() { + } + + static String challengeType(String type) { + if (type == null) return "-"; + return switch (type) { + case "INDIVIDUAL" -> "개인"; + case "GROUP" -> "그룹"; + case "STREAK" -> "연속"; + default -> type; + }; + } + + static String challengeMetric(String metric) { + if (metric == null) return "-"; + return switch (metric) { + case "PROBLEM_COUNT" -> "오답노트 등록"; + case "PRACTICE_COUNT" -> "복습"; + case "ATTENDANCE" -> "출석"; + case "WEEKLY_PROBLEM_COUNT" -> "주간 오답노트 (구)"; + case "WEEKLY_PRACTICE_COUNT" -> "주간 복습 (구)"; + case "STREAK" -> "연속 기록 (구)"; + default -> metric; + }; + } + + static String challengePeriod(String period, Integer periodDays) { + if (periodDays != null && periodDays > 0) { + return periodDays + "일"; + } + if (period == null) return "-"; + return switch (period) { + case "DAILY" -> "하루"; + case "WEEKLY" -> "일주일"; + case "MONTHLY" -> "한 달"; + default -> period; + }; + } + + static String challengeStatus(String status) { + if (status == null) return "-"; + return switch (status) { + case "IN_PROGRESS" -> "진행 중"; + case "COMPLETED" -> "완료"; + case "FAILED" -> "실패"; + case "EXPIRED" -> "만료"; + default -> status; + }; + } + + static String feedEvent(String eventType) { + if (eventType == null) return "-"; + return switch (eventType) { + case "PROBLEM_REGISTERED" -> "오답노트 등록"; + case "PRACTICE_COMPLETED" -> "복습 완료"; + case "STREAK_MILESTONE" -> "연속 출석"; + case "LEVEL_UP" -> "레벨 업"; + case "CHALLENGE_CLEARED" -> "챌린지 달성"; + case "PROBLEM_SHARED" -> "문제 공유"; + default -> eventType; + }; + } + + static String ability(Object ability) { + if (ability == null) return "총 학습"; + return switch (String.valueOf(ability)) { + case "ATTENDANCE" -> "출석"; + case "NOTE_WRITE" -> "오답노트 작성"; + case "PROBLEM_PRACTICE" -> "문제 복습"; + case "NOTE_PRACTICE" -> "복습노트"; + default -> String.valueOf(ability); + }; + } + + /** 피드 메타데이터를 한 줄로 줄인다. 모르는 모양이면 키와 값을 그대로 늘어놓는다. */ + static String feedSummary(String eventType, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return ""; + } + Object count = metadata.get("count"); + switch (eventType == null ? "" : eventType) { + case "PROBLEM_REGISTERED": + if (count != null) { + Object subject = metadata.get("subject"); + return count + "개" + (subject == null ? "" : " (" + subject + ")"); + } + break; + case "PRACTICE_COMPLETED": + if (count != null) return count + "회"; + break; + case "STREAK_MILESTONE": + if (metadata.get("days") != null) return metadata.get("days") + "일째"; + break; + case "LEVEL_UP": + if (metadata.get("level") != null) { + return ability(metadata.get("ability")) + " Lv." + metadata.get("level"); + } + break; + default: + break; + } + return metadata.entrySet().stream() + .map(e -> e.getKey() + " " + e.getValue()) + .collect(Collectors.joining(", ")); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomQueryRepository.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomQueryRepository.java new file mode 100644 index 00000000..16f0543f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminStudyRoomQueryRepository.java @@ -0,0 +1,346 @@ +package com.aisip.OnO.backend.admin.repository; + +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Challenge; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Comment; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Feed; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.InviteCode; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Member; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.Overview; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.RoomHeader; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.RoomRow; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.SharedProblem; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomViews.WeeklyReport; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 관리자 스터디룸 화면 전용 조회. + * + *

화면 하나가 방, 멤버, 챌린지, 공유 문제, 댓글, 피드, 주간 리포트를 한꺼번에 보여 줘서 + * 엔티티를 따라 지연 로딩하면 쿼리가 행 수만큼 늘어난다. 그래서 필요한 칸만 SQL 로 한 번에 모아 읽는다. + * 읽기만 하고, 도메인 서비스의 규칙(권한 확인, 탈퇴 처리)은 거치지 않는다. + */ +@Repository +@RequiredArgsConstructor +public class AdminStudyRoomQueryRepository { + + private static final int FEED_LIMIT = 100; + + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final NamedParameterJdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public Overview overview(LocalDateTime weekStart) { + return jdbc.queryForObject(""" + SELECT + (SELECT COUNT(*) FROM study_room r WHERE r.deleted_at IS NULL) AS total_rooms, + (SELECT COUNT(DISTINCT f.room_id) FROM study_room_feed f + JOIN study_room r ON r.id = f.room_id AND r.deleted_at IS NULL + WHERE f.deleted_at IS NULL AND f.created_at >= :weekStart) AS active_rooms, + (SELECT COUNT(*) FROM study_room_member m + JOIN study_room r ON r.id = m.room_id AND r.deleted_at IS NULL + WHERE m.deleted_at IS NULL) AS total_members, + (SELECT COUNT(*) FROM study_room_shared_problem sp + JOIN study_room r ON r.id = sp.room_id AND r.deleted_at IS NULL + WHERE sp.deleted_at IS NULL) AS shared_problems, + (SELECT COUNT(*) FROM study_room_challenge c + JOIN study_room r ON r.id = c.room_id AND r.deleted_at IS NULL + WHERE c.deleted_at IS NULL AND c.status = 'IN_PROGRESS') AS in_progress_challenges + """, new MapSqlParameterSource("weekStart", weekStart), (rs, i) -> new Overview( + rs.getLong("total_rooms"), + rs.getLong("active_rooms"), + rs.getLong("total_members"), + rs.getLong("shared_problems"), + rs.getLong("in_progress_challenges") + )); + } + + public long countRooms() { + Long count = jdbc.queryForObject( + "SELECT COUNT(*) FROM study_room WHERE deleted_at IS NULL", + new MapSqlParameterSource(), Long.class); + return count == null ? 0 : count; + } + + /** + * 방 목록 한 페이지. 집계 칸은 방마다 상관 서브쿼리로 센다. + * 한 페이지가 많아야 수십 개라 방 전체를 GROUP BY 하는 것보다 싸다. + */ + public List findRooms(int offset, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("offset", offset) + .addValue("limit", limit); + return jdbc.query(""" + SELECT r.id, r.name, r.thumbnail_url, r.host_user_id, u.name AS host_name, r.created_at, + (SELECT COUNT(*) FROM study_room_member m + WHERE m.room_id = r.id AND m.deleted_at IS NULL) AS member_count, + (SELECT COUNT(*) FROM study_room_shared_problem sp + WHERE sp.room_id = r.id AND sp.deleted_at IS NULL) AS shared_count, + (SELECT COUNT(*) FROM study_room_shared_problem_comment c + JOIN study_room_shared_problem sp ON sp.id = c.shared_problem_id + WHERE sp.room_id = r.id AND sp.deleted_at IS NULL AND c.deleted_at IS NULL) AS comment_count, + (SELECT COUNT(*) FROM study_room_challenge ch + WHERE ch.room_id = r.id AND ch.deleted_at IS NULL AND ch.status = 'IN_PROGRESS') AS in_progress_count, + (SELECT COUNT(*) FROM study_room_challenge ch + WHERE ch.room_id = r.id AND ch.deleted_at IS NULL AND ch.status = 'COMPLETED') AS completed_count, + (SELECT MAX(f.created_at) FROM study_room_feed f + WHERE f.room_id = r.id AND f.deleted_at IS NULL) AS last_activity_at + FROM study_room r + LEFT JOIN user u ON u.id = r.host_user_id + WHERE r.deleted_at IS NULL + ORDER BY r.created_at DESC, r.id DESC + LIMIT :limit OFFSET :offset + """, params, (rs, i) -> new RoomRow( + rs.getLong("id"), + rs.getString("name"), + rs.getString("thumbnail_url"), + nullableLong(rs, "host_user_id"), + rs.getString("host_name"), + rs.getLong("member_count"), + rs.getLong("shared_count"), + rs.getLong("comment_count"), + rs.getLong("in_progress_count"), + rs.getLong("completed_count"), + rs.getObject("last_activity_at", LocalDateTime.class), + rs.getObject("created_at", LocalDateTime.class) + )); + } + + public Optional findRoom(Long roomId) { + return jdbc.query(""" + SELECT r.id, r.name, r.thumbnail_url, r.host_user_id, u.name AS host_name, r.created_at + FROM study_room r + LEFT JOIN user u ON u.id = r.host_user_id + WHERE r.id = :roomId AND r.deleted_at IS NULL + """, new MapSqlParameterSource("roomId", roomId), (rs, i) -> new RoomHeader( + rs.getLong("id"), + rs.getString("name"), + rs.getString("thumbnail_url"), + nullableLong(rs, "host_user_id"), + rs.getString("host_name"), + rs.getObject("created_at", LocalDateTime.class) + )).stream().findFirst(); + } + + public List findInviteCodes(Long roomId, LocalDateTime now) { + return jdbc.query(""" + SELECT code, created_at, expired_at + FROM study_room_invite_code + WHERE room_id = :roomId AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 5 + """, new MapSqlParameterSource("roomId", roomId), (rs, i) -> { + LocalDateTime expiredAt = rs.getObject("expired_at", LocalDateTime.class); + return new InviteCode( + rs.getString("code"), + rs.getObject("created_at", LocalDateTime.class), + expiredAt, + expiredAt != null && expiredAt.isBefore(now)); + }); + } + + public List findMembers(Long roomId) { + return jdbc.query(""" + SELECT m.user_id, u.name, u.email, m.role, m.weekly_goal, m.created_at, + (SELECT COUNT(*) FROM study_room_shared_problem sp + WHERE sp.room_id = m.room_id AND sp.shared_by_user_id = m.user_id + AND sp.deleted_at IS NULL) AS shared_count + FROM study_room_member m + LEFT JOIN user u ON u.id = m.user_id + WHERE m.room_id = :roomId AND m.deleted_at IS NULL + ORDER BY (m.role = 'HOST') DESC, m.created_at ASC + """, new MapSqlParameterSource("roomId", roomId), (rs, i) -> new Member( + rs.getLong("user_id"), + rs.getString("name"), + rs.getString("email"), + "HOST".equals(rs.getString("role")), + nullableInt(rs, "weekly_goal"), + rs.getObject("created_at", LocalDateTime.class), + rs.getLong("shared_count") + )); + } + + public List findChallenges(Long roomId) { + return jdbc.query(""" + SELECT c.id, c.title, c.type, c.metric, c.period, c.period_days, c.target_value, c.status, + c.start_at, c.end_at, c.completed_at, u.name AS created_by_name + FROM study_room_challenge c + LEFT JOIN user u ON u.id = c.created_by_user_id + WHERE c.room_id = :roomId AND c.deleted_at IS NULL + ORDER BY (c.status = 'IN_PROGRESS') DESC, c.end_at DESC, c.id DESC + """, new MapSqlParameterSource("roomId", roomId), (rs, i) -> { + String status = rs.getString("status"); + return new Challenge( + rs.getLong("id"), + rs.getString("title"), + AdminStudyRoomLabels.challengeType(rs.getString("type")), + AdminStudyRoomLabels.challengeMetric(rs.getString("metric")), + AdminStudyRoomLabels.challengePeriod(rs.getString("period"), nullableInt(rs, "period_days")), + nullableInt(rs, "target_value"), + status, + AdminStudyRoomLabels.challengeStatus(status), + rs.getObject("start_at", LocalDateTime.class), + rs.getObject("end_at", LocalDateTime.class), + rs.getObject("completed_at", LocalDateTime.class), + rs.getString("created_by_name")); + }); + } + + /** 공유 문제와 거기 달린 댓글. 댓글은 한 번에 읽어서 문제별로 나눠 붙인다. */ + public List findSharedProblems(Long roomId) { + MapSqlParameterSource params = new MapSqlParameterSource("roomId", roomId); + List sharedProblems = jdbc.query(""" + SELECT sp.id, sp.problem_id, sp.shared_by_user_id, u.name AS shared_by_name, sp.comment, sp.created_at, + (SELECT COUNT(*) FROM study_room_shared_problem_reaction re + WHERE re.shared_problem_id = sp.id AND re.deleted_at IS NULL) AS reaction_count, + (SELECT COUNT(*) FROM study_room_shared_problem_comment c + WHERE c.shared_problem_id = sp.id AND c.deleted_at IS NULL) AS comment_count + FROM study_room_shared_problem sp + LEFT JOIN user u ON u.id = sp.shared_by_user_id + WHERE sp.room_id = :roomId AND sp.deleted_at IS NULL + ORDER BY sp.created_at DESC, sp.id DESC + """, params, (rs, i) -> new SharedProblem( + rs.getLong("id"), + nullableLong(rs, "problem_id"), + nullableLong(rs, "shared_by_user_id"), + rs.getString("shared_by_name"), + rs.getString("comment"), + rs.getLong("reaction_count"), + rs.getLong("comment_count"), + rs.getObject("created_at", LocalDateTime.class), + List.of() + )); + if (sharedProblems.isEmpty()) { + return sharedProblems; + } + + Map> commentsBySharedProblem = jdbc.query(""" + SELECT c.shared_problem_id, c.author_id, u.name AS author_name, c.content, c.created_at, + (SELECT COUNT(*) FROM study_room_shared_problem_comment_reaction re + WHERE re.comment_id = c.id AND re.deleted_at IS NULL) AS reaction_count + FROM study_room_shared_problem_comment c + JOIN study_room_shared_problem sp ON sp.id = c.shared_problem_id + LEFT JOIN user u ON u.id = c.author_id + WHERE sp.room_id = :roomId AND sp.deleted_at IS NULL AND c.deleted_at IS NULL + ORDER BY c.created_at ASC, c.id ASC + """, params, (rs, i) -> new Comment( + rs.getLong("shared_problem_id"), + nullableLong(rs, "author_id"), + rs.getString("author_name"), + rs.getString("content"), + rs.getLong("reaction_count"), + rs.getObject("created_at", LocalDateTime.class) + )).stream().collect(Collectors.groupingBy(Comment::sharedProblemId)); + + return sharedProblems.stream() + .map(sp -> sp.withComments(commentsBySharedProblem.getOrDefault(sp.id(), List.of()))) + .toList(); + } + + public List findRecentFeeds(Long roomId) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("roomId", roomId) + .addValue("limit", FEED_LIMIT); + return jdbc.query(""" + SELECT f.id, f.event_type, f.metadata_json, f.user_id, u.name AS user_name, f.created_at, + (SELECT COUNT(*) FROM study_room_feed_reaction re + WHERE re.feed_id = f.id AND re.deleted_at IS NULL) AS reaction_count + FROM study_room_feed f + LEFT JOIN user u ON u.id = f.user_id + WHERE f.room_id = :roomId AND f.deleted_at IS NULL + ORDER BY f.created_at DESC, f.id DESC + LIMIT :limit + """, params, (rs, i) -> { + String eventType = rs.getString("event_type"); + return new Feed( + rs.getLong("id"), + AdminStudyRoomLabels.feedEvent(eventType), + nullableLong(rs, "user_id"), + rs.getString("user_name"), + AdminStudyRoomLabels.feedSummary(eventType, readMetadata(rs.getString("metadata_json"))), + rs.getLong("reaction_count"), + rs.getObject("created_at", LocalDateTime.class)); + }); + } + + public List findWeeklyReports(Long roomId) { + return jdbc.query(""" + SELECT w.week_start, w.week_end, w.top_member_name, w.top_member_problem_count, + w.longest_streak_name, w.longest_streak_days, w.total_problems, + w.challenges_completed, w.cheer_message, + (SELECT COUNT(*) FROM study_room_weekly_report_read rr + WHERE rr.report_id = w.id AND rr.deleted_at IS NULL) AS read_count + FROM study_room_weekly_report w + WHERE w.room_id = :roomId AND w.deleted_at IS NULL + ORDER BY w.week_start DESC + LIMIT 12 + """, new MapSqlParameterSource("roomId", roomId), (rs, i) -> new WeeklyReport( + rs.getObject("week_start", LocalDate.class), + rs.getObject("week_end", LocalDate.class), + rs.getString("top_member_name"), + nullableInt(rs, "top_member_problem_count"), + rs.getString("longest_streak_name"), + nullableInt(rs, "longest_streak_days"), + nullableInt(rs, "total_problems"), + nullableInt(rs, "challenges_completed"), + rs.getString("cheer_message"), + rs.getLong("read_count") + )); + } + + /** 방 안의 반응을 공유 문제, 댓글, 피드 가리지 않고 모두 센다. */ + public long countReactions(Long roomId) { + Long count = jdbc.queryForObject(""" + SELECT + (SELECT COUNT(*) FROM study_room_shared_problem_reaction re + JOIN study_room_shared_problem sp ON sp.id = re.shared_problem_id + WHERE sp.room_id = :roomId AND sp.deleted_at IS NULL AND re.deleted_at IS NULL) + + (SELECT COUNT(*) FROM study_room_shared_problem_comment_reaction re + JOIN study_room_shared_problem_comment c ON c.id = re.comment_id + JOIN study_room_shared_problem sp ON sp.id = c.shared_problem_id + WHERE sp.room_id = :roomId AND sp.deleted_at IS NULL + AND c.deleted_at IS NULL AND re.deleted_at IS NULL) + + (SELECT COUNT(*) FROM study_room_feed_reaction re + JOIN study_room_feed f ON f.id = re.feed_id + WHERE f.room_id = :roomId AND f.deleted_at IS NULL AND re.deleted_at IS NULL) + """, new MapSqlParameterSource("roomId", roomId), Long.class); + return count == null ? 0 : count; + } + + /** 메타데이터가 깨져 있어도 피드 한 줄 때문에 상세 화면 전체가 안 열리면 안 된다. */ + private Map readMetadata(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(json, MAP_TYPE); + } catch (Exception e) { + return Map.of(); + } + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static Integer nullableInt(ResultSet rs, String column) throws SQLException { + int value = rs.getInt(column); + return rs.wasNull() ? null : value; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/repository/AdminUserQueryRepository.java b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminUserQueryRepository.java new file mode 100644 index 00000000..dca97518 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/repository/AdminUserQueryRepository.java @@ -0,0 +1,511 @@ +package com.aisip.OnO.backend.admin.repository; + +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.admin.dto.AdminUserRows; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.mission.entity.MissionType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 관리자 유저 화면 전용 읽기 쿼리. + * + *

유저 한 명을 보려면 문제, 복습, 복습노트, 미션, 치장, 업적, 스터디룸까지 열 개 가까운 테이블을 + * 봐야 한다. 도메인 서비스마다 엔티티를 불러 모으면 연관을 따라가며 쿼리가 불어나서, 화면에 필요한 + * 열만 SQL 로 바로 읽는다. 쓰기는 하지 않는다. + */ +@Repository +@RequiredArgsConstructor +public class AdminUserQueryRepository { + + /** 정렬 파라미터는 사용자 입력이라 SQL 에 그대로 붙이지 않고 여기 있는 것만 받는다. */ + private static final Map SORT_COLUMNS = Map.of( + "createdAt", "u.created_at", + "lastActiveAt", "u.last_active_at", + "totalStudyLevel", "u.total_study_level", + "problemCount", "problem_count", + "solveCount", "solve_count", + "practiceNoteCount", "note_count", + "name", "u.name" + ); + + /** mission_log.mission_type 은 {@link MissionType} 의 ordinal 로 저장된다. */ + private static final Map MISSION_TYPE_LABELS = Map.of( + MissionType.USER_LOGIN, "출석", + MissionType.PROBLEM_WRITE, "오답노트 작성", + MissionType.PROBLEM_PRACTICE, "문제 복습", + MissionType.NOTE_PRACTICE, "복습노트 완료" + ); + + private static final int DETAIL_LIST_LIMIT = 300; + + private final NamedParameterJdbcTemplate jdbc; + + public static String resolveSortColumn(String sortBy) { + return SORT_COLUMNS.getOrDefault(sortBy, SORT_COLUMNS.get("createdAt")); + } + + // ---------- 목록 ---------- + + public List findUsers(String q, String platform, String sortBy, String direction, long offset, int limit) { + MapSqlParameterSource params = searchParams(q, platform) + .addValue("limit", limit) + .addValue("offset", offset); + String order = resolveSortColumn(sortBy) + ("asc".equalsIgnoreCase(direction) ? " ASC" : " DESC"); + + String sql = """ + SELECT u.id, u.name, u.email, u.platform, u.total_study_level, u.total_study_point, + u.last_active_at, u.created_at, + COALESCE(p.cnt, 0) AS problem_count, + COALESCE(s.cnt, 0) AS solve_count, + COALESCE(n.cnt, 0) AS note_count + FROM `user` u + LEFT JOIN (SELECT user_id, COUNT(*) cnt FROM problem WHERE deleted_at IS NULL GROUP BY user_id) p + ON p.user_id = u.id + LEFT JOIN (SELECT user_id, COUNT(*) cnt FROM problem_solve WHERE deleted_at IS NULL GROUP BY user_id) s + ON s.user_id = u.id + LEFT JOIN (SELECT user_id, COUNT(*) cnt FROM practice_note WHERE deleted_at IS NULL GROUP BY user_id) n + ON n.user_id = u.id + WHERE u.deleted_at IS NULL + """ + searchCondition(q, platform) + """ + ORDER BY %s, u.id DESC + LIMIT :limit OFFSET :offset + """.formatted(order); + + return jdbc.query(sql, params, (rs, i) -> new AdminUserRows.ListRow( + rs.getLong("id"), + rs.getString("name"), + rs.getString("email"), + rs.getString("platform"), + rs.getLong("total_study_level"), + rs.getLong("total_study_point"), + rs.getLong("problem_count"), + rs.getLong("solve_count"), + rs.getLong("note_count"), + toDateTime(rs.getTimestamp("last_active_at")), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + public long countUsers(String q, String platform) { + String sql = "SELECT COUNT(*) FROM `user` u WHERE u.deleted_at IS NULL " + searchCondition(q, platform); + Long count = jdbc.queryForObject(sql, searchParams(q, platform), Long.class); + return count == null ? 0 : count; + } + + public List findPlatforms() { + return jdbc.queryForList(""" + SELECT DISTINCT UPPER(platform) FROM `user` + WHERE deleted_at IS NULL AND platform IS NOT NULL AND platform <> '' + ORDER BY 1 + """, Map.of(), String.class); + } + + public AdminUserRows.Summary summarize(LocalDateTime todayStart, LocalDateTime weekStart) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("todayStart", todayStart) + .addValue("weekStart", weekStart); + return jdbc.queryForObject(""" + SELECT COUNT(*) AS total, + COALESCE(SUM(created_at >= :todayStart), 0) AS today, + COALESCE(SUM(UPPER(platform) = 'GUEST'), 0) AS guests, + (SELECT COUNT(DISTINCT user_id) FROM mission_log + WHERE deleted_at IS NULL AND mission_type = 0 AND created_at >= :weekStart) AS active7 + FROM `user` + WHERE deleted_at IS NULL + """, params, (rs, i) -> new AdminUserRows.Summary( + rs.getLong("total"), rs.getLong("today"), rs.getLong("guests"), rs.getLong("active7"))); + } + + private String searchCondition(String q, String platform) { + StringBuilder where = new StringBuilder(); + if (q != null) { + where.append(" AND (u.name LIKE :like OR u.email LIKE :like"); + if (parseId(q) != null) { + where.append(" OR u.id = :qid"); + } + where.append(")"); + } + if (platform != null) { + where.append(" AND UPPER(u.platform) = :platform"); + } + return where.append('\n').toString(); + } + + private MapSqlParameterSource searchParams(String q, String platform) { + MapSqlParameterSource params = new MapSqlParameterSource(); + if (q != null) { + params.addValue("like", "%" + escapeLike(q) + "%"); + params.addValue("qid", parseId(q)); + } + if (platform != null) { + params.addValue("platform", platform.toUpperCase()); + } + return params; + } + + private static Long parseId(String q) { + try { + return Long.parseLong(q); + } catch (NumberFormatException e) { + return null; + } + } + + /** 이름에 % 나 _ 가 들어간 유저를 찾을 때 와일드카드로 먹히지 않게 한다. */ + private static String escapeLike(String value) { + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + // ---------- 상세 ---------- + + public Optional findProfile(Long userId) { + List rows = jdbc.query(""" + SELECT id, name, email, platform, profile_image_url, notification_enabled, + last_active_at, last_notified_at, created_at, + attendance_level, attendance_point, note_write_level, note_write_point, + problem_practice_level, problem_practice_point, note_practice_level, note_practice_point, + total_study_level, total_study_point + FROM `user` + WHERE id = :userId AND deleted_at IS NULL + """, Map.of("userId", userId), (rs, i) -> new AdminUserRows.Profile( + rs.getLong("id"), + rs.getString("name"), + rs.getString("email"), + rs.getString("platform"), + rs.getString("profile_image_url"), + rs.getBoolean("notification_enabled"), + toDateTime(rs.getTimestamp("last_active_at")), + toDate(rs.getDate("last_notified_at")), + toDateTime(rs.getTimestamp("created_at")), + level(rs, "attendance"), + level(rs, "note_write"), + level(rs, "problem_practice"), + level(rs, "note_practice"), + level(rs, "total_study") + )); + return rows.stream().findFirst(); + } + + public AdminUserRows.Counts countOwnedData(Long userId) { + return jdbc.queryForObject(""" + SELECT + (SELECT COUNT(*) FROM problem WHERE user_id = :userId AND deleted_at IS NULL) AS problems, + (SELECT COUNT(*) FROM problem_solve WHERE user_id = :userId AND deleted_at IS NULL) AS solves, + (SELECT COUNT(*) FROM problem_solve + WHERE user_id = :userId AND deleted_at IS NULL AND answer_status = 'CORRECT') AS correct_solves, + (SELECT COUNT(*) FROM practice_note WHERE user_id = :userId AND deleted_at IS NULL) AS notes, + (SELECT COUNT(*) FROM folder WHERE user_id = :userId AND deleted_at IS NULL) AS folders, + (SELECT COUNT(*) FROM tag WHERE user_id = :userId AND deleted_at IS NULL) AS tags, + (SELECT COUNT(*) FROM study_room_member WHERE user_id = :userId AND deleted_at IS NULL) AS rooms, + (SELECT COUNT(*) FROM user_achievement WHERE user_id = :userId) AS achievements, + (SELECT COUNT(*) FROM fcm_token WHERE user_id = :userId AND deleted_at IS NULL) AS fcm_tokens, + (SELECT COUNT(DISTINCT DATE(created_at)) FROM mission_log + WHERE user_id = :userId AND deleted_at IS NULL AND mission_type = 0) AS login_days + """, Map.of("userId", userId), (rs, i) -> new AdminUserRows.Counts( + rs.getLong("problems"), + rs.getLong("solves"), + rs.getLong("correct_solves"), + rs.getLong("notes"), + rs.getLong("folders"), + rs.getLong("tags"), + rs.getLong("rooms"), + rs.getLong("achievements"), + rs.getLong("fcm_tokens"), + rs.getLong("login_days") + )); + } + + /** 연속 출석을 계산하려고 최근 출석한 날짜를 최신순으로 가져온다. */ + public List findRecentLoginDates(Long userId, int limit) { + return jdbc.query(""" + SELECT DISTINCT DATE(created_at) AS d FROM mission_log + WHERE user_id = :userId AND deleted_at IS NULL AND mission_type = 0 + ORDER BY d DESC + LIMIT :limit + """, new MapSqlParameterSource("userId", userId).addValue("limit", limit), + (rs, i) -> rs.getDate("d").toLocalDate()); + } + + public List findProblems(Long userId) { + return jdbc.query(""" + SELECT p.id, p.memo, p.reference, f.name AS folder_name, a.status, a.subject, + COALESCE(s.cnt, 0) AS solve_count, p.next_review_at, p.created_at + FROM problem p + LEFT JOIN folder f ON f.id = p.folder_id AND f.deleted_at IS NULL + LEFT JOIN problem_analysis a ON a.problem_id = p.id AND a.deleted_at IS NULL + LEFT JOIN (SELECT problem_id, COUNT(*) cnt FROM problem_solve + WHERE user_id = :userId AND deleted_at IS NULL GROUP BY problem_id) s + ON s.problem_id = p.id + WHERE p.user_id = :userId AND p.deleted_at IS NULL + ORDER BY p.created_at DESC, p.id DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.ProblemRow( + rs.getLong("id"), + rs.getString("memo"), + rs.getString("reference"), + rs.getString("folder_name"), + rs.getString("status"), + rs.getString("subject"), + rs.getLong("solve_count"), + toDate(rs.getDate("next_review_at")), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + public List findSolves(Long userId) { + return jdbc.query(""" + SELECT s.id, s.problem_id, p.reference, s.answer_status, s.time_spent_seconds, + s.reflection, s.mood_emoji_key, s.practiced_at + FROM problem_solve s + LEFT JOIN problem p ON p.id = s.problem_id + WHERE s.user_id = :userId AND s.deleted_at IS NULL + ORDER BY s.practiced_at DESC, s.id DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.SolveRow( + rs.getLong("id"), + rs.getLong("problem_id"), + rs.getString("reference"), + rs.getString("answer_status"), + nullableInt(rs, "time_spent_seconds"), + rs.getString("reflection"), + rs.getString("mood_emoji_key"), + toDateTime(rs.getTimestamp("practiced_at")) + )); + } + + public List findPracticeNotes(Long userId) { + return jdbc.query(""" + SELECT n.id, n.title, COALESCE(m.cnt, 0) AS problem_count, n.practice_count, + n.last_solved_at, n.repeat_type, n.created_at + FROM practice_note n + LEFT JOIN (SELECT practice_note_id, COUNT(*) cnt FROM problem_practice_note_mapping + WHERE deleted_at IS NULL GROUP BY practice_note_id) m + ON m.practice_note_id = n.id + WHERE n.user_id = :userId AND n.deleted_at IS NULL + ORDER BY n.created_at DESC, n.id DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.PracticeNoteRow( + rs.getLong("id"), + rs.getString("title"), + rs.getLong("problem_count"), + (Long) rs.getObject("practice_count", Long.class), + toDateTime(rs.getTimestamp("last_solved_at")), + rs.getString("repeat_type"), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + public List findFolders(Long userId) { + return jdbc.query(""" + SELECT f.id, f.name, parent.name AS parent_name, COALESCE(p.cnt, 0) AS problem_count, f.created_at + FROM folder f + LEFT JOIN folder parent ON parent.id = f.parent_folder_id + LEFT JOIN (SELECT folder_id, COUNT(*) cnt FROM problem + WHERE user_id = :userId AND deleted_at IS NULL GROUP BY folder_id) p + ON p.folder_id = f.id + WHERE f.user_id = :userId AND f.deleted_at IS NULL + ORDER BY f.created_at, f.id + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.FolderRow( + rs.getLong("id"), + rs.getString("name"), + rs.getString("parent_name"), + rs.getLong("problem_count"), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + public List findTags(Long userId) { + return jdbc.query(""" + SELECT t.id, t.name, COUNT(m.id) AS problem_count + FROM tag t + LEFT JOIN problem_tag_mapping m ON m.tag_id = t.id AND m.deleted_at IS NULL + WHERE t.user_id = :userId AND t.deleted_at IS NULL + GROUP BY t.id, t.name + ORDER BY problem_count DESC, t.name + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.TagRow( + rs.getLong("id"), rs.getString("name"), rs.getLong("problem_count"))); + } + + public List findMissionProgress(Long userId) { + return jdbc.query(""" + SELECT d.code, d.title, d.category, mp.period_key, mp.current_value, mp.target_snapshot, + mp.completed_at, mp.claimed_at, + COALESCE(mp.reward_type_snapshot, d.reward_type) AS reward_type, + COALESCE(mp.reward_value_snapshot, d.reward_value) AS reward_value + FROM mission_progress mp + LEFT JOIN mission_definition d ON d.id = mp.mission_id + WHERE mp.user_id = :userId AND mp.deleted_at IS NULL + ORDER BY mp.period_key DESC, d.sort_order, mp.id DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.MissionProgressRow( + rs.getString("code"), + rs.getString("title"), + rs.getString("category"), + rs.getString("period_key"), + rs.getInt("current_value"), + rs.getInt("target_snapshot"), + toDateTime(rs.getTimestamp("completed_at")), + toDateTime(rs.getTimestamp("claimed_at")), + rs.getString("reward_type"), + nullableInt(rs, "reward_value") + )); + } + + public List findMissionLogs(Long userId) { + MissionType[] types = MissionType.values(); + return jdbc.query(""" + SELECT id, mission_type, point, reference_id, created_at + FROM mission_log + WHERE user_id = :userId AND deleted_at IS NULL + ORDER BY created_at DESC, id DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> { + int ordinal = rs.getInt("mission_type"); + String label = ordinal >= 0 && ordinal < types.length + ? MISSION_TYPE_LABELS.getOrDefault(types[ordinal], types[ordinal].name()) + : String.valueOf(ordinal); + return new AdminUserRows.MissionLogRow( + rs.getLong("id"), + label, + (Long) rs.getObject("point", Long.class), + (Long) rs.getObject("reference_id", Long.class), + toDateTime(rs.getTimestamp("created_at")) + ); + }); + } + + public List findStudyRooms(Long userId) { + return jdbc.query(""" + SELECT r.id, r.name, m.role, m.weekly_goal, m.created_at, + (SELECT COUNT(*) FROM study_room_member x + WHERE x.room_id = r.id AND x.deleted_at IS NULL) AS member_count + FROM study_room_member m + JOIN study_room r ON r.id = m.room_id AND r.deleted_at IS NULL + WHERE m.user_id = :userId AND m.deleted_at IS NULL + ORDER BY m.created_at DESC + LIMIT :limit + """, detailParams(userId), (rs, i) -> new AdminUserRows.StudyRoomRow( + rs.getLong("id"), + rs.getString("name"), + rs.getString("role"), + rs.getLong("member_count"), + nullableInt(rs, "weekly_goal"), + toDateTime(rs.getTimestamp("created_at")) + )); + } + + /** 착용 중인 치장. 슬롯 순서는 화면에 겹쳐 그리는 순서(layer_order)를 따른다. */ + public List findEquippedCosmetics(Long userId) { + RowMapper mapper = (rs, i) -> { + String slot = rs.getString("slot"); + return new AdminUserRows.CosmeticRow( + slot, + slotName(slot), + rs.getString("item_key"), + rs.getString("name_ko"), + rs.getString("set_name_ko"), + toDateTime(rs.getTimestamp("updated_at")) + ); + }; + List rows = jdbc.query(""" + SELECT l.slot, l.item_key, c.name_ko, c.set_name_ko, l.updated_at + FROM user_cosmetic_loadout l + LEFT JOIN cosmetic_item c ON c.item_key = l.item_key + WHERE l.user_id = :userId + """, Map.of("userId", userId), mapper); + return rows.stream() + .sorted(java.util.Comparator.comparingInt(row -> slotOrder(row.slot()))) + .toList(); + } + + /** 업적 이름은 DB 가 아니라 {@link Achievement} 에 있다. 모르는 키도 버리지 않고 키 그대로 보여 준다. */ + public List findAchievements(Long userId) { + return jdbc.query(""" + SELECT achievement_key, earned_at FROM user_achievement + WHERE user_id = :userId + ORDER BY earned_at DESC + """, Map.of("userId", userId), (rs, i) -> { + String key = rs.getString("achievement_key"); + Optional achievement = Achievement.fromKey(key); + return new AdminUserRows.AchievementRow( + key, + achievement.map(Achievement::getNameKo).orElse(key), + achievement.map(Achievement::getDescriptionKo).orElse(null), + toDateTime(rs.getTimestamp("earned_at")) + ); + }); + } + + public List findMoods(Long userId, int limit) { + return jdbc.query(""" + SELECT study_date, emoji_key FROM learning_calendar_mood + WHERE user_id = :userId AND deleted_at IS NULL + ORDER BY study_date DESC + LIMIT :limit + """, new MapSqlParameterSource("userId", userId).addValue("limit", limit), + (rs, i) -> new AdminUserRows.MoodRow(toDate(rs.getDate("study_date")), rs.getString("emoji_key"))); + } + + // ---------- 변환 ---------- + + private static MapSqlParameterSource detailParams(Long userId) { + return new MapSqlParameterSource("userId", userId).addValue("limit", DETAIL_LIST_LIMIT); + } + + public static int detailListLimit() { + return DETAIL_LIST_LIMIT; + } + + private static AdminUserRows.Level level(ResultSet rs, String prefix) throws SQLException { + return new AdminUserRows.Level( + (Long) rs.getObject(prefix + "_level", Long.class), + (Long) rs.getObject(prefix + "_point", Long.class) + ); + } + + /** COALESCE 나 드라이버 설정에 따라 정수 컬럼이 Long 으로 올 수 있어 숫자로 받아 변환한다. */ + private static Integer nullableInt(ResultSet rs, String column) throws SQLException { + Object value = rs.getObject(column); + return value == null ? null : ((Number) value).intValue(); + } + + private static String slotName(String slot) { + try { + return CosmeticSlot.valueOf(slot).getNameKo(); + } catch (IllegalArgumentException | NullPointerException e) { + return slot; + } + } + + private static int slotOrder(String slot) { + try { + return CosmeticSlot.valueOf(slot).getLayerOrder(); + } catch (IllegalArgumentException | NullPointerException e) { + return Integer.MAX_VALUE; + } + } + + private static LocalDateTime toDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } + + private static LocalDate toDate(Date date) { + return date == null ? null : date.toLocalDate(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/service/AdminStatsService.java b/src/main/java/com/aisip/OnO/backend/admin/service/AdminStatsService.java new file mode 100644 index 00000000..f00fa407 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/service/AdminStatsService.java @@ -0,0 +1,241 @@ +package com.aisip.OnO.backend.admin.service; + +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.AnalysisStats; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.DailyRow; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.GrowthStats; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.LabelCount; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.LearningStats; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.Metric; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentFeedback; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentProblem; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentSolve; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RecentUser; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.RoomStats; +import com.aisip.OnO.backend.admin.dto.AdminStatsDto.UserStats; +import com.aisip.OnO.backend.admin.repository.AdminStatsQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 통계 화면에 쓰는 지표를 모은다. + * + *

증감률은 선택한 기간과 바로 앞의 같은 길이 기간을 비교한다. 7일을 보고 있으면 그 전 7일과 비교한다. + */ +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminStatsService { + + private final AdminStatsQueryRepository statsQueryRepository; + + public Period period(LocalDate start, LocalDate end) { + return new Period(start, end); + } + + public Daily daily(LocalDate start, LocalDate end) { + return new Daily( + statsQueryRepository.dailyActiveUsers(start, end), + statsQueryRepository.dailyNewUsers(start, end), + statsQueryRepository.dailyProblems(start, end), + statsQueryRepository.dailySolves(start, end), + statsQueryRepository.dailyPracticeNotes(start, end), + statsQueryRepository.dailyMissionsCompleted(start, end), + statsQueryRepository.dailyRoomActivity(start, end) + ); + } + + public UserStats userStats(Period period, Daily daily, LocalDate today) { + Period previous = period.previous(); + long days = period.days(); + double averageDau = (double) sum(daily.activeUsers()) / days; + double previousAverageDau = (double) sum(statsQueryRepository.dailyActiveUsers(previous.start(), previous.end())) / days; + + long wau = statsQueryRepository.countActiveUsers(period.end().minusDays(6), period.end()); + long mau = statsQueryRepository.countActiveUsers(period.end().minusDays(29), period.end()); + + return new UserStats( + statsQueryRepository.countUsers(), + statsQueryRepository.countGuestUsers(), + statsQueryRepository.countNotificationEnabledUsers(), + statsQueryRepository.countUsersWithFcmToken(), + new Metric(sum(daily.newUsers()), statsQueryRepository.countSignups(previous.start(), previous.end())), + new Metric(statsQueryRepository.countActiveUsers(period.start(), period.end()), + statsQueryRepository.countActiveUsers(previous.start(), previous.end())), + new Metric(averageDau, previousAverageDau), + wau, + mau, + mau == 0 ? 0.0 : averageDau * 100.0 / mau, + statsQueryRepository.retention(period.start(), period.end(), 1, today), + statsQueryRepository.retention(period.start(), period.end(), 7, today), + statsQueryRepository.signupsByPlatform(period.start(), period.end()) + ); + } + + public LearningStats learningStats(Period period, Daily daily) { + Period previous = period.previous(); + Map answers = statsQueryRepository.solvesByAnswerStatus(period.start(), period.end()); + return new LearningStats( + statsQueryRepository.countAll("problem"), + statsQueryRepository.countAll("problem_solve"), + statsQueryRepository.countAll("practice_note"), + new Metric(sum(daily.problems()), statsQueryRepository.countProblems(previous.start(), previous.end())), + statsQueryRepository.countProblemWriters(period.start(), period.end()), + new Metric(sum(daily.solves()), statsQueryRepository.countSolves(previous.start(), previous.end())), + statsQueryRepository.countSolvers(period.start(), period.end()), + answers.getOrDefault("CORRECT", 0L), + answers.getOrDefault("WRONG", 0L), + answers.getOrDefault("PARTIAL", 0L), + statsQueryRepository.averageSolveSeconds(period.start(), period.end()), + statsQueryRepository.countSolvesWithReflection(period.start(), period.end()), + new Metric(sum(daily.practiceNotes()), statsQueryRepository.countPracticeNotes(previous.start(), previous.end())), + statsQueryRepository.countPracticeNoteFirstCompletions(period.start(), period.end()), + statsQueryRepository.countInRange("tag", "created_at", period.start(), period.end()), + statsQueryRepository.countInRange("folder", "created_at", period.start(), period.end()), + statsQueryRepository.countInRange("learning_calendar_mood", "created_at", period.start(), period.end()), + statsQueryRepository.countSolveMoods(period.start(), period.end()) + ); + } + + public AnalysisStats analysisStats(Period period) { + List all = statsQueryRepository.analysisStatuses(); + List inPeriod = statsQueryRepository.analysisStatuses(period.start(), period.end()); + return new AnalysisStats( + all, + inPeriod, + all.stream().mapToLong(LabelCount::count).sum(), + inPeriod.stream().mapToLong(LabelCount::count).sum(), + statsQueryRepository.analysisSubjects(period.start(), period.end(), 8) + ); + } + + public GrowthStats growthStats(Period period, Daily daily) { + List achievements = statsQueryRepository.achievementsEarned(period.start(), period.end()).stream() + .map(row -> new LabelCount( + Achievement.fromKey(row.label()).map(Achievement::getNameKo).orElse(row.label()), + row.count())) + .toList(); + return new GrowthStats( + sum(daily.missionsCompleted()), + statsQueryRepository.countMissionsClaimed(period.start(), period.end()), + statsQueryRepository.topCompletedMissions(period.start(), period.end(), 6), + achievements.stream().mapToLong(LabelCount::count).sum(), + achievements.stream().limit(6).toList(), + statsQueryRepository.countCosmeticUsers(), + statsQueryRepository.levelDistribution() + ); + } + + public RoomStats roomStats(Period period) { + LocalDate start = period.start(); + LocalDate end = period.end(); + return new RoomStats( + statsQueryRepository.countAll("study_room"), + statsQueryRepository.countInRange("study_room", "created_at", start, end), + statsQueryRepository.countInRange("study_room_member", "created_at", start, end), + statsQueryRepository.countInRange("study_room_shared_problem", "created_at", start, end), + statsQueryRepository.countInRange("study_room_shared_problem_comment", "created_at", start, end), + statsQueryRepository.countRoomReactions(start, end), + statsQueryRepository.countInRange("study_room_challenge", "created_at", start, end), + statsQueryRepository.countChallengesCompleted(start, end), + statsQueryRepository.countChallengesFailed(start, end) + ); + } + + /** 관리자 홈의 오늘 현황. 어제와 비교한다. */ + public Home home(LocalDate today) { + LocalDate yesterday = today.minusDays(1); + Map recentDau = statsQueryRepository.dailyActiveUsers(today.minusDays(13), today); + return new Home( + new Metric(recentDau.get(today), recentDau.get(yesterday)), + new Metric(statsQueryRepository.countSignups(today, today), statsQueryRepository.countSignups(yesterday, yesterday)), + new Metric(statsQueryRepository.countProblems(today, today), statsQueryRepository.countProblems(yesterday, yesterday)), + new Metric(statsQueryRepository.countSolves(today, today), statsQueryRepository.countSolves(yesterday, yesterday)), + recentDau, + recentDau.values().stream().mapToLong(Long::longValue).max().orElse(0L), + statsQueryRepository.recentUsers(8), + statsQueryRepository.recentProblems(8), + statsQueryRepository.recentSolves(8), + statsQueryRepository.recentFeedbacks(3), + statsQueryRepository.analysisStatuses() + ); + } + + public static long sum(Map daily) { + return daily.values().stream().mapToLong(Long::longValue).sum(); + } + + public record Home( + Metric activeUsers, + Metric signups, + Metric problems, + Metric solves, + Map recentDau, + long recentDauMax, + List recentUsers, + List recentProblems, + List recentSolves, + List recentFeedbacks, + List analysisStatuses + ) { + + public long analysisCount(String status) { + return analysisStatuses.stream() + .filter(s -> s.label().equals(status)) + .mapToLong(LabelCount::count) + .sum(); + } + + /** CSS 막대 높이(%). 최댓값이 0 이면 모든 막대를 바닥에 둔다. */ + public double barHeight(long value) { + return recentDauMax == 0 ? 0.0 : value * 100.0 / recentDauMax; + } + } + + public record Period(LocalDate start, LocalDate end) { + + public long days() { + return ChronoUnit.DAYS.between(start, end) + 1; + } + + public Period previous() { + return new Period(start.minusDays(days()), start.minusDays(1)); + } + } + + public record Daily( + Map activeUsers, + Map newUsers, + Map problems, + Map solves, + Map practiceNotes, + Map missionsCompleted, + Map roomActivity + ) { + + /** 표는 최근 날짜가 위로 오게 뒤집어서 준다. */ + public List rowsNewestFirst() { + List rows = new ArrayList<>(); + for (LocalDate date : activeUsers.keySet()) { + rows.add(new DailyRow(date, + activeUsers.get(date), newUsers.get(date), problems.get(date), solves.get(date), + practiceNotes.get(date), missionsCompleted.get(date), roomActivity.get(date))); + } + Collections.reverse(rows); + return rows; + } + + public DailyRow totals() { + return new DailyRow(null, sum(activeUsers), sum(newUsers), sum(problems), sum(solves), + sum(practiceNotes), sum(missionsCompleted), sum(roomActivity)); + } + } +} diff --git a/src/main/resources/templates/admin-achievements.html b/src/main/resources/templates/admin-achievements.html new file mode 100644 index 00000000..f3a23077 --- /dev/null +++ b/src/main/resources/templates/admin-achievements.html @@ -0,0 +1,97 @@ + + + + +

+ + +
+
+
+

업적

+

훈장마다 몇 명이 받았는지, 최근에 누가 받았는지 볼 수 있어요.

+
+
+ +
+
+
받은 훈장
+
0개
+
유저별 훈장 수를 모두 더한 값
+
+
+
하나 이상 받은 유저
+
0명
+
전체 0명 중 0%
+
+
+
최근 7일 획득
+
0개
+
+
+ +
+
+

훈장별 획득 탈퇴한 유저와 관리자 계정은 빼고 셉니다

+
+
+
+
첫
+
+
첫 걸음
+
설명
+
+
+
+
+ 0명 + 0% +
+
-
+
+
+
+
+ +
+

최근 받은 훈장

+
+
+ + + + + + + + + + + + + + + +
받은 시각유저훈장
2026-09-21유저첫 걸음
+
+
아직 받은 훈장이 없어요
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/admin-cosmetics.html b/src/main/resources/templates/admin-cosmetics.html new file mode 100644 index 00000000..1fc1f736 --- /dev/null +++ b/src/main/resources/templates/admin-cosmetics.html @@ -0,0 +1,112 @@ + + + + +
+ + +
+
+
+

치장

+

아이템마다 해금 조건과 지금 착용 중인 유저 수를 볼 수 있어요.

+
+
+ +
+
+
전체 아이템
+
0개
+
활성 0개
+
+
+
꾸민 유저
+
0명
+
한 자리라도 착용한 유저
+
+
+
가장 많이 착용한 아이템
+
-
+
0명
+
+
+ +
+
+

자리별 착용 착용 중인 유저 수

+
+
+ 머리 +
+
+
+ 0 +
+
+
+
+

세트 0개

+
+
+
+
세트
+
set, 아이템 3개
+
+ 착용 0 +
+
+
세트로 묶인 아이템이 없어요
+
+
+ +
+
+

아이템

+
+ 전체 + 머리 +
+
+ +
+
+ 머리 + 3개 +
+
+
+
+ + item_key +
+
이름
+
총 학습 Lv.2
+
세트
+
+ 착용 0 + 비활성 + 전신 +
+
+
+
+
등록된 아이템이 없어요
+
+
+
+ + + + diff --git a/src/main/resources/templates/admin-missions.html b/src/main/resources/templates/admin-missions.html new file mode 100644 index 00000000..5c0fd107 --- /dev/null +++ b/src/main/resources/templates/admin-missions.html @@ -0,0 +1,142 @@ + + + + +
+ + +
+
+
+

미션

+

오늘과 이번 주 기준

+
+
+ +
+
+
운영 중인 미션
+
0개
+
+
+
오늘 달성
+
0건
+
+
+
오늘 보상 받음
+
0건
+
+
+
최근 7일 보상 받음
+
0건
+
+
+ + +
+
+

일일 미션

+ 기간 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
미션지표목표보상이번 기간 진행달성보상 받음달성률누적 달성누적 받음
+
+ + 제목 + 꺼짐 + + 설명 +
+
출석110 XP000 +
+
+ 0% +
+
00
+
+
등록된 미션이 없어요
+
+
+
+ +
+
+

최근 30일 달성과 보상 날짜별 건수

+
+ 달성 + 보상 받음 +
+
+
+ 09-21 +
+
+
+
+ 0 / 0 +
+
+
+
+

예전 방식 적립 최근 30일, mission_log 기준

+

미션이 생기기 전부터 쓰던 적립 기록이에요. 복습노트는 노트마다 처음 완료할 때 한 번만 쌓여요.

+
+
+
+
출석
+
적립 0pt
+
+ 0건 +
+
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/admin-practice-note-detail.html b/src/main/resources/templates/admin-practice-note-detail.html new file mode 100644 index 00000000..a1df5d3b --- /dev/null +++ b/src/main/resources/templates/admin-practice-note-detail.html @@ -0,0 +1,133 @@ + + + + +
+ +
+ +
+
+ 복습노트 +

복습노트

+

#1

+
+
+ +
+
+
문제
+
0개
+
+
+
복습 횟수
+
0회
+
+
+
마지막 복습
+
-
+
날짜
+
+
+
마지막 세션 기분
+
-
+
+
+ +
+
+

노트 정보

+
+
유저
+
+ 이름 + ID +
+
이메일
+
email
+
만든 날
+
날짜
+
+
+
+

복습 알림

+
+
알림
+
매일 21:00
+
반복
+
+ 매일 + 요일마다 + - +
+
간격
+
-
+
+

알림을 켜지 않았어요.

+
+
+ +
+

들어 있는 문제

0개
+
+
+ + + + + + + + + + + + + + + +
ID출처 / 메모과목복습마지막 결과마지막 복습등록일
1 +
출처
+
메모
+
수학0-날짜
+
문제가 없는 복습노트예요.
+
+
+
+ +
+
+

최근 복습 기록

+ 이 유저의 전체 기록 보기 +
+
+
+ + + + + + + + + + + + + + +
복습 시각문제결과소요 시간회고기분
날짜문제-회고기분
+
이 노트의 문제로 남긴 복습 기록이 없어요.
+
+
+
+ +
+
+ + + + diff --git a/src/main/resources/templates/admin-study-room-detail.html b/src/main/resources/templates/admin-study-room-detail.html index 707eaee3..1c1490e7 100644 --- a/src/main/resources/templates/admin-study-room-detail.html +++ b/src/main/resources/templates/admin-study-room-detail.html @@ -1,148 +1,262 @@ - - - - 스터디룸 상세 — OnO Admin - - - + + +
+ - -
-
- - - - - -

스터디룸 이름

- # +
+
+
+ 스터디룸 +

방 이름

+

+ #1 + 방장 + 방장 + , 2026-09-01에 만들었어요 +

+
+
+ 초대 코드 + ABC123 + 만료 + 09-30까지 +
-
-
+
+
+
멤버
+
0명
+
+
+
공유 문제
+
0개
+
+
+
댓글
+
0개
+
+
+
반응
+
0개
+
공유 문제, 댓글, 피드에 남긴 이모지
+
+
+
챌린지
+
0개
+
진행 0, 완료 0
+
+
- -
-

- 기본 정보 -

-
-
-
룸 ID
-
-
-
-
방장 ID
-
-
-
-
생성일
-
-
-
-
전체 멤버
-
-
-
-
공유 문제 수
-
+
+
+ + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +
유저역할주간 목표공유한 문제들어온 날
+
+ 이름 + email +
+
방장멤버5개02026-09-01
+
+
멤버가 없어요
-
-
전체 챌린지
-
+
+ +
-
+ - -
-
- -

멤버 목록

- -
- - - - - - - - - - - - - - - - - - - - -
유저 ID이름역할참여일
- 방장 - 멤버 -
멤버가 없습니다.
-
+ - -
-
- -

챌린지 목록

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
ID제목유형지표목표상태기간
- - - - - - ~ - -
챌린지가 없습니다.
-
-
+ + +
+
+ + diff --git a/src/main/resources/templates/admin-study-rooms.html b/src/main/resources/templates/admin-study-rooms.html index e614db6a..2e9b63b9 100644 --- a/src/main/resources/templates/admin-study-rooms.html +++ b/src/main/resources/templates/admin-study-rooms.html @@ -1,88 +1,106 @@ - - - - 스터디룸 관리 — OnO Admin - - - + + +
+ - -
-
-
- - - - - -

스터디룸 관리

+
+
+
+

스터디룸

+

방마다 누가 모여서 무엇을 하고 있는지 볼 수 있어요.

- 전체 0 개
-
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - -
ID룸 이름멤버 수공유 문제 수생성일상세
- - - - - 명 - - - 상세 보기 → -
스터디룸이 없습니다.
+
+
+
전체 방
+
0개
+
+
+
이번 주 활동한 방
+
0개
+
월요일부터 피드가 한 번이라도 쌓인 방
+
+
+
참여 중인 멤버
+
0명
+
여러 방에 들어간 유저는 방마다 셉니다
+
+
+
공유된 문제
+
0개
+
+
+
진행 중인 챌린지
+
0개
+
- -
- « - - - - - - » +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
방방장멤버공유 문제댓글챌린지최근 활동만든 날
+
+ + + +
+ 방 이름 + #1 +
+
+
+ 방장 + - + 100 + 진행 1 + 완료 1 + 0 + + 2026-09-21 10:00 + 활동 없음 + 2026-09-01
+
+
아직 만들어진 스터디룸이 없어요
+
+
-
+
+ + diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 96eef239..a56e88e3 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -1,172 +1,156 @@ - - - - - OnO 관리자 - 대시보드 - - - - - - -
- -
-

관리자 대시보드

-

유저, 문제, 통계를 관리할 수 있습니다.

+
+
+
+
+
- -
- - -
-
-
- - - -
- - - -
-

유저 관리

-

모든 가입 유저 보기 및 관리

+
+ - - - - - -
-
-
- - - -
- - - +
- - -
-
-
- - - + - - -
-
- - - - + + + diff --git a/src/main/resources/templates/analysis.html b/src/main/resources/templates/analysis.html index 78e97b86..2c69e4ad 100644 --- a/src/main/resources/templates/analysis.html +++ b/src/main/resources/templates/analysis.html @@ -1,563 +1,476 @@ - - - - - OnO 관리자 - 통계 - - - - - + + + +
+ - -
- -
-

시스템 통계

-

OnO 애플리케이션 통계 개요

+
+ +
+
+

통계

+

+ 2026. 8. 23 ~ 2026. 9. 21 (30일)

+
+
-
-
-
-

기간별 통계 조회

-

- 2024-01-01 - 부터 - 2024-01-30 - 까지 -

-
- + +
+ +
-
-
- -
- -
-
-
- -
- -
-
- +
+ +
+ + 최대 366일까지 볼 수 있어요 - -
- -
-
-
-

총 유저 수

-

0

-
-
- - - -
-
- -
+ +
+
+
+
+
+
+
+
- -
-
-
-

총 문제 수

-

0

-
-
- - - -
-
- +
+
+

활성 유저와 신규 가입 일별

+
- - -
-
-
-

선택 기간 문제 등록 수

-

0

-
-
- - - -
-
-
-

선택 기간 작성된 문제

-
+
+

오답노트와 복습 일별

+
+
-
-
-
-

선택 기간 분석 실패율

-

0.0%

-
-
- - - -
-
-
-

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

-
+
+
+ + + + + +
-
-
-
-

선택 기간 고유 방문 유저

-

0

+ +
+
+
+

규모

+
+
전체 유저
0
+
게스트
00%
+
알림 켠 유저
00%
+
푸시 토큰 있는 유저
0
+
-
- - - +
+

활동 기준

+
+
WAU (7일)
0
+
MAU (30일)
0
+
고착도 (DAU/MAU)
0%
+
기간 활성 유저
0
+
-
-
-

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

-
-
- -
-
-
-

선택 기간 평균 DAU

-

0.0

+
+

재방문 기간 가입자 기준

+
+
+
다음 날 재방문 (D1)
+
0%0/0명
+
+
+
7일째 재방문 (D7)
+
0%0/0명
+
+
+

가입 N일째에 로그인한 비율이에요. 아직 N일이 지나지 않은 가입자는 빼고 계산해요.

-
- - - +
+

가입 경로 기간 가입

+
+
+ 구글 +
+ 0 +
+
+
기간 가입자가 없어요
-
-

날짜별 출석 유저 수 평균

-
-
+
- -
-
-
-

선택 기간 가입자 수

-

0

+ + -
-
-
-

총 복습 기록 수

-

0

+ +
+ - -
-
-

상세 통계

-
-
-
- -
-

- - - - 유저 통계 -

-
-
- 총 유저 수 - 0 -
-
- 선택 기간 방문 횟수 - 0 + + - -
-

- - - - 문제 통계 -

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

- - - - 복습 통계 -

-
-
- 총 복습노트 수 - 0 -
-
- 총 복습 기록 수 - 0 -
-
- 선택 기간 복습노트 생성 - 0 -
-
- 선택 기간 복습 수행 - 0 -
+ +
+
- -
-
-

날짜별 출석 유저 수

+ +
+
+

날짜별

+ 숫자를 누르면 그날 목록으로 이동해요
-
-
- - - - - - - - - - - +
+
+
날짜방문 횟수출석 유저 수신규 가입자 수문제 등록 수복습노트 생성 수복습 수행 수
+ + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + +
날짜활성 유저신규 가입오답노트복습복습노트미션 완료스터디룸 활동
2024-01-01 - 0 - 회 - - - 0 - 명 - - - 0 - 명 - - - - 0 - 명 - - - 0 - 명 - - - 0 - 개 - - 0 - 개 - - 0 - 회 -
- 데이터가 없습니다 -
+ 9. 21 + 월 + + 0 + 0 + + 0 + 0 + + 0 + 0 + + 0 + 0 + + 0 + 0 + 00
합계 - 0 - 회 - - 0 - 명 - - 0 - 명 - - 0 - 개 - - 0 - 개 - - 0 - 회 -
합계0000000
+

활성 유저 합계는 날짜별 값을 더한 것이 아니라 기간 동안 들어온 서로 다른 유저 수예요.

+
+
- -
-

빠른 이동

-
- -
- - - -
-
-

유저 관리

-

유저 계정 보기 및 수정

-
-
+ + + diff --git a/src/main/resources/templates/daily-users.html b/src/main/resources/templates/daily-users.html index 6a3354be..d45003b8 100644 --- a/src/main/resources/templates/daily-users.html +++ b/src/main/resources/templates/daily-users.html @@ -1,114 +1,61 @@ - - - - - OnO 관리자 - 날짜별 유저 목록 - - - - - + + + +
+ +
- -
- -
+
-
- - - - - -

- 2024년 1월 1일 - 유저 목록 -

-
-

- 총 0명 -

+ 통계 +

9월 1일 신규 가입자

+

2026-09-01, 총 0명

+
+
- -
-
- - - - - - - - - +
+
+
유저 ID이름이메일총 학습 레벨가입일
+ + + + + + + + + + - - - - - - - - - - - + + + + + + + + + +
ID유저총 레벨출석오답노트 작성문제 복습가입일
1유저 이름user@example.com -
- Lv.1 - (0pt) -
-
2024-01-01 00:00
- 해당 날짜에 가입한 유저가 없습니다 - 해당 날짜에 출석한 유저가 없습니다 -
1 +
+ 이름 + email +
+
Lv.1Lv.1Lv.1Lv.1-
+
없어요
- - -
+
+
+ diff --git a/src/main/resources/templates/fragments/admin-learning.html b/src/main/resources/templates/fragments/admin-learning.html new file mode 100644 index 00000000..570045f8 --- /dev/null +++ b/src/main/resources/templates/fragments/admin-learning.html @@ -0,0 +1,53 @@ + + + + + + + 분석 완료 + 분석 실패 + 분석 중 + 요청 제한 + 분석 대기 + 이미지 없음 + 분석 없음 + + + + + 분석 완료 + 분석 실패 + 분석 중 + 요청 제한 + 분석 대기 + 이미지 없음 + 상태 + + + + + 정답 + 오답 + 부분 정답 + 결과 없음 + - + + + + 정답 + 오답 + 부분 정답 + 결과 없음 + 상태 + + + + + + 이름 + email + + - + + + diff --git a/src/main/resources/templates/fragments/admin-stats.html b/src/main/resources/templates/fragments/admin-stats.html new file mode 100644 index 00000000..cf67dba6 --- /dev/null +++ b/src/main/resources/templates/fragments/admin-stats.html @@ -0,0 +1,19 @@ + + + + +
+
가입
+
+ 0 +
+
+ 직전 기간 기록 없음 + 직전보다 + +0% + +
+
+ + diff --git a/src/main/resources/templates/image.html b/src/main/resources/templates/image.html index 5ad2dd67..6548dc00 100644 --- a/src/main/resources/templates/image.html +++ b/src/main/resources/templates/image.html @@ -1,24 +1,24 @@ - - - - Image View - - - -Image + + + + + +
+ 이미지 +

열 수 없는 이미지 주소예요.

+
+ + - \ No newline at end of file + diff --git a/src/main/resources/templates/practice-logs.html b/src/main/resources/templates/practice-logs.html index 17b049cd..14efaeee 100644 --- a/src/main/resources/templates/practice-logs.html +++ b/src/main/resources/templates/practice-logs.html @@ -1,119 +1,147 @@ - - - - - OnO 관리자 - 복습 기록 - - - - -
-
-
-

복습 기록

-

- 총 0개 -

+
+
+
전체 복습
+
0회
- - 복습노트 목록 + +
오늘
+
0회
+
+
정답률
+
0.0%
+
결과를 남긴 기록 기준
+
+
+
평균 소요 시간
+
-
+
+
+
회고를 쓴 기록
+
0회
+
-
-
-

복습 기록 목록

-
- - - +
+
+

결과 분포

+
+ + + + +
+ +
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + 초기화
-
- - + + +
+
+ 복습일 + 유저 + + + + 필터 해제 +
+ +
+
+
+ - - - - - + + + + + + + + - - - - - + + + + - - - + + + + - - - - -
기록 ID유저복습노트포인트완료일복습 시각유저문제결과소요 시간회고기분사진
1 - 유저 -

user@example.com

+ +
날짜 +
문제
+
메모
- 복습노트 - #1 + +
+ + 이관 +
15pt2024-01-01 00:00-회고기분0
복습 기록이 없습니다
-
- -
- 이전 - 이전 - - << - << - - - 1 - 1 - - - >> - >> - - 다음 - 다음 + + +
조건에 맞는 복습 기록이 없어요.
+
+
-
+ + +
+ diff --git a/src/main/resources/templates/practice-notes.html b/src/main/resources/templates/practice-notes.html index cc3e8a59..bd223db5 100644 --- a/src/main/resources/templates/practice-notes.html +++ b/src/main/resources/templates/practice-notes.html @@ -1,130 +1,112 @@ - - - - - OnO 관리자 - 복습 관리 - - - - + + + +
+ +
-
-
-

복습 관리

-

- 복습노트 0개 -

+
+
+

복습노트

+

오답노트를 묶어 둔 복습 세트예요.

+
-
-
-

총 복습노트 수

-

0

+
+
+
전체
+
0개
+
+
+
한 번 이상 복습
+
0개
+
0%
+
+
+
누적 복습 횟수
+
0회
+
+
+
알림을 켠 노트
+
0개
- -

복습 기록

-

복습 기록 페이지로 이동

-
-
-
-

복습노트 목록

-
- - - +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + 초기화
-
- - + + +
+
+ 날짜 + 유저 + 0개 + 필터 해제 +
+ +
+
+
+ - - - - - - - + + + + + + + - - - - - + + - - - - + + + + - - - - + - -
복습노트 ID유저제목문제 수복습 횟수마지막 복습일작성일제목유저문제복습마지막 복습알림만든 날
1 - 유저 -

user@example.com

+ +
+
제목
+
#1
복습노트00 - 2024-01-01 00:00 - - + 00- + 매일 21: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 40e0bc27..2156fc45 100644 --- a/src/main/resources/templates/problem.html +++ b/src/main/resources/templates/problem.html @@ -1,284 +1,214 @@ - - - - - OnO 관리자 - 문제 상세 - - - - - + + + +
+ +
- -
- -
+
-
- - - - - -

문제 상세 정보

-
-

문제 ID: 1

+ 오답노트 +

오답노트

+

+ #1 + 등록일 +

+
- -
-
-

기본 정보

+
+
+

기본 정보

+
+
작성자
+
+ 이름 + ID + 탈퇴 +
+
이메일
+
email
+
폴더
+
+ 폴더 + ID +
+
출처
+
출처
+
푼 날짜
+
-
+
태그
+
+
+ 태그 +
+ 없음 +
+
+
+
메모
+
메모
+
-
-
-
-
문제 ID
-
1
-
-
-
폴더
-
- 폴더명 - -
-
+ +
+

복습 상태

+
-
작성자
-
- - 사용자명 - - -
+
복습 횟수
+
0회
-
작성일
-
2024-01-01 00:00:00
+
연속 정답
+
0회
-
푼 날짜
-
- 2024-01-01 - - -
+
복습 간격
+
1일
-
출처
-
- 출처 - - -
-
-
-
메모
-
- 메모 내용 - - -
+
다음 복습일
+
-
-
-
-
+
- -
-
-

문제 이미지

-
-
-
-
-
- 이미지 타입 -
- - +

어느 복습노트에도 들어 있지 않아요.

- -
-
-

AI 분석 결과

-
-
-
-
- - 분석 완료 - - - 분석중 - - - 분석 대기 - - - 분석 실패 - -
-
-
-
과목
-
과목
-
-
-
문제 유형
-
문제 유형
-
-
-
핵심 포인트
-
-
    -
  • 핵심 포인트
  • -
-
-
-
-
풀이
-
풀이
-
-
-
흔한 실수
-
흔한 실수
-
-
-
학습 팁
-
학습 팁
-
-
-
에러 메시지
-
에러 메시지
+
+

이미지

0장
+
+
+
+ + + +
+ 문제 + 정답 + 풀이 + 풀이 과정 + 유형
-
-
-
- - 미등록 - -

아직 AI 분석이 등록되지 않았습니다

+
-
+ - -
-
-

문제 복습 기록

- - 총 0개 - +
+

AI 분석

+
+
분석 기록이 없어요.
+ +
+ + 수학 + 유형 +
+
+
에러 메시지
+
에러
+
+
+
핵심 포인트
+
포인트
+
+
+
풀이
+
풀이
+
+
+
흔한 실수
+
실수
+
+
+
학습 팁
+
팁
+
+
-
-
- - - - - - - - - - - + + +
+

공유된 스터디룸

0곳
+
+
+
기록 ID유저 ID복습일정답 상태소요 시간회고이미지
+ + - - - - - - - - - - + + + + + + + + +
스터디룸공유한 사람한마디댓글반응공유일
1 - 1 - 2024-01-01CORRECT - 60초 - - - - 회고 - - - - 0장 -
방 + 이름 + 한마디00날짜
-
- 이 문제에 작성된 복습 기록이 없습니다 +
+
+ +
+
+

복습 기록

+ 0회 +
+
+
아직 이 문제를 다시 풀지 않았어요.
+
+
+
+
+ + 날짜 + 시간 +
+
+ 기분 + 예전 기록 이관 +
+
+
+
회고
+
회고
+
+
+
나아진 점
+
개선
+
+
-
+ - - -
+
+
+ + diff --git a/src/main/resources/templates/problems.html b/src/main/resources/templates/problems.html index 6c0acd1f..744373ee 100644 --- a/src/main/resources/templates/problems.html +++ b/src/main/resources/templates/problems.html @@ -1,192 +1,129 @@ - - - - - OnO 관리자 - 문제 목록 - - - - - - -
- -
-

문제 관리

-

총 0개의 문제

+ - -
-
- - +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + 초기화 + +
+
+ +
+
+ 등록일 + 유저 + + + + 0개 + 필터 해제 +
+ +
+
+
+ - - - - - - - + + + + + + + + - - - - - - - + + + + - + - - + + + - - - - -
ID폴더 ID메모출처분석푼 날짜작성일ID작성자출처 / 메모폴더분석복습태그등록일
11 - 메모 내용... - - - - 출처 - - + +
1 +
출처
+
메모
- - 분석 완료 - - - 분석중 - - - 분석 대기 - - - 분석 실패 - - - 이미지 없음 - - - 미등록 - + 폴더 +
+ + 수학 +
- 2024-01-01 - - - 2024-01-01 00:00002026-01-01
- 문제가 없습니다 -
-
-
- - -
-
- 1 - 부터 20 - 까지 (총 100개) -
- -
- - - 이전 - - - 이전 - - - - << - - - << - - - -
- - - 1 - - - 1 - - + + +
조건에 맞는 오답노트가 없어요.
- - - >> - - - >> - - - - - 다음 - - - 다음 - +
-
-
+ + + +
+ diff --git a/src/main/resources/templates/user.html b/src/main/resources/templates/user.html index e99f0fed..bb9cc66f 100644 --- a/src/main/resources/templates/user.html +++ b/src/main/resources/templates/user.html @@ -1,460 +1,475 @@ - - - - - OnO 관리자 - 유저 상세 - - - - - - -
- -
- - - - - 유저 목록으로 돌아가기 + - - -
-
-

유저 상세 정보

- + +
복습 기록
+
0회
+
정답률 0%
+
+ +
복습노트
+
0개
+
+
+
연속 출석
+
0일
+
총 0일
+
+
스터디룸
+
0개
+
+
+
업적
+
0개
+
+
-
- -
-
- -

1

-
-
- -

유저 이름

-
-
- -

user@example.com

-
-
- -

2024-01-01 00:00:00

-
-
- - -
-
-
- -

0

-
-
- -

0

-
-
- -

0

-
-
- -

0

-
-
-
+
+
+ + + + + + +
- -
-

레벨 정보 (클릭하여 수정)

-
- -
- -
-

Lv.1

-

(0/40 pt)

-
+ +
+
+
+

계정

+
+
유저 ID
1
+
가입일
-
+
최근 활동
-
+
푸시 알림
+
+ 켜짐 + 꺼짐 + 기기 0대 +
+
마지막 알림
-
+
- -
- -
-

Lv.1

-

(0 pt)

+
+

레벨 눌러서 수정

+
+ + + + +
- -
- -
-

Lv.1

-

(0 pt)

+
+

착용 중인 치장 0개

+
+
+
+
아이템
+
key
+
+ 머리 +
+
착용한 치장이 없어요
- -
- -
-

Lv.1

-

(0 pt)

+
+

받은 업적 0개

+
+
+
+
첫 걸음
+
설명
+
+ 2026-01-01 +
+
아직 받은 업적이 없어요
- -
- -
-

Lv.1

-

(0 pt)

+
+

최근 기분 기록 학습 달력에 남긴 기분, 최근 14일치

+
+
+ 9/1 + happy +
+
기분 기록이 없어요
-
-
+
- -
-
- -
-
- - -
- -
-

유저가 작성한 문제

-
- - + +
+ - - - - - - + - - - - - - - - + + + + + + - + + + - -
ID메모출처이미지푼 날짜작성일ID출처메모폴더AI 분석복습다음 복습작성일
1 - 메모... - - - - 출처 - - - -
- - - - - - - - 이미지 없음 -
-
- 2024-01-01 - - + +
1출처메모폴더 + 수학 + 분석 실패 + 분석 중 + 한도 초과 + 이미지 없음 + 대기 + - 2024-01-01 00:000--
-
-
- 이 유저가 작성한 문제가 없습니다 + + +
작성한 오답노트가 없어요
+
+
+ 최근 300개 + 전체 보기 +
-
+ - - -
+ - - -
- 이 유저의 복습노트가 없습니다 + + + +
+ - - -
-
-
- -