From abb4d7f9db768833672cfc040e33efa6bd7b0107 Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:36:58 +0900 Subject: [PATCH 01/10] =?UTF-8?q?ICC-382=20=F0=9F=94=A5=20chore:=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20Terraform=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=94=EC=97=AC=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infra/terraform/gcp, infra/terraform/oci Terraform 구성 일괄 삭제 - 루트 잔여 org/springframework/ai/google/genai/GoogleGenAiChatModel.java 삭제 - infra/scripts/oci-mysql-backup 스크립트 정리, CLAUDE.md 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 28 +- infra/scripts/oci-mysql-backup/backup.sh | 11 +- infra/scripts/oci-mysql-backup/healthcheck.sh | 0 infra/scripts/oci-mysql-backup/restore.sh | 17 +- infra/terraform/gcp/.terraform.lock.hcl | 22 - infra/terraform/gcp/apis.tf | 11 - infra/terraform/gcp/iam.tf | 34 - infra/terraform/gcp/main.tf | 18 - infra/terraform/gcp/outputs.tf | 19 - infra/terraform/gcp/storage.tf | 21 - infra/terraform/gcp/variables.tf | 23 - infra/terraform/oci/.terraform.lock.hcl | 25 - infra/terraform/oci/main.tf | 106 -- infra/terraform/oci/mysql.tf | 76 -- .../ai/google/genai/GoogleGenAiChatModel.java | 1159 ----------------- 15 files changed, 35 insertions(+), 1535 deletions(-) mode change 100644 => 100755 infra/scripts/oci-mysql-backup/backup.sh mode change 100644 => 100755 infra/scripts/oci-mysql-backup/healthcheck.sh mode change 100644 => 100755 infra/scripts/oci-mysql-backup/restore.sh delete mode 100644 infra/terraform/gcp/.terraform.lock.hcl delete mode 100644 infra/terraform/gcp/apis.tf delete mode 100644 infra/terraform/gcp/iam.tf delete mode 100644 infra/terraform/gcp/main.tf delete mode 100644 infra/terraform/gcp/outputs.tf delete mode 100644 infra/terraform/gcp/storage.tf delete mode 100644 infra/terraform/gcp/variables.tf delete mode 100644 infra/terraform/oci/.terraform.lock.hcl delete mode 100644 infra/terraform/oci/main.tf delete mode 100644 infra/terraform/oci/mysql.tf delete mode 100644 org/springframework/ai/google/genai/GoogleGenAiChatModel.java diff --git a/CLAUDE.md b/CLAUDE.md index 195724a9..20fb2853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,22 +10,22 @@ | 분류 | 기술 | 버전 | |---------------|-----------------------------------------------------------------------|----------------| | 언어 | Java | 21 | -| 프레임워크 | Spring Boot | 3.5.8 | -| AI | Spring AI (Google Gemini via Vertex AI) | 1.1.2 | +| 프레임워크 | Spring Boot | 4.1.0 | +| AI | Spring AI (Google Gemini via Vertex AI) | 2.0.0 | | ORM | Spring Data JPA + Hibernate | (Boot BOM) | | DB | MySQL | - | | 인증 | JWT (Auth0 java-jwt 4.5.0) + OAuth2 Client | - | | 클라우드 | OCI Java SDK (Object Storage) + Cloudflare CDN + Google Cloud Storage | 3.90.0 | -| 문서변환 | JODConverter (LibreOffice) | 4.4.9 | -| PDF 처리 | Apache PDFBox | 3.0.3 | +| 문서변환 | JODConverter (LibreOffice) | 4.4.11 | +| PDF 처리 | Apache PDFBox | 3.0.7 | | 모니터링 | Micrometer + Prometheus + Actuator | (Boot BOM) | -| 장애격리 | Resilience4j (Circuit Breaker) | 2.3.0 | +| 장애격리 | Resilience4j (Circuit Breaker) | 2.4.0 | | Rate Limiting | Bucket4j + Caffeine | 8.19.0 | -| API 문서 | SpringDoc OpenAPI (Swagger UI) | 2.8.17 | +| API 문서 | SpringDoc OpenAPI (Swagger UI) | 3.0.3 | | 암호화 | Jasypt | 3.0.5 | | ID 난독화 | Hashids | 1.0.3 | | 빌드 | Gradle (Groovy DSL) | 8.14.3 | -| 컨테이너 | Jib (Docker) | 3.4.0 | +| 컨테이너 | Jib (Docker) | 3.5.3 | | 포맷터 | Spotless + Google Java Format | 7.0.4 / 1.25.2 | | DB 마이그레이션 | Flyway | (Boot BOM) | | 테스트 | JUnit 5 | (Boot BOM) | @@ -80,7 +80,7 @@ q-asker/api/ │ ├── resilience.yml # Circuit Breaker │ └── spring-doc.yml # Swagger/OpenAPI ├── modules/ -│ ├── global/ # 공통 (BaseEntity, ApiResponse, GlobalExceptionHandler) +│ ├── global/ # 공통 (BaseEntity, ApiResponse, GlobalExceptionHandler, Boot4CompatConfig) │ ├── auth/ (api + impl) # 인증 (JWT, OAuth2, RateLimitFilter) │ ├── oci/ (api + impl) # OCI Object Storage 파일 업로드 │ ├── board/ (api + impl) # 게시판 @@ -94,11 +94,9 @@ q-asker/api/ │ ├── monitoring/ # Grafana Alloy 설정 │ ├── mysql/ # MySQL Docker 설정 │ ├── base-image/ # Docker 베이스 이미지 -│ ├── scripts/ -│ │ └── oci-mysql-backup/ # OCI MySQL 백업/복구/헬스체크 스크립트 (backup.sh, restore.sh, healthcheck.sh, systemd) -│ └── terraform/ -│ ├── gcp/ # GCP 인프라 (GCS, IAM, Vertex AI) -│ └── oci/ # OCI 인프라 (NSG Cloudflare 인바운드, MySQL HeatWave 백업/PITR) +│ ├── blue-green/ # Blue-Green 무중단 배포 (Nginx 트래픽 스위칭, docker-compose, deploy.sh) +│ └── scripts/ +│ └── oci-mysql-backup/ # OCI MySQL 백업/복구/헬스체크 스크립트 (backup.sh, restore.sh, healthcheck.sh, deploy.sh, env.example, healthcheck.baseline.yml, lib/, systemd/, RESTORE.md) — 리눅스·macOS 호환 (gzip -dc, sha256sum↔shasum 폴백) ├── docs/ # 문서, 분석 자료 ├── gradle/ │ ├── libs.versions.toml # Version Catalog: 모든 의존성/플러그인 버전 SSOT @@ -161,5 +159,7 @@ q-asker/api/ For additional context about technologies to be used, project structure, -shell commands, and other important information, read the current plan +shell commands, and other important information, read the current plan: +`specs/001-quiz-read-optimization/plan.md` (research.md, data-model.md, +contracts/, quickstart.md in the same directory) diff --git a/infra/scripts/oci-mysql-backup/backup.sh b/infra/scripts/oci-mysql-backup/backup.sh old mode 100644 new mode 100755 index 56070d0f..ca77e388 --- a/infra/scripts/oci-mysql-backup/backup.sh +++ b/infra/scripts/oci-mysql-backup/backup.sh @@ -57,8 +57,15 @@ fail() { exit "$code" } +# sha256sum(GNU/coreutils) 또는 shasum -a 256(macOS/BSD) 이식성 래퍼. +# 둘 다 " " 형식으로 출력하므로 downstream awk '{print $1}' 호환. +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@" + else shasum -a 256 "$@"; fi +} + # ─── 사전 검증 ─── -for cmd in flock mysqldump mysql sha256sum jq oci; do +for cmd in flock mysqldump mysql jq oci; do command -v "$cmd" >/dev/null || { log "[ERR] $cmd 미설치"; exit 1; } done for var in MYSQL_HOST MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE; do @@ -124,7 +131,7 @@ log "[step 1/5] dump complete ($DUMP_SIZE bytes)" # ─── Step 2: SHA256 ─── log "[step 2/5] checksum..." -if ! sha256sum "$DUMP_FILE" | awk '{print $1}' > "$SHA_FILE"; then +if ! sha256 "$DUMP_FILE" | awk '{print $1}' > "$SHA_FILE"; then fail "checksum" 3 fi SHA256=$(cat "$SHA_FILE") diff --git a/infra/scripts/oci-mysql-backup/healthcheck.sh b/infra/scripts/oci-mysql-backup/healthcheck.sh old mode 100644 new mode 100755 diff --git a/infra/scripts/oci-mysql-backup/restore.sh b/infra/scripts/oci-mysql-backup/restore.sh old mode 100644 new mode 100755 index 1025b8dd..0f36ac0c --- a/infra/scripts/oci-mysql-backup/restore.sh +++ b/infra/scripts/oci-mysql-backup/restore.sh @@ -54,6 +54,13 @@ fail() { exit "$code" } +# sha256sum(GNU/coreutils) 또는 shasum -a 256(macOS/BSD) 이식성 래퍼. +# 둘 다 " " 형식으로 출력하므로 downstream awk '{print $1}' 호환. +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@" + else shasum -a 256 "$@"; fi +} + usage() { cat </dev/null || { log "[ERR] $cmd 미설치"; exit 1; } done @@ -148,7 +155,7 @@ BACKUP_TS=$(echo "$BASENAME" | sed -n 's/^qasker-mysql-\([0-9TZ]*\)\.sql\.gz$/\1 [[ -z "$BACKUP_TS" ]] && BACKUP_TS="unknown" UNIX_TS=$(date +%s) CONTAINER_NAME="mysql-restore-${BACKUP_TS}-${UNIX_TS}" -ROOT_PWD=$(openssl rand -hex 16) +ROOT_PWD="password" log "[START] object_key=$OBJECT_KEY container=$CONTAINER_NAME" @@ -170,7 +177,7 @@ log "[step 1/5] downloaded" # ─── Step 2: sha256 검증 (운영 무영향 검증) ─── log "[step 2/5] verifying sha256..." EXPECTED_SHA=$(cat "$SHA_FILE") -ACTUAL_SHA=$(sha256sum "$DUMP_FILE" | awk '{print $1}') +ACTUAL_SHA=$(sha256 "$DUMP_FILE" | awk '{print $1}') if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then log "[FAIL] checksum mismatch" @@ -202,7 +209,7 @@ if ! docker run -d \ -e "MYSQL_ROOT_PASSWORD=$ROOT_PWD" \ -e "MYSQL_ROOT_HOST=%" \ -e "MYSQL_DATABASE=qaskerdb" \ - -p 0:3306 \ + -p 55000:3306 \ --health-cmd="mysqladmin ping -uroot -p$ROOT_PWD --silent" \ --health-interval=3s \ --health-timeout=2s \ @@ -244,7 +251,7 @@ done # ─── Step 4: dump 적재 ─── log "[step 4/5] loading dump.sql.gz..." LOAD_START=$(date +%s) -if ! zcat "$DUMP_FILE" | MYSQL_PWD="$ROOT_PWD" mysql \ +if ! gzip -dc "$DUMP_FILE" | MYSQL_PWD="$ROOT_PWD" mysql \ -h 127.0.0.1 -P "$HOST_PORT" --protocol=tcp -uroot qaskerdb 2>"$WORK_DIR/load.err"; then log "[FAIL] dump load failed" cat "$WORK_DIR/load.err" >&2 diff --git a/infra/terraform/gcp/.terraform.lock.hcl b/infra/terraform/gcp/.terraform.lock.hcl deleted file mode 100644 index 78b8795f..00000000 --- a/infra/terraform/gcp/.terraform.lock.hcl +++ /dev/null @@ -1,22 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/google" { - version = "6.50.0" - constraints = "~> 6.0" - hashes = [ - "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", - "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", - "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", - "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", - "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", - "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", - "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", - "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", - "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", - "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", - "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", - ] -} diff --git a/infra/terraform/gcp/apis.tf b/infra/terraform/gcp/apis.tf deleted file mode 100644 index 66c56359..00000000 --- a/infra/terraform/gcp/apis.tf +++ /dev/null @@ -1,11 +0,0 @@ -# Vertex AI API 활성화 -resource "google_project_service" "aiplatform" { - service = "aiplatform.googleapis.com" - disable_on_destroy = false -} - -# Cloud Storage API 활성화 -resource "google_project_service" "storage" { - service = "storage.googleapis.com" - disable_on_destroy = false -} diff --git a/infra/terraform/gcp/iam.tf b/infra/terraform/gcp/iam.tf deleted file mode 100644 index ebd878f1..00000000 --- a/infra/terraform/gcp/iam.tf +++ /dev/null @@ -1,34 +0,0 @@ -# AI 전용 서비스 계정 -resource "google_service_account" "ai" { - account_id = var.service_account_id - display_name = "Q-Asker AI Service" -} - -# GCS 파일 업로드/삭제 권한 -resource "google_project_iam_member" "ai_storage" { - project = var.project_id - role = "roles/storage.objectAdmin" - member = "serviceAccount:${google_service_account.ai.email}" -} - -# Vertex AI 호출 권한 -resource "google_project_iam_member" "ai_vertex" { - project = var.project_id - role = "roles/aiplatform.user" - member = "serviceAccount:${google_service_account.ai.email}" -} - -# Vertex AI 서비스 에이전트가 GCS 버킷의 객체를 읽을 수 있도록 권한 부여. -# Gemini가 GCS URI(fileData)로 PDF를 참조할 때, 실제 객체 fetch는 -# 'service-@gcp-sa-aiplatform.iam.gserviceaccount.com'이 수행한다. -data "google_project" "this" { - project_id = var.project_id -} - -resource "google_storage_bucket_iam_member" "vertex_agent_read" { - bucket = google_storage_bucket.ai_files.name - role = "roles/storage.objectViewer" - member = "serviceAccount:service-${data.google_project.this.number}@gcp-sa-aiplatform.iam.gserviceaccount.com" - - depends_on = [google_project_service.aiplatform] -} diff --git a/infra/terraform/gcp/main.tf b/infra/terraform/gcp/main.tf deleted file mode 100644 index ab464dc7..00000000 --- a/infra/terraform/gcp/main.tf +++ /dev/null @@ -1,18 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - google = { - source = "hashicorp/google" - version = "~> 6.0" - } - } - - # 로컬 state (1인 프로젝트) - backend "local" {} -} - -provider "google" { - project = var.project_id - region = var.region -} diff --git a/infra/terraform/gcp/outputs.tf b/infra/terraform/gcp/outputs.tf deleted file mode 100644 index 1018c651..00000000 --- a/infra/terraform/gcp/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "gcs_bucket_name" { - description = "GCS 버킷 이름" - value = google_storage_bucket.ai_files.name -} - -output "service_account_email" { - description = "AI 서비스 계정 이메일" - value = google_service_account.ai.email -} - -output "project_id" { - description = "GCP 프로젝트 ID" - value = var.project_id -} - -output "region" { - description = "GCP 리전" - value = var.region -} \ No newline at end of file diff --git a/infra/terraform/gcp/storage.tf b/infra/terraform/gcp/storage.tf deleted file mode 100644 index 2c5ce09e..00000000 --- a/infra/terraform/gcp/storage.tf +++ /dev/null @@ -1,21 +0,0 @@ -# Vertex AI 컨텍스트 캐싱용 PDF 임시 저장 버킷 -resource "google_storage_bucket" "ai_files" { - name = var.gcs_bucket_name - location = var.region - - storage_class = "STANDARD" - uniform_bucket_level_access = true - - # 1일 후 자동 삭제 (GCS 최소 단위가 일 단위) - # 애플리케이션에서 캐시 삭제 시 즉시 삭제도 병행 - lifecycle_rule { - condition { - age = 1 - } - action { - type = "Delete" - } - } - - depends_on = [google_project_service.storage] -} diff --git a/infra/terraform/gcp/variables.tf b/infra/terraform/gcp/variables.tf deleted file mode 100644 index db97abca..00000000 --- a/infra/terraform/gcp/variables.tf +++ /dev/null @@ -1,23 +0,0 @@ -variable "project_id" { - description = "GCP 프로젝트 ID" - type = string - default = "project-568b4cc1-9e5e-4d68-91d" -} - -variable "region" { - description = "GCP 리전" - type = string - default = "asia-northeast3" -} - -variable "gcs_bucket_name" { - description = "Vertex AI 컨텍스트 캐싱용 PDF 임시 저장 버킷" - type = string - default = "q-asker-ai-files-v2" -} - -variable "service_account_id" { - description = "AI 서비스 계정 ID" - type = string - default = "q-asker-ai" -} diff --git a/infra/terraform/oci/.terraform.lock.hcl b/infra/terraform/oci/.terraform.lock.hcl deleted file mode 100644 index c0aaa8da..00000000 --- a/infra/terraform/oci/.terraform.lock.hcl +++ /dev/null @@ -1,25 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/oracle/oci" { - version = "6.37.0" - constraints = "~> 6.0" - hashes = [ - "h1:S3gkcr6ZMGmrU0SQencNQ+j2L2DkOgi+InVKAVOWZWI=", - "zh:2b1ba7863e162f3f2e5929c6a43db6b0d44bb24032bf7d1b4fe27c36e39d512f", - "zh:2d2ccd7eaab45c0b35a52b7dd6e315a38a9e32d72827003194786dbae8004240", - "zh:3e3017f035fac18114e0b1d29c72430958054def0f800c22d36e1144d0c76422", - "zh:504eb43e31cead3c4ff9b3649b51b62e59f91cb94f622e7df110f31bb95daf20", - "zh:86e2ad61fa0c56a7e17b28ac79558ffd462cd086dd2f62b79988596792aa45c8", - "zh:92436cd5326a587e8fa927bee2d42800ff9ef93a782ccbea5c6ebd11e06cf786", - "zh:94aa72b19bbf5ccc4778c9154d615444184d63e782ded54741e18969ab00cb61", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b8d7e2d181acda6b8836071610fd292cc5c8ef659cfb92aac33bd6bfda71c24f", - "zh:ccb95a6b390f4b161532e9b14708a6cb989ca2fc51f1ae00eae9db4dd7ca70d4", - "zh:d385763af4eda8aa6bf906773b461efce4cab82670826d990fdd5e05fb8a2afa", - "zh:df0cd594595c16b33b1215543853f5e3671d16e665090b3f48f19ff288029dd4", - "zh:e5266e51a70ae31af36a3b58707f77c4189c956ec6f982fcce8aafcec36de3d7", - "zh:e5da4389063cddf4e7424ce0df1c218c1508672b45470d66adc81c52d5ccf46d", - "zh:e608694f226bb18cd93128b8dac910127cdf457f7baf37e472d0d18b531e9f84", - ] -} diff --git a/infra/terraform/oci/main.tf b/infra/terraform/oci/main.tf deleted file mode 100644 index 43b797d4..00000000 --- a/infra/terraform/oci/main.tf +++ /dev/null @@ -1,106 +0,0 @@ -terraform { - required_providers { - oci = { - source = "oracle/oci" - version = "~> 6.0" - } - null = { - source = "hashicorp/null" - version = "~> 3.2" - } - } -} - -provider "oci" { - config_file_profile = "DEFAULT" -} - -locals { - api_nsg_id = "ocid1.networksecuritygroup.oc1.ap-chuncheon-1.aaaaaaaap6rb3pbbxgfofgdpx72xtbgqjl5ws2w3bnenkl3mf7u43gzevomq" - mon_nsg_id = "ocid1.networksecuritygroup.oc1.ap-chuncheon-1.aaaaaaaaamr2hlzkkpi76gg5dqufgszh6yzwf7rnd3ylhiyswaiwa3mscinq" - - # Cloudflare IPv4 대역 (https://www.cloudflare.com/ips-v4/) - cloudflare_ipv4 = [ - "173.245.48.0/20", - "103.21.244.0/22", - "103.22.200.0/22", - "103.31.4.0/22", - "141.101.64.0/18", - "108.162.192.0/18", - "190.93.240.0/20", - "188.114.96.0/20", - "197.234.240.0/22", - "198.41.128.0/17", - "162.158.0.0/15", - "104.16.0.0/13", - "104.24.0.0/14", - "172.64.0.0/13", - "131.0.72.0/22", - ] - - # HTTP(80) + HTTPS(443) 포트 - web_ports = [80, 443] - - # Cloudflare IP × 포트 조합 - cloudflare_rules = flatten([ - for port in local.web_ports : [ - for cidr in local.cloudflare_ipv4 : { - port = port - cidr = cidr - } - ] - ]) -} - -# 기존 NSG를 data source로 참조 (Terraform이 관리하지 않는 기존 리소스) -data "oci_core_network_security_group" "api_nsg" { - network_security_group_id = local.api_nsg_id -} - -data "oci_core_network_security_group" "mon_nsg" { - network_security_group_id = local.mon_nsg_id -} - -# API 서버: Cloudflare IP 대역에서 80/443 허용 -resource "oci_core_network_security_group_security_rule" "cloudflare_ingress" { - for_each = { for idx, rule in local.cloudflare_rules : "${rule.port}-${replace(rule.cidr, "/", "_")}" => rule } - - network_security_group_id = local.api_nsg_id - direction = "INGRESS" - protocol = "6" # TCP - - source = each.value.cidr - source_type = "CIDR_BLOCK" - stateless = false - - tcp_options { - destination_port_range { - min = each.value.port - max = each.value.port - } - } - - description = "Cloudflare ${each.value.port} from ${each.value.cidr}" -} - -# MON 서버: Cloudflare IP 대역에서 3000 허용 (Grafana) -resource "oci_core_network_security_group_security_rule" "mon_cloudflare_ingress" { - for_each = { for cidr in local.cloudflare_ipv4 : replace(cidr, "/", "_") => cidr } - - network_security_group_id = local.mon_nsg_id - direction = "INGRESS" - protocol = "6" # TCP - - source = each.value - source_type = "CIDR_BLOCK" - stateless = false - - tcp_options { - destination_port_range { - min = 3000 - max = 3000 - } - } - - description = "Cloudflare 3000 from ${each.value}" -} diff --git a/infra/terraform/oci/mysql.tf b/infra/terraform/oci/mysql.tf deleted file mode 100644 index d24bb7a3..00000000 --- a/infra/terraform/oci/mysql.tf +++ /dev/null @@ -1,76 +0,0 @@ -# ============================================================ -# 운영 MySQL HeatWave 매니지드 자동 백업 + PITR 활성화 (T2) -# ============================================================ -# FR-001: 매니지드 자동 백업 활성화, 보존 ≥ 7일, PITR 지원 -# FR-012: 보존 기간이 무료 할당량을 초과하지 않도록 산정 -# (T1 측정: 운영 DB 194 MiB, 무압축 최악 가정으로도 안전권) -# -# DB System 자체는 Terraform이 관리하지 않으며(콘솔 생성 자산), -# 본 모듈은 backup_policy 속성만 OCI CLI를 통해 idempotent하게 갱신한다. -# -# 사전 요구: oci CLI 설치, ~/.oci/config DEFAULT 프로필 인증. - -variable "mysql_db_system_id" { - type = string - description = "운영 MySQL HeatWave DB System OCID. terraform.tfvars 또는 TF_VAR_mysql_db_system_id 환경변수로 주입." -} - -variable "mysql_backup_retention_days" { - type = number - default = 7 - description = "매니지드 자동 백업 보존 기간(일). T1 산정 기준 7일." - - validation { - condition = var.mysql_backup_retention_days >= 1 && var.mysql_backup_retention_days <= 35 - error_message = "OCI MySQL 백업 보존 기간은 1~35일 범위여야 합니다." - } -} - -# 현재 DB System 상태 참조 (검증용; DB System 자체는 콘솔에서 관리) -data "oci_mysql_mysql_db_system" "qasker_prod" { - db_system_id = var.mysql_db_system_id -} - -# backup_policy 갱신: triggers 변경 시 재실행되어 idempotent하게 동작. -resource "null_resource" "mysql_backup_policy" { - triggers = { - db_system_id = var.mysql_db_system_id - retention_days = var.mysql_backup_retention_days - pitr_enabled = "true" - } - - provisioner "local-exec" { - interpreter = ["/bin/bash", "-c"] - command = <<-EOT - set -euo pipefail - POLICY_FILE=$(mktemp) - trap 'rm -f "$POLICY_FILE"' EXIT - cat > "$POLICY_FILE" < - * Key features include: - *
    - *
  • Support for multiple Gemini model versions including Gemini Pro, Gemini 1.5 Pro, - * Gemini 1.5/2.0 Flash variants
  • - *
  • Tool/Function calling capabilities through {@link ToolCallingManager}
  • - *
  • Streaming support via {@link #stream(Prompt)} method
  • - *
  • Configurable safety settings through {@link GoogleGenAiSafetySetting}
  • - *
  • Support for system messages and multi-modal content (text and images)
  • - *
  • Built-in retry mechanism and observability through Micrometer
  • - *
  • Google Search Retrieval integration
  • - *
- * - *

- * The model can be configured with various options including temperature, top-k, top-p - * sampling, maximum output tokens, and candidate count through - * {@link GoogleGenAiChatOptions}. - * - *

- * Use the {@link Builder} to create instances with custom configurations: - * - *

{@code
- * GoogleGenAiChatModel model = GoogleGenAiChatModel.builder()
- * 		.genAiClient(genAiClient)
- * 		.defaultOptions(options)
- * 		.toolCallingManager(toolManager)
- * 		.build();
- * }
- * - * @author Christian Tzolov - * @author Grogdunn - * @author luocongqiu - * @author Chris Turchin - * @author Mark Pollack - * @author Soby Chacko - * @author Jihoon Kim - * @author Alexandros Pappas - * @author Ilayaperumal Gopinathan - * @author Dan Dobrin - * @since 0.8.1 - * @see GoogleGenAiChatOptions - * @see ToolCallingManager - * @see ChatModel - */ -public class GoogleGenAiChatModel implements ChatModel, DisposableBean { - - private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); - - private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build(); - - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final Client genAiClient; - - private final GoogleGenAiChatOptions defaultOptions; - - /** - * The retry template used to retry the API calls. - */ - private final RetryTemplate retryTemplate; - - /** - * The cached content service for managing cached content. - */ - private final GoogleGenAiCachedContentService cachedContentService; - - // GenerationConfig is now built dynamically per request - - /** - * Observation registry used for instrumentation. - */ - private final ObservationRegistry observationRegistry; - - /** - * Tool calling manager used to call tools. - */ - private final ToolCallingManager toolCallingManager; - - /** - * The tool execution eligibility predicate used to determine if a tool can be - * executed. - */ - private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; - - /** - * Conventions to use for generating observations. - */ - private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; - - /** - * Creates a new instance of GoogleGenAiChatModel. - * @param genAiClient the GenAI Client instance to use - * @param defaultOptions the default options to use - * @param toolCallingManager the tool calling manager to use. It is wrapped in a - * {@link GoogleGenAiToolCallingManager} to ensure compatibility with Vertex AI's - * OpenAPI schema format. - * @param retryTemplate the retry template to use - * @param observationRegistry the observation registry to use - */ - public GoogleGenAiChatModel(Client genAiClient, GoogleGenAiChatOptions defaultOptions, - ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, - ObservationRegistry observationRegistry) { - this(genAiClient, defaultOptions, toolCallingManager, retryTemplate, observationRegistry, - new DefaultToolExecutionEligibilityPredicate()); - } - - /** - * Creates a new instance of GoogleGenAiChatModel. - * @param genAiClient the GenAI Client instance to use - * @param defaultOptions the default options to use - * @param toolCallingManager the tool calling manager to use. It is wrapped in a - * {@link GoogleGenAiToolCallingManager} to ensure compatibility with Vertex AI's - * OpenAPI schema format. - * @param retryTemplate the retry template to use - * @param observationRegistry the observation registry to use - * @param toolExecutionEligibilityPredicate the tool execution eligibility predicate - */ - public GoogleGenAiChatModel(Client genAiClient, GoogleGenAiChatOptions defaultOptions, - ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, ObservationRegistry observationRegistry, - ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { - - Assert.notNull(genAiClient, "GenAI Client must not be null"); - Assert.notNull(defaultOptions, "GoogleGenAiChatOptions must not be null"); - Assert.notNull(defaultOptions.getModel(), "GoogleGenAiChatOptions.modelName must not be null"); - Assert.notNull(retryTemplate, "RetryTemplate must not be null"); - Assert.notNull(toolCallingManager, "ToolCallingManager must not be null"); - Assert.notNull(toolExecutionEligibilityPredicate, "ToolExecutionEligibilityPredicate must not be null"); - - this.genAiClient = genAiClient; - this.defaultOptions = defaultOptions; - // GenerationConfig is now created per request - this.retryTemplate = retryTemplate; - this.observationRegistry = observationRegistry; - this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; - // Initialize cached content service only if the client supports it - this.cachedContentService = (genAiClient != null && genAiClient.caches != null && genAiClient.async != null - && genAiClient.async.caches != null) ? new GoogleGenAiCachedContentService(genAiClient) : null; - - // Wrap the provided tool calling manager in a GoogleGenAiToolCallingManager to - // ensure - // compatibility with Vertex AI's OpenAPI schema format. - if (toolCallingManager instanceof GoogleGenAiToolCallingManager) { - this.toolCallingManager = toolCallingManager; - } - else { - this.toolCallingManager = new GoogleGenAiToolCallingManager(toolCallingManager); - } - } - - private static GeminiMessageType toGeminiMessageType(@NonNull MessageType type) { - - Assert.notNull(type, "Message type must not be null"); - - return switch (type) { - case SYSTEM, USER, TOOL -> GeminiMessageType.USER; - case ASSISTANT -> GeminiMessageType.MODEL; - default -> throw new IllegalArgumentException("Unsupported message type: " + type); - }; - } - - static List messageToGeminiParts(Message message) { - - if (message instanceof SystemMessage systemMessage) { - - List parts = new ArrayList<>(); - - if (systemMessage.getText() != null) { - parts.add(Part.fromText(systemMessage.getText())); - } - - return parts; - } - else if (message instanceof UserMessage userMessage) { - List parts = new ArrayList<>(); - if (userMessage.getText() != null) { - parts.add(Part.fromText(userMessage.getText())); - } - - parts.addAll(mediaToParts(userMessage.getMedia())); - - return parts; - } - else if (message instanceof AssistantMessage assistantMessage) { - List parts = new ArrayList<>(); - - // Check if there are thought signatures to restore. - // Per Google's documentation, thought signatures must be attached to the - // first functionCall part in each step of the current turn. - // See: https://ai.google.dev/gemini-api/docs/thought-signatures - List thoughtSignatures = null; - if (assistantMessage.getMetadata() != null - && assistantMessage.getMetadata().containsKey("thoughtSignatures")) { - Object signaturesObj = assistantMessage.getMetadata().get("thoughtSignatures"); - if (signaturesObj instanceof List) { - thoughtSignatures = new ArrayList<>((List) signaturesObj); - } - } - - // Add text part (without thought signature - signatures go on functionCall - // parts) - if (StringUtils.hasText(assistantMessage.getText())) { - parts.add(Part.builder().text(assistantMessage.getText()).build()); - } - - // Add function call parts with thought signatures attached. - // Per Google's docs: "The first functionCall part in each step of the - // current turn must include its thought_signature." - if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { - List toolCalls = assistantMessage.getToolCalls(); - for (int i = 0; i < toolCalls.size(); i++) { - AssistantMessage.ToolCall toolCall = toolCalls.get(i); - Part.Builder partBuilder = Part.builder() - .functionCall(FunctionCall.builder() - .name(toolCall.name()) - .args(parseJsonToMap(toolCall.arguments())) - .build()); - - // Attach thought signature to function call part if available - if (thoughtSignatures != null && !thoughtSignatures.isEmpty()) { - partBuilder.thoughtSignature(thoughtSignatures.remove(0)); - } - - parts.add(partBuilder.build()); - } - } - - return parts; - } - else if (message instanceof ToolResponseMessage toolResponseMessage) { - - return toolResponseMessage.getResponses() - .stream() - .map(response -> Part.builder() - .functionResponse(FunctionResponse.builder() - .name(response.name()) - .response(parseJsonToMap(response.responseData())) - .build()) - .build()) - .toList(); - } - else { - throw new IllegalArgumentException("Gemini doesn't support message type: " + message.getClass()); - } - } - - private static List mediaToParts(Collection media) { - List parts = new ArrayList<>(); - - List mediaParts = media.stream().map(mediaData -> { - Object data = mediaData.getData(); - String mimeType = mediaData.getMimeType().toString(); - - if (data instanceof byte[]) { - return Part.fromBytes((byte[]) data, mimeType); - } - else if (data instanceof URI || data instanceof String) { - // Handle URI or String URLs - String uri = data.toString(); - return Part.fromUri(uri, mimeType); - } - else { - throw new IllegalArgumentException("Unsupported media data type: " + data.getClass()); - } - }).toList(); - - if (!CollectionUtils.isEmpty(mediaParts)) { - parts.addAll(mediaParts); - } - - return parts; - } - - // Helper methods for JSON/Map conversion - private static Map parseJsonToMap(String json) { - try { - // First, try to parse as an array - Object parsed = ModelOptionsUtils.OBJECT_MAPPER.readValue(json, Object.class); - if (parsed instanceof List) { - // It's an array, wrap it in a map with "result" key - Map wrapper = new HashMap<>(); - wrapper.put("result", parsed); - return wrapper; - } - else if (parsed instanceof Map) { - // It's already a map, return it - return (Map) parsed; - } - else { - // It's a primitive or other type, wrap it - Map wrapper = new HashMap<>(); - wrapper.put("result", parsed); - return wrapper; - } - } - catch (Exception e) { - throw new RuntimeException("Failed to parse JSON: " + json, e); - } - } - - private static String mapToJson(Map map) { - try { - return ModelOptionsUtils.OBJECT_MAPPER.writeValueAsString(map); - } - catch (Exception e) { - throw new RuntimeException("Failed to convert map to JSON", e); - } - } - - private static Schema jsonToSchema(String json) { - try { - // Parse JSON into Schema using OBJECT_MAPPER - return ModelOptionsUtils.OBJECT_MAPPER.readValue(json, Schema.class); - } - catch (Exception e) { - throw new RuntimeException(e); - } - } - - // https://googleapis.github.io/java-genai/javadoc/com/google/genai/types/GenerationConfig.html - @Override - public ChatResponse call(Prompt prompt) { - var requestPrompt = this.buildRequestPrompt(prompt); - return this.internalCall(requestPrompt, null); - } - - private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { - - ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(prompt) - .provider(GoogleGenAiConstants.PROVIDER_NAME) - .build(); - - ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION - .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, - this.observationRegistry) - .observe(() -> this.retryTemplate.execute(context -> { - - var geminiRequest = createGeminiRequest(prompt); - - GenerateContentResponse generateContentResponse = this.getContentResponse(geminiRequest); - - List generations = generateContentResponse.candidates() - .orElse(List.of()) - .stream() - .map(this::responseCandidateToGeneration) - .flatMap(List::stream) - .toList(); - - var usage = generateContentResponse.usageMetadata(); - GoogleGenAiChatOptions options = (GoogleGenAiChatOptions) prompt.getOptions(); - Usage currentUsage = (usage.isPresent()) ? getDefaultUsage(usage.get(), options) - : getDefaultUsage(null, options); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(currentUsage, previousChatResponse); - ChatResponse chatResponse = new ChatResponse(generations, - toChatResponseMetadata(cumulativeUsage, generateContentResponse.modelVersion().get())); - - observationContext.setResponse(chatResponse); - return chatResponse; - })); - - if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { - var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); - if (toolExecutionResult.returnDirect()) { - // Return tool execution result directly to the client. - return ChatResponse.builder() - .from(response) - .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) - .build(); - } - else { - // Send the tool execution result back to the model. - return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), - response); - } - } - - return response; - - } - - Prompt buildRequestPrompt(Prompt prompt) { - // Process runtime options - GoogleGenAiChatOptions runtimeOptions = null; - if (prompt.getOptions() != null) { - if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) { - runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class, - GoogleGenAiChatOptions.class); - } - else { - runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, - GoogleGenAiChatOptions.class); - } - } - - // Define request options by merging runtime options and default options - GoogleGenAiChatOptions requestOptions = ModelOptionsUtils.merge(runtimeOptions, this.defaultOptions, - GoogleGenAiChatOptions.class); - - // Merge @JsonIgnore-annotated options explicitly since they are ignored by - // Jackson, used by ModelOptionsUtils. - if (runtimeOptions != null) { - requestOptions.setInternalToolExecutionEnabled( - ModelOptionsUtils.mergeOption(runtimeOptions.getInternalToolExecutionEnabled(), - this.defaultOptions.getInternalToolExecutionEnabled())); - requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(), - this.defaultOptions.getToolNames())); - requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(), - this.defaultOptions.getToolCallbacks())); - requestOptions.setToolContext(ToolCallingChatOptions.mergeToolContext(runtimeOptions.getToolContext(), - this.defaultOptions.getToolContext())); - - requestOptions.setGoogleSearchRetrieval(ModelOptionsUtils.mergeOption( - runtimeOptions.getGoogleSearchRetrieval(), this.defaultOptions.getGoogleSearchRetrieval())); - requestOptions.setSafetySettings(ModelOptionsUtils.mergeOption(runtimeOptions.getSafetySettings(), - this.defaultOptions.getSafetySettings())); - requestOptions - .setLabels(ModelOptionsUtils.mergeOption(runtimeOptions.getLabels(), this.defaultOptions.getLabels())); - } - else { - requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.getInternalToolExecutionEnabled()); - requestOptions.setToolNames(this.defaultOptions.getToolNames()); - requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); - requestOptions.setToolContext(this.defaultOptions.getToolContext()); - - requestOptions.setGoogleSearchRetrieval(this.defaultOptions.getGoogleSearchRetrieval()); - requestOptions.setSafetySettings(this.defaultOptions.getSafetySettings()); - requestOptions.setLabels(this.defaultOptions.getLabels()); - } - - ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); - - return new Prompt(prompt.getInstructions(), requestOptions); - } - - @Override - public Flux stream(Prompt prompt) { - var requestPrompt = this.buildRequestPrompt(prompt); - return this.internalStream(requestPrompt, null); - } - - public Flux internalStream(Prompt prompt, ChatResponse previousChatResponse) { - return Flux.deferContextual(contextView -> { - - ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(prompt) - .provider(GoogleGenAiConstants.PROVIDER_NAME) - .build(); - - Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( - this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, - this.observationRegistry); - - observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); - - var request = createGeminiRequest(prompt); - - try { - ResponseStream responseStream = this.genAiClient.models - .generateContentStream(request.modelName, request.contents, request.config); - - Flux chatResponseFlux = Flux.fromIterable(responseStream).switchMap(response -> { - List generations = response.candidates() - .orElse(List.of()) - .stream() - .map(this::responseCandidateToGeneration) - .flatMap(List::stream) - .toList(); - - var usage = response.usageMetadata(); - GoogleGenAiChatOptions options = (GoogleGenAiChatOptions) prompt.getOptions(); - Usage currentUsage = usage.isPresent() ? getDefaultUsage(usage.get(), options) - : getDefaultUsage(null, options); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(currentUsage, previousChatResponse); - ChatResponse chatResponse = new ChatResponse(generations, - toChatResponseMetadata(cumulativeUsage, response.modelVersion().get())); - return Flux.just(chatResponse); - }); - - // @formatter:off - Flux flux = chatResponseFlux.flatMap(response -> { - if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { - // FIXME: bounded elastic needs to be used since tool calling - // is currently only synchronous - return Flux.deferContextual(ctx -> { - ToolExecutionResult toolExecutionResult; - try { - ToolCallReactiveContextHolder.setContext(ctx); - toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); - } - finally { - ToolCallReactiveContextHolder.clearContext(); - } - if (toolExecutionResult.returnDirect()) { - // Return tool execution result directly to the client. - return Flux.just(ChatResponse.builder().from(response) - .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) - .build()); - } - else { - // Send the tool execution result back to the model. - return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), response); - } - }).subscribeOn(Schedulers.boundedElastic()); - } - else { - return Flux.just(response); - } - }) - .doOnError(observation::error) - .doFinally(s -> observation.stop()) - .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); - // @formatter:on; - - return new MessageAggregator().aggregate(flux, observationContext::setResponse); - - } - catch (Exception e) { - throw new RuntimeException("Failed to generate content", e); - } - - }); - } - - protected List responseCandidateToGeneration(Candidate candidate) { - - // TODO - The candidateIndex (e.g. choice must be assigned to the generation). - int candidateIndex = candidate.index().orElse(0); - FinishReason candidateFinishReason = candidate.finishReason().orElse(new FinishReason(FinishReason.Known.STOP)); - - Map messageMetadata = new HashMap<>(); - messageMetadata.put("candidateIndex", candidateIndex); - messageMetadata.put("finishReason", candidateFinishReason); - - // Extract thought signatures from response parts if present - if (candidate.content().isPresent() && candidate.content().get().parts().isPresent()) { - List parts = candidate.content().get().parts().get(); - List thoughtSignatures = parts.stream() - .filter(part -> part.thoughtSignature().isPresent()) - .map(part -> part.thoughtSignature().get()) - .toList(); - - if (!thoughtSignatures.isEmpty()) { - messageMetadata.put("thoughtSignatures", thoughtSignatures); - } - } - - ChatGenerationMetadata chatGenerationMetadata = ChatGenerationMetadata.builder() - .finishReason(candidateFinishReason.toString()) - .build(); - - boolean isFunctionCall = candidate.content().isPresent() && candidate.content().get().parts().isPresent() - && candidate.content().get().parts().get().stream().allMatch(part -> part.functionCall().isPresent()); - - if (isFunctionCall) { - List assistantToolCalls = candidate.content() - .get() - .parts() - .orElse(List.of()) - .stream() - .filter(part -> part.functionCall().isPresent()) - .map(part -> { - FunctionCall functionCall = part.functionCall().get(); - var functionName = functionCall.name().orElse(""); - String functionArguments = mapToJson(functionCall.args().orElse(Map.of())); - return new AssistantMessage.ToolCall("", "function", functionName, functionArguments); - }) - .toList(); - - AssistantMessage assistantMessage = AssistantMessage.builder() - .content("") - .properties(messageMetadata) - .toolCalls(assistantToolCalls) - .build(); - - return List.of(new Generation(assistantMessage, chatGenerationMetadata)); - } - else { - return candidate.content() - .get() - .parts() - .orElse(List.of()) - .stream() - .map(part -> AssistantMessage.builder() - .content(part.text().orElse("")) - .properties(messageMetadata) - .build()) - .map(assistantMessage -> new Generation(assistantMessage, chatGenerationMetadata)) - .toList(); - } - } - - private ChatResponseMetadata toChatResponseMetadata(Usage usage, String modelVersion) { - return ChatResponseMetadata.builder().usage(usage).model(modelVersion).build(); - } - - private Usage getDefaultUsage(com.google.genai.types.GenerateContentResponseUsageMetadata usageMetadata, - GoogleGenAiChatOptions options) { - // Check if extended metadata should be included (default to true if not - // configured) - boolean includeExtended = true; - if (options != null && options.getIncludeExtendedUsageMetadata() != null) { - includeExtended = options.getIncludeExtendedUsageMetadata(); - } - else if (this.defaultOptions.getIncludeExtendedUsageMetadata() != null) { - includeExtended = this.defaultOptions.getIncludeExtendedUsageMetadata(); - } - - if (includeExtended) { - return GoogleGenAiUsage.from(usageMetadata); - } - else { - // Fall back to basic usage for backward compatibility - return new DefaultUsage(usageMetadata.promptTokenCount().orElse(0), - usageMetadata.candidatesTokenCount().orElse(0), usageMetadata.totalTokenCount().orElse(0)); - } - } - - GeminiRequest createGeminiRequest(Prompt prompt) { - - GoogleGenAiChatOptions requestOptions = (GoogleGenAiChatOptions) prompt.getOptions(); - - // Build GenerateContentConfig - GenerateContentConfig.Builder configBuilder = GenerateContentConfig.builder(); - - String modelName = requestOptions.getModel() != null ? requestOptions.getModel() - : this.defaultOptions.getModel(); - - // Set generation config parameters directly on configBuilder - if (requestOptions.getTemperature() != null) { - configBuilder.temperature(requestOptions.getTemperature().floatValue()); - } - if (requestOptions.getMaxOutputTokens() != null) { - configBuilder.maxOutputTokens(requestOptions.getMaxOutputTokens()); - } - if (requestOptions.getTopK() != null) { - configBuilder.topK(requestOptions.getTopK().floatValue()); - } - if (requestOptions.getTopP() != null) { - configBuilder.topP(requestOptions.getTopP().floatValue()); - } - if (requestOptions.getCandidateCount() != null) { - configBuilder.candidateCount(requestOptions.getCandidateCount()); - } - if (requestOptions.getStopSequences() != null) { - configBuilder.stopSequences(requestOptions.getStopSequences()); - } - if (requestOptions.getResponseMimeType() != null) { - configBuilder.responseMimeType(requestOptions.getResponseMimeType()); - } - if (requestOptions.getResponseSchema() != null) { - configBuilder.responseJsonSchema(jsonToSchema(requestOptions.getResponseSchema())); - } - if (requestOptions.getFrequencyPenalty() != null) { - configBuilder.frequencyPenalty(requestOptions.getFrequencyPenalty().floatValue()); - } - if (requestOptions.getPresencePenalty() != null) { - configBuilder.presencePenalty(requestOptions.getPresencePenalty().floatValue()); - } - - // Build thinking config if any thinking option is set - if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null - || requestOptions.getThinkingLevel() != null) { - ThinkingConfig.Builder thinkingBuilder = ThinkingConfig.builder(); - if (requestOptions.getThinkingBudget() != null) { - thinkingBuilder.thinkingBudget(requestOptions.getThinkingBudget()); - } - if (requestOptions.getIncludeThoughts() != null) { - thinkingBuilder.includeThoughts(requestOptions.getIncludeThoughts()); - } - if (requestOptions.getThinkingLevel() != null) { - thinkingBuilder.thinkingLevel(mapToGenAiThinkingLevel(requestOptions.getThinkingLevel())); - } - configBuilder.thinkingConfig(thinkingBuilder.build()); - } - - if (requestOptions.getLabels() != null && !requestOptions.getLabels().isEmpty()) { - configBuilder.labels(requestOptions.getLabels()); - } - - // Add safety settings - if (!CollectionUtils.isEmpty(requestOptions.getSafetySettings())) { - configBuilder.safetySettings(toGeminiSafetySettings(requestOptions.getSafetySettings())); - } - - // Add tools - List tools = new ArrayList<>(); - List toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions); - if (!CollectionUtils.isEmpty(toolDefinitions)) { - final List functionDeclarations = toolDefinitions.stream() - .map(toolDefinition -> FunctionDeclaration.builder() - .name(toolDefinition.name()) - .description(toolDefinition.description()) - .parameters(jsonToSchema(toolDefinition.inputSchema())) - .build()) - .toList(); - tools.add(Tool.builder().functionDeclarations(functionDeclarations).build()); - } - - if (prompt.getOptions() instanceof GoogleGenAiChatOptions options && options.getGoogleSearchRetrieval()) { - var googleSearch = GoogleSearch.builder().build(); - final var googleSearchRetrievalTool = Tool.builder().googleSearch(googleSearch).build(); - tools.add(googleSearchRetrievalTool); - } - - if (!CollectionUtils.isEmpty(tools)) { - configBuilder.tools(tools); - } - - // Handle cached content - if (requestOptions.getUseCachedContent() != null && requestOptions.getUseCachedContent() - && requestOptions.getCachedContentName() != null) { - // Set the cached content name in the config - configBuilder.cachedContent(requestOptions.getCachedContentName()); - logger.debug("Using cached content: {}", requestOptions.getCachedContentName()); - } - - // Handle system instruction - List systemContents = toGeminiContent( - prompt.getInstructions().stream().filter(m -> m.getMessageType() == MessageType.SYSTEM).toList()); - - if (!CollectionUtils.isEmpty(systemContents)) { - Assert.isTrue(systemContents.size() <= 1, "Only one system message is allowed in the prompt"); - configBuilder.systemInstruction(systemContents.get(0)); - } - - GenerateContentConfig config = configBuilder.build(); - - // Create message contents - return new GeminiRequest(toGeminiContent( - prompt.getInstructions().stream().filter(m -> m.getMessageType() != MessageType.SYSTEM).toList()), - modelName, config); - } - - // Helper methods for mapping safety settings enums - private static com.google.genai.types.HarmCategory mapToGenAiHarmCategory( - GoogleGenAiSafetySetting.HarmCategory category) { - return switch (category) { - case HARM_CATEGORY_UNSPECIFIED -> new com.google.genai.types.HarmCategory( - com.google.genai.types.HarmCategory.Known.HARM_CATEGORY_UNSPECIFIED); - case HARM_CATEGORY_HATE_SPEECH -> new com.google.genai.types.HarmCategory( - com.google.genai.types.HarmCategory.Known.HARM_CATEGORY_HATE_SPEECH); - case HARM_CATEGORY_DANGEROUS_CONTENT -> new com.google.genai.types.HarmCategory( - com.google.genai.types.HarmCategory.Known.HARM_CATEGORY_DANGEROUS_CONTENT); - case HARM_CATEGORY_HARASSMENT -> new com.google.genai.types.HarmCategory( - com.google.genai.types.HarmCategory.Known.HARM_CATEGORY_HARASSMENT); - case HARM_CATEGORY_SEXUALLY_EXPLICIT -> new com.google.genai.types.HarmCategory( - com.google.genai.types.HarmCategory.Known.HARM_CATEGORY_SEXUALLY_EXPLICIT); - default -> throw new IllegalArgumentException("Unknown HarmCategory: " + category); - }; - } - - private static com.google.genai.types.HarmBlockThreshold mapToGenAiHarmBlockThreshold( - GoogleGenAiSafetySetting.HarmBlockThreshold threshold) { - return switch (threshold) { - case HARM_BLOCK_THRESHOLD_UNSPECIFIED -> new com.google.genai.types.HarmBlockThreshold( - com.google.genai.types.HarmBlockThreshold.Known.HARM_BLOCK_THRESHOLD_UNSPECIFIED); - case BLOCK_LOW_AND_ABOVE -> new com.google.genai.types.HarmBlockThreshold( - com.google.genai.types.HarmBlockThreshold.Known.BLOCK_LOW_AND_ABOVE); - case BLOCK_MEDIUM_AND_ABOVE -> new com.google.genai.types.HarmBlockThreshold( - com.google.genai.types.HarmBlockThreshold.Known.BLOCK_MEDIUM_AND_ABOVE); - case BLOCK_ONLY_HIGH -> new com.google.genai.types.HarmBlockThreshold( - com.google.genai.types.HarmBlockThreshold.Known.BLOCK_ONLY_HIGH); - case BLOCK_NONE -> new com.google.genai.types.HarmBlockThreshold( - com.google.genai.types.HarmBlockThreshold.Known.BLOCK_NONE); - case OFF -> - new com.google.genai.types.HarmBlockThreshold(com.google.genai.types.HarmBlockThreshold.Known.OFF); - default -> throw new IllegalArgumentException("Unknown HarmBlockThreshold: " + threshold); - }; - } - - private static ThinkingLevel mapToGenAiThinkingLevel(GoogleGenAiThinkingLevel level) { - return switch (level) { - case THINKING_LEVEL_UNSPECIFIED -> new ThinkingLevel(ThinkingLevel.Known.THINKING_LEVEL_UNSPECIFIED); - case LOW -> new ThinkingLevel(ThinkingLevel.Known.LOW); - case HIGH -> new ThinkingLevel(ThinkingLevel.Known.HIGH); - }; - } - - private List toGeminiContent(List instructions) { - - List contents = instructions.stream() - .map(message -> Content.builder() - .role(toGeminiMessageType(message.getMessageType()).getValue()) - .parts(messageToGeminiParts(message)) - .build()) - .toList(); - - return contents; - } - - private List toGeminiSafetySettings(List safetySettings) { - return safetySettings.stream() - .map(safetySetting -> SafetySetting.builder() - .category(mapToGenAiHarmCategory(safetySetting.getCategory())) - .threshold(mapToGenAiHarmBlockThreshold(safetySetting.getThreshold())) - .build()) - .toList(); - } - - /** - * Generates the content response based on the provided Gemini request. Package - * protected for testing purposes. - * @param request the GeminiRequest containing the content and model information - * @return a GenerateContentResponse containing the generated content - * @throws RuntimeException if content generation fails - */ - GenerateContentResponse getContentResponse(GeminiRequest request) { - try { - return this.genAiClient.models.generateContent(request.modelName, request.contents, request.config); - } - catch (Exception e) { - throw new RuntimeException("Failed to generate content", e); - } - } - - @Override - public ChatOptions getDefaultOptions() { - return GoogleGenAiChatOptions.fromOptions(this.defaultOptions); - } - - /** - * Gets the cached content service for managing cached content. - * @return the cached content service - */ - public GoogleGenAiCachedContentService getCachedContentService() { - return this.cachedContentService; - } - - @Override - public void destroy() throws Exception { - // GenAI Client doesn't need explicit closing - } - - /** - * Use the provided convention for reporting observation data - * @param observationConvention The provided convention - */ - public void setObservationConvention(ChatModelObservationConvention observationConvention) { - Assert.notNull(observationConvention, "observationConvention cannot be null"); - this.observationConvention = observationConvention; - } - - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - - private Client genAiClient; - - private GoogleGenAiChatOptions defaultOptions = GoogleGenAiChatOptions.builder() - .temperature(0.7) - .topP(1.0) - .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH) - .build(); - - private ToolCallingManager toolCallingManager; - - private ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate(); - - private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; - - private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; - - private Builder() { - } - - public Builder genAiClient(Client genAiClient) { - this.genAiClient = genAiClient; - return this; - } - - public Builder defaultOptions(GoogleGenAiChatOptions defaultOptions) { - this.defaultOptions = defaultOptions; - return this; - } - - public Builder toolCallingManager(ToolCallingManager toolCallingManager) { - this.toolCallingManager = toolCallingManager; - return this; - } - - public Builder toolExecutionEligibilityPredicate( - ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { - this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; - return this; - } - - public Builder retryTemplate(RetryTemplate retryTemplate) { - this.retryTemplate = retryTemplate; - return this; - } - - public Builder observationRegistry(ObservationRegistry observationRegistry) { - this.observationRegistry = observationRegistry; - return this; - } - - public GoogleGenAiChatModel build() { - if (this.toolCallingManager != null) { - return new GoogleGenAiChatModel(this.genAiClient, this.defaultOptions, this.toolCallingManager, - this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate); - } - return new GoogleGenAiChatModel(this.genAiClient, this.defaultOptions, DEFAULT_TOOL_CALLING_MANAGER, - this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate); - } - - } - - public enum GeminiMessageType { - - USER("user"), - - MODEL("model"); - - public final String value; - - GeminiMessageType(String value) { - this.value = value; - } - - public String getValue() { - return this.value; - } - - } - - public enum ChatModel implements ChatModelDescription { - - /** - * gemini-1.5-pro is recommended to upgrade to gemini-2.0-flash - *

- * Discontinuation date: September 24, 2025 - *

- * See: stable-version - */ - GEMINI_1_5_PRO("gemini-1.5-pro-002"), - - /** - * gemini-1.5-flash is recommended to upgrade to - * gemini-2.0-flash-lite - *

- * Discontinuation date: September 24, 2025 - *

- * See: stable-version - */ - GEMINI_1_5_FLASH("gemini-1.5-flash-002"), - - /** - * gemini-2.0-flash delivers next-gen features and improved capabilities, - * including superior speed, built-in tool use, multimodal generation, and a 1M - * token context window. - *

- * Inputs: Text, Code, Images, Audio, Video - 1,048,576 tokens | Outputs: Text, - * Audio(Experimental), Images(Experimental) - 8,192 tokens - *

- * Knowledge cutoff: June 2024 - *

- * Model ID: gemini-2.0-flash - *

- * See: gemini-2.0-flash - */ - GEMINI_2_0_FLASH("gemini-2.0-flash-001"), - - /** - * gemini-2.0-flash-lite is the fastest and most cost efficient Flash - * model. It's an upgrade path for 1.5 Flash users who want better quality for the - * same price and speed. - *

- * Inputs: Text, Code, Images, Audio, Video - 1,048,576 tokens | Outputs: Text - - * 8,192 tokens - *

- * Knowledge cutoff: June 2024 - *

- * Model ID: gemini-2.0-flash-lite - *

- * See: gemini-2.0-flash-lite - */ - GEMINI_2_0_FLASH_LIGHT("gemini-2.0-flash-lite-001"), - - /** - * gemini-2.5-pro is the most advanced reasoning Gemini model, capable of - * solving complex problems. - *

- * Inputs: Text, Code, Images, Audio, Video - 1,048,576 tokens | Outputs: Text - - * 65,536 tokens - *

- * Knowledge cutoff: January 2025 - *

- * Model ID: gemini-2.5-pro-preview-05-06 - *

- * See: gemini-2.5-pro - */ - GEMINI_2_5_PRO("gemini-2.5-pro"), - - /** - * gemini-2.5-flash is a thinking model that offers great, well-rounded - * capabilities. It is designed to offer a balance between price and performance. - *

- * Inputs: Text, Code, Images, Audio, Video - 1,048,576 tokens | Outputs: Text - - * 65,536 tokens - *

- * Knowledge cutoff: January 2025 - *

- * Model ID: gemini-2.5-flash-preview-04-17 - *

- * See: gemini-2.5-flash - */ - GEMINI_2_5_FLASH("gemini-2.5-flash"), - - /** - * gemini-2.5-flash-lite is the fastest and most cost efficient Flash - * model. It's an upgrade path for 2.0 Flash users who want better quality for the - * same price and speed. - *

- * Inputs: Text, Code, Images, Audio, Video - 1,048,576 tokens | Outputs: Text - - * 8,192 tokens - *

- * Knowledge cutoff: Jan 2025 - *

- * Model ID: gemini-2.0-flash-lite - *

- * See: gemini-2.5-flash-lite - */ - GEMINI_2_5_FLASH_LIGHT("gemini-2.5-flash-lite"), - - GEMINI_3_PRO_PREVIEW("gemini-3-pro-preview"); - - public final String value; - - ChatModel(String value) { - this.value = value; - } - - public String getValue() { - return this.value; - } - - @Override - public String getName() { - return this.value; - } - - } - - @JsonInclude(Include.NON_NULL) - public record GeminiRequest(List contents, String modelName, GenerateContentConfig config) { - - } - -} From 3b6f9d2e19693349b5997a52600b8470fd8e07ef Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:37:12 +0900 Subject: [PATCH 02/10] =?UTF-8?q?ICC-381=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20admin=20=EB=AA=A8=EB=93=88=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminController.uploadImage 인라인 검증을 private validateImage로 추출 - 하드코딩 상수(허용 타입/최대 크기)를 ImageUploadProperties(@ConfigurationProperties)로 분리 - q-asker.admin.image-upload 설정을 app-common.yml에 추가 (문서 업로드용 file-validation과 분리) - oci FileValidateServiceImpl: null contentType 에러를 EXTENSION_NOT_EXIST→EXTENSION_INVALID로 통일 - AdminControllerTest 신규 9케이스 추가 (admin 모듈 테스트 셋업 최초 구성) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/resources/config/app-common.yml | 6 + modules/admin/build.gradle | 11 ++ modules/admin/gradle.lockfile | 30 ++- .../admin/controller/AdminController.java | 33 ++-- .../properties/ImageUploadProperties.java | 15 ++ .../admin/controller/AdminControllerTest.java | 180 ++++++++++++++++++ .../oci/service/FileValidateServiceImpl.java | 5 +- 7 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 modules/admin/src/main/java/com/icc/qasker/admin/properties/ImageUploadProperties.java create mode 100644 modules/admin/src/test/java/com/icc/qasker/admin/controller/AdminControllerTest.java diff --git a/app/src/main/resources/config/app-common.yml b/app/src/main/resources/config/app-common.yml index e6be2192..302bdd06 100644 --- a/app/src/main/resources/config/app-common.yml +++ b/app/src/main/resources/config/app-common.yml @@ -13,6 +13,12 @@ q-asker: allowed-extensions: application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint max-file-size: 52_428_800 + # 관리자 이미지 업로드 검증 (문서 업로드와 규칙이 달라 분리) + admin: + image-upload: + max-size: 5_242_880 # 5MB + allowed-types: image/jpeg,image/png,image/gif,image/webp + server: shutdown: graceful diff --git a/modules/admin/build.gradle b/modules/admin/build.gradle index e51d303b..22162ec1 100644 --- a/modules/admin/build.gradle +++ b/modules/admin/build.gradle @@ -23,4 +23,15 @@ dependencies { // API 문서 (Swagger) // ──────────────────────────────── implementation libs.springdoc.openapi.starter.webmvc.ui + + // ──────────────────────────────── + // 테스트 + // ──────────────────────────────── + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation platform(libs.junit.bom) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} + +test { + useJUnitPlatform() } diff --git a/modules/admin/gradle.lockfile b/modules/admin/gradle.lockfile index 934f3cc7..5e709e7e 100644 --- a/modules/admin/gradle.lockfile +++ b/modules/admin/gradle.lockfile @@ -14,8 +14,10 @@ com.fasterxml.jackson:jackson-bom:2.21.4=compileClasspath,runtimeClasspath,testC com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=runtimeClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=runtimeClasspath,testRuntimeClasspath @@ -44,7 +46,10 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=runtimeClasspath,testRuntimeCl jakarta.transaction:jakarta.transaction-api:2.0.1=runtimeClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=runtimeClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -52,11 +57,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=runtimeClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=runtimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -65,11 +74,25 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=runtimeClasspath,testRuntimeCla org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -92,11 +115,14 @@ org.springframework.boot:spring-boot-starter-data-jpa:4.1.0=runtimeClasspath,tes org.springframework.boot:spring-boot-starter-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.1.0=runtimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=runtimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -113,11 +139,13 @@ org.springframework:spring-core:7.0.8=compileClasspath,runtimeClasspath,testComp org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.8=runtimeClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=runtimeClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/modules/admin/src/main/java/com/icc/qasker/admin/controller/AdminController.java b/modules/admin/src/main/java/com/icc/qasker/admin/controller/AdminController.java index 32b4ca87..71208587 100644 --- a/modules/admin/src/main/java/com/icc/qasker/admin/controller/AdminController.java +++ b/modules/admin/src/main/java/com/icc/qasker/admin/controller/AdminController.java @@ -1,5 +1,6 @@ package com.icc.qasker.admin.controller; +import com.icc.qasker.admin.properties.ImageUploadProperties; import com.icc.qasker.board.BoardAdminService; import com.icc.qasker.board.dto.request.PostRequest; import com.icc.qasker.board.dto.request.ReplyRequest; @@ -14,7 +15,6 @@ import jakarta.validation.Valid; import java.io.IOException; import java.util.Map; -import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -33,12 +33,9 @@ @RequiredArgsConstructor public class AdminController { - private static final Set ALLOWED_IMAGE_TYPES = - Set.of("image/jpeg", "image/png", "image/gif", "image/webp"); - private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB - private final BoardAdminService boardAdminService; private final ObjectStorageService objectStorageService; + private final ImageUploadProperties imageUploadProperties; @Operation(summary = "업데이트 로그를 작성한다") @RateLimit(RateLimitTier.WRITE) @@ -71,16 +68,7 @@ public ResponseEntity reply( @RateLimit(RateLimitTier.WRITE) @PostMapping(value = "/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> uploadImage(@RequestParam("file") MultipartFile file) { - if (file.isEmpty()) { - throw new CustomException(ExceptionMessage.FILE_NAME_NOT_EXIST); - } - if (file.getSize() > MAX_IMAGE_SIZE) { - throw new CustomException(ExceptionMessage.OUT_OF_FILE_SIZE); - } - String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { - throw new CustomException(ExceptionMessage.EXTENSION_INVALID); - } + String contentType = validateImage(file); try { String url = @@ -91,4 +79,19 @@ public ResponseEntity> uploadImage(@RequestParam("file") Mul throw new CustomException(ExceptionMessage.FILE_UPLOAD_FAILED); } } + + /** 업로드 이미지의 존재·크기·contentType을 검증하고 검증된 contentType을 반환한다. */ + private String validateImage(MultipartFile file) { + if (file.isEmpty()) { + throw new CustomException(ExceptionMessage.FILE_NAME_NOT_EXIST); + } + if (file.getSize() > imageUploadProperties.maxSize()) { + throw new CustomException(ExceptionMessage.OUT_OF_FILE_SIZE); + } + String contentType = file.getContentType(); + if (contentType == null || !imageUploadProperties.allowedTypes().contains(contentType)) { + throw new CustomException(ExceptionMessage.EXTENSION_INVALID); + } + return contentType; + } } diff --git a/modules/admin/src/main/java/com/icc/qasker/admin/properties/ImageUploadProperties.java b/modules/admin/src/main/java/com/icc/qasker/admin/properties/ImageUploadProperties.java new file mode 100644 index 00000000..91b2cffd --- /dev/null +++ b/modules/admin/src/main/java/com/icc/qasker/admin/properties/ImageUploadProperties.java @@ -0,0 +1,15 @@ +package com.icc.qasker.admin.properties; + +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 관리자 이미지 업로드 검증 설정. + * + *

문서 업로드용 {@code q-asker.file-validation}(PDF/PPT/DOCX·50MB)과 규칙이 다르므로 별도 프리픽스로 분리한다. + * + * @param maxSize 허용 최대 크기(바이트) + * @param allowedTypes 허용 contentType 집합 + */ +@ConfigurationProperties(prefix = "q-asker.admin.image-upload") +public record ImageUploadProperties(long maxSize, Set allowedTypes) {} diff --git a/modules/admin/src/test/java/com/icc/qasker/admin/controller/AdminControllerTest.java b/modules/admin/src/test/java/com/icc/qasker/admin/controller/AdminControllerTest.java new file mode 100644 index 00000000..b2e65e1d --- /dev/null +++ b/modules/admin/src/test/java/com/icc/qasker/admin/controller/AdminControllerTest.java @@ -0,0 +1,180 @@ +package com.icc.qasker.admin.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.admin.properties.ImageUploadProperties; +import com.icc.qasker.board.BoardAdminService; +import com.icc.qasker.board.dto.request.PostRequest; +import com.icc.qasker.board.dto.request.ReplyRequest; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.oci.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +/** + * AdminController 회귀 테스트. + * + *

이미지 업로드 검증 분기를 private 메서드로 추출하는 리팩터링의 안전망이다. 검증 순서/에러 코드/위임 호출이 리팩터링 전후로 동일함을 보장한다. + */ +class AdminControllerTest { + + private static final ImageUploadProperties IMAGE_PROPERTIES = + new ImageUploadProperties( + 5 * 1024 * 1024, Set.of("image/jpeg", "image/png", "image/gif", "image/webp")); + + private BoardAdminService boardAdminService; + private ObjectStorageService objectStorageService; + private AdminController adminController; + + @BeforeEach + void setUp() { + boardAdminService = mock(BoardAdminService.class); + objectStorageService = mock(ObjectStorageService.class); + adminController = + new AdminController(boardAdminService, objectStorageService, IMAGE_PROPERTIES); + } + + private MultipartFile validImageFile() throws IOException { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getContentType()).thenReturn("image/png"); + when(file.getOriginalFilename()).thenReturn("shot.png"); + when(file.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[] {1, 2, 3})); + return file; + } + + @Nested + @DisplayName("이미지 업로드 검증") + class UploadImageValidation { + + @Test + @DisplayName("빈 파일이면 FILE_NAME_NOT_EXIST") + void emptyFile() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(true); + + assertThatThrownBy(() -> adminController.uploadImage(file)) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.FILE_NAME_NOT_EXIST.getMessage()); + } + + @Test + @DisplayName("5MB 초과면 OUT_OF_FILE_SIZE") + void tooLarge() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(IMAGE_PROPERTIES.maxSize() + 1); + + assertThatThrownBy(() -> adminController.uploadImage(file)) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.OUT_OF_FILE_SIZE.getMessage()); + } + + @Test + @DisplayName("contentType이 null이면 EXTENSION_INVALID") + void nullContentType() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getContentType()).thenReturn(null); + + assertThatThrownBy(() -> adminController.uploadImage(file)) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.EXTENSION_INVALID.getMessage()); + } + + @Test + @DisplayName("허용되지 않은 contentType이면 EXTENSION_INVALID") + void disallowedContentType() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getContentType()).thenReturn("application/pdf"); + + assertThatThrownBy(() -> adminController.uploadImage(file)) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.EXTENSION_INVALID.getMessage()); + } + } + + @Test + @DisplayName("유효한 이미지면 스토리지에 업로드하고 URL을 반환한다") + void uploadImageSuccess() throws IOException { + MultipartFile file = validImageFile(); + when(objectStorageService.uploadImage(any(), anyLong(), eq("image/png"), eq("shot.png"))) + .thenReturn("https://cdn.example.com/shot.png"); + + ResponseEntity> response = adminController.uploadImage(file); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).containsEntry("url", "https://cdn.example.com/shot.png"); + verify(objectStorageService).uploadImage(any(), eq(1024L), eq("image/png"), eq("shot.png")); + } + + @Test + @DisplayName("스토리지 업로드 중 IOException이면 FILE_UPLOAD_FAILED") + void uploadImageIoException() throws IOException { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getContentType()).thenReturn("image/jpeg"); + when(file.getOriginalFilename()).thenReturn("a.jpg"); + when(file.getInputStream()).thenThrow(new IOException("stream closed")); + + assertThatThrownBy(() -> adminController.uploadImage(file)) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.FILE_UPLOAD_FAILED.getMessage()); + } + + @Nested + @DisplayName("게시판 관리 위임") + class BoardDelegation { + + @Test + @DisplayName("createUpdateLog는 BoardAdminService에 위임한다") + void createUpdateLog() { + PostRequest request = new PostRequest("제목", "내용"); + + adminController.createUpdateLog(request, "admin-1"); + + verify(boardAdminService).createUpdateLog(request, "admin-1"); + } + + @Test + @DisplayName("updateUpdateLog는 BoardAdminService에 위임한다") + void updateUpdateLog() { + PostRequest request = new PostRequest("제목", "내용"); + + adminController.updateUpdateLog(7L, request, "admin-1"); + + verify(boardAdminService).updateUpdateLog(7L, request, "admin-1"); + } + + @Test + @DisplayName("reply는 BoardAdminService에 boardId/userId/content를 전달한다") + void reply() { + ReplyRequest replyRequest = new ReplyRequest("관리자 답변"); + + adminController.reply(replyRequest, 9L, "admin-1"); + + verify(boardAdminService).reply(9L, "admin-1", "관리자 답변"); + } + } +} diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java index d8eadedf..36fce6e7 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/FileValidateServiceImpl.java @@ -37,10 +37,7 @@ public void validateFileWithThrowing(String fileName, long fileSize, String cont if (fileName.length() > maxFileNameLength) { throw new CustomException(ExceptionMessage.FILE_NAME_TOO_LONG); } - if (contentType == null) { - throw new CustomException(ExceptionMessage.EXTENSION_NOT_EXIST); - } - if (!allowedExtensions.contains(contentType)) { + if (contentType == null || !allowedExtensions.contains(contentType)) { throw new CustomException(ExceptionMessage.EXTENSION_INVALID); } } From 18c8a7df9a8e31b786e7f75d483a515c5eed7854 Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:17:13 +0900 Subject: [PATCH 03/10] =?UTF-8?q?ICC-383=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20board=20=EB=AA=A8=EB=93=88=20SOLID=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R1(SRP): PostResponseMapper.toDetail() 추가, getPost() 인라인 DTO 매핑 제거, "운영팀" 상수 단일화 - R2(DRY): Board.ensureModifiableBy() 도메인 메서드 추출, requireUserId() 헬퍼 통일 - R3(DRY): toPageResponse() 헬퍼 추출, UNKNOWN_USERNAME 상수화 - 회귀 테스트: BoardServiceTest(22) + BoardAdminServiceImplTest(4) 선작성 - BoardService 167줄 → 97줄 Co-Authored-By: Claude Sonnet 4.6 --- modules/auth/impl/gradle.lockfile | 27 +- modules/board/impl/build.gradle | 7 + modules/board/impl/gradle.lockfile | 32 +- .../com/icc/qasker/board/entity/Board.java | 14 + .../board/mapper/PostResponseMapper.java | 22 +- .../qasker/board/service/BoardService.java | 103 ++--- .../service/BoardAdminServiceImplTest.java | 107 +++++ .../board/service/BoardServiceTest.java | 368 ++++++++++++++++++ 8 files changed, 600 insertions(+), 80 deletions(-) create mode 100644 modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardAdminServiceImplTest.java create mode 100644 modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardServiceTest.java diff --git a/modules/auth/impl/gradle.lockfile b/modules/auth/impl/gradle.lockfile index 74e52c27..5150f8c5 100644 --- a/modules/auth/impl/gradle.lockfile +++ b/modules/auth/impl/gradle.lockfile @@ -17,12 +17,14 @@ com.github.ben-manes.caffeine:caffeine:3.2.4=compileClasspath,runtimeClasspath,t com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.49.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.nimbusds:content-type:2.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:oauth2-oidc-sdk:11.37.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=runtimeClasspath,testRuntimeClasspath @@ -51,7 +53,8 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClassp jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -61,11 +64,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=runtimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -74,12 +81,25 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=compileClasspath,runtimeClasspa org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath org.ow2.asm:asm:9.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -107,11 +127,14 @@ org.springframework.boot:spring-boot-starter-json:4.1.0=compileClasspath,runtime org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-client:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -135,11 +158,13 @@ org.springframework:spring-core:7.0.8=compileClasspath,runtimeClasspath,testComp org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/modules/board/impl/build.gradle b/modules/board/impl/build.gradle index 6f6ab391..14cc6973 100644 --- a/modules/board/impl/build.gradle +++ b/modules/board/impl/build.gradle @@ -45,4 +45,11 @@ dependencies { // 예: KafkaTemplate을 이용한 간편한 메시지 발행(Publish) 및 @KafkaListener를 통한 선언적 메시지 구독(Subscribe) implementation 'org.springframework.kafka:spring-kafka' + // ──────────────────────────────── + // 테스트 + // ──────────────────────────────── + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation platform(libs.junit.bom) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" + } diff --git a/modules/board/impl/gradle.lockfile b/modules/board/impl/gradle.lockfile index 6596ee32..38d1ba94 100644 --- a/modules/board/impl/gradle.lockfile +++ b/modules/board/impl/gradle.lockfile @@ -18,8 +18,10 @@ com.github.spotbugs:spotbugs-annotations:4.9.8=runtimeClasspath,testRuntimeClass com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=runtimeClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=runtimeClasspath,testRuntimeClasspath @@ -48,7 +50,10 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClassp jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.kafka:kafka-clients:4.2.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -57,11 +62,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=runtimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -70,12 +79,26 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=compileClasspath,runtimeClasspa org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:5.14.0=runtimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:5.14.0=runtimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -98,11 +121,14 @@ org.springframework.boot:spring-boot-starter-data-jpa:4.1.0=compileClasspath,run org.springframework.boot:spring-boot-starter-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -121,12 +147,14 @@ org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,te org.springframework:spring-jdbc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.7=runtimeClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/modules/board/impl/src/main/java/com/icc/qasker/board/entity/Board.java b/modules/board/impl/src/main/java/com/icc/qasker/board/entity/Board.java index a47256b2..eed797ea 100644 --- a/modules/board/impl/src/main/java/com/icc/qasker/board/entity/Board.java +++ b/modules/board/impl/src/main/java/com/icc/qasker/board/entity/Board.java @@ -2,6 +2,8 @@ import com.icc.qasker.board.dto.BoardCategory; import com.icc.qasker.global.entity.CreatedAt; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -78,4 +80,16 @@ public void changeStatus(BoardStatus status) { public void incrementViewCount() { this.viewCount += 1; } + + public void ensureModifiableBy(String userId) { + if (category == BoardCategory.UPDATE_LOG) { + throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); + } + if (status == BoardStatus.ANSWERED) { + throw new CustomException(ExceptionMessage.ALREADY_ANSWERED); + } + if (!userId.equals(this.userId)) { + throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); + } + } } diff --git a/modules/board/impl/src/main/java/com/icc/qasker/board/mapper/PostResponseMapper.java b/modules/board/impl/src/main/java/com/icc/qasker/board/mapper/PostResponseMapper.java index 317bd97e..29060119 100644 --- a/modules/board/impl/src/main/java/com/icc/qasker/board/mapper/PostResponseMapper.java +++ b/modules/board/impl/src/main/java/com/icc/qasker/board/mapper/PostResponseMapper.java @@ -1,8 +1,11 @@ package com.icc.qasker.board.mapper; import com.icc.qasker.board.dto.BoardCategory; +import com.icc.qasker.board.dto.response.PostDetailResponse; import com.icc.qasker.board.dto.response.PostResponse; import com.icc.qasker.board.entity.Board; +import com.icc.qasker.board.entity.Reply; +import java.util.List; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -11,7 +14,7 @@ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class PostResponseMapper { - private static final String UPDATE_LOG_USERNAME = "운영팀"; + static final String UPDATE_LOG_USERNAME = "운영팀"; public PostResponse fromEntity(Board board, String nickname) { boolean isUpdateLog = board.getCategory() == BoardCategory.UPDATE_LOG; @@ -24,4 +27,21 @@ public PostResponse fromEntity(Board board, String nickname) { board.getCreatedAt(), board.getCategory().name()); } + + public PostDetailResponse toDetail(Board board, String nickname, boolean isWriter) { + boolean isUpdateLog = board.getCategory() == BoardCategory.UPDATE_LOG; + List replies = + isUpdateLog ? List.of() : board.getReplies().stream().map(Reply::getContent).toList(); + return new PostDetailResponse( + board.getBoardId(), + isUpdateLog ? UPDATE_LOG_USERNAME : nickname, + board.getTitle(), + board.getContent(), + board.getViewCount(), + isUpdateLog ? null : board.getStatus().name(), + board.getCreatedAt(), + replies, + !isUpdateLog && isWriter, + board.getCategory().name()); + } } diff --git a/modules/board/impl/src/main/java/com/icc/qasker/board/service/BoardService.java b/modules/board/impl/src/main/java/com/icc/qasker/board/service/BoardService.java index a7ca0741..05baa7ac 100644 --- a/modules/board/impl/src/main/java/com/icc/qasker/board/service/BoardService.java +++ b/modules/board/impl/src/main/java/com/icc/qasker/board/service/BoardService.java @@ -8,7 +8,6 @@ import com.icc.qasker.board.dto.response.PostResponse; import com.icc.qasker.board.entity.Board; import com.icc.qasker.board.entity.BoardStatus; -import com.icc.qasker.board.entity.Reply; import com.icc.qasker.board.mapper.PostResponseMapper; import com.icc.qasker.board.repository.BoardRepository; import com.icc.qasker.global.error.CustomException; @@ -27,7 +26,7 @@ @Transactional(readOnly = true) public class BoardService { - private static final String UPDATE_LOG_USERNAME = "운영팀"; + private static final String UNKNOWN_USERNAME = "알 수 없음"; private final BoardRepository boardRepository; private final PostResponseMapper postResponseMapper; @@ -39,12 +38,7 @@ public PostPageResponse getPosts(BoardCategory category, Pageable pageable) { if (category == BoardCategory.UPDATE_LOG) { List posts = boards.map(board -> postResponseMapper.fromEntity(board, null)).getContent(); - return new PostPageResponse( - posts, - boards.getTotalElements(), - boards.getTotalPages(), - boards.getSize(), - boards.getNumber()); + return toPageResponse(boards, posts); } List userIds = boards.stream().map(Board::getUserId).distinct().toList(); @@ -53,18 +47,12 @@ public PostPageResponse getPosts(BoardCategory category, Pageable pageable) { List posts = boards .map( - board -> { - String nickname = nicknames.getOrDefault(board.getUserId(), "알 수 없음"); - return postResponseMapper.fromEntity(board, nickname); - }) + board -> + postResponseMapper.fromEntity( + board, nicknames.getOrDefault(board.getUserId(), UNKNOWN_USERNAME))) .getContent(); - return new PostPageResponse( - posts, - boards.getTotalElements(), - boards.getTotalPages(), - boards.getSize(), - boards.getNumber()); + return toPageResponse(boards, posts); } @Transactional @@ -76,41 +64,15 @@ public PostDetailResponse getPost(Long boardId, String requestUserId) { board.incrementViewCount(); - if (board.getCategory() == BoardCategory.UPDATE_LOG) { - return new PostDetailResponse( - board.getBoardId(), - UPDATE_LOG_USERNAME, - board.getTitle(), - board.getContent(), - board.getViewCount(), - null, - board.getCreatedAt(), - List.of(), - false, - board.getCategory().name()); - } - - List replies = board.getReplies().stream().map(Reply::getContent).toList(); + boolean isUpdateLog = board.getCategory() == BoardCategory.UPDATE_LOG; + String nickname = isUpdateLog ? null : userService.getUserNickname(board.getUserId()); boolean isWriter = Objects.equals(requestUserId, board.getUserId()); - - return new PostDetailResponse( - board.getBoardId(), - userService.getUserNickname(board.getUserId()), - board.getTitle(), - board.getContent(), - board.getViewCount(), - board.getStatus().name(), - board.getCreatedAt(), - replies, - isWriter, - board.getCategory().name()); + return postResponseMapper.toDetail(board, nickname, isWriter); } @Transactional public void createPost(PostRequest request, String userId) { - if (userId == null) { - throw new CustomException(ExceptionMessage.UNAUTHORIZED); - } + requireUserId(userId); userService.checkUserExists(userId); Board board = Board.builder().title(request.title()).content(request.content()).userId(userId).build(); @@ -119,49 +81,38 @@ public void createPost(PostRequest request, String userId) { @Transactional public void updatePost(Long boardId, PostRequest request, String userId) { - if (userId == null) { - throw new CustomException(ExceptionMessage.UNAUTHORIZED); - } - + requireUserId(userId); Board board = boardRepository .findById(boardId) .orElseThrow(() -> new CustomException(ExceptionMessage.POST_NOT_FOUND)); - - if (board.getCategory() == BoardCategory.UPDATE_LOG) { - throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); - } - - if (board.getStatus() == BoardStatus.ANSWERED) { - throw new CustomException(ExceptionMessage.ALREADY_ANSWERED); - } - - if (!userId.equals(board.getUserId())) { - throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); - } + board.ensureModifiableBy(userId); board.update(request.title(), request.content()); } @Transactional public void deletePost(Long boardId, String userId) { - if (userId == null) { - throw new CustomException(ExceptionMessage.UNAUTHORIZED); - } + requireUserId(userId); Board board = boardRepository .findById(boardId) .orElseThrow(() -> new CustomException(ExceptionMessage.POST_NOT_FOUND)); + board.ensureModifiableBy(userId); + board.changeStatus(BoardStatus.DELETED); + } - if (board.getCategory() == BoardCategory.UPDATE_LOG) { - throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); + private void requireUserId(String userId) { + if (userId == null) { + throw new CustomException(ExceptionMessage.UNAUTHORIZED); } + } - if (board.getStatus() == BoardStatus.ANSWERED) { - throw new CustomException(ExceptionMessage.ALREADY_ANSWERED); - } - if (!userId.equals(board.getUserId())) { - throw new CustomException(ExceptionMessage.NOT_ENOUGH_ACCESS); - } - board.changeStatus(BoardStatus.DELETED); + private PostPageResponse toPageResponse(Page boards, List posts) { + return new PostPageResponse( + posts, + boards.getTotalElements(), + boards.getTotalPages(), + boards.getSize(), + boards.getNumber()); } } diff --git a/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardAdminServiceImplTest.java b/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardAdminServiceImplTest.java new file mode 100644 index 00000000..2aa8a97c --- /dev/null +++ b/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardAdminServiceImplTest.java @@ -0,0 +1,107 @@ +package com.icc.qasker.board.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.board.dto.BoardCategory; +import com.icc.qasker.board.dto.request.PostRequest; +import com.icc.qasker.board.entity.Board; +import com.icc.qasker.board.entity.BoardStatus; +import com.icc.qasker.board.repository.BoardRepository; +import com.icc.qasker.board.repository.ReplyRepository; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BoardAdminServiceImplTest { + + @Mock BoardRepository boardRepository; + @Mock ReplyRepository replyRepository; + + @InjectMocks BoardAdminServiceImpl boardAdminService; + + private Board inquiryBoard() { + Board board = Board.builder().userId("user1").title("제목").content("내용").build(); + ReflectionTestUtils.setField(board, "boardId", 1L); + return board; + } + + private Board updateLogBoard() { + Board board = + Board.builder() + .userId("admin1") + .title("업데이트") + .content("내용") + .category(BoardCategory.UPDATE_LOG) + .build(); + ReflectionTestUtils.setField(board, "boardId", 2L); + return board; + } + + @Nested + @DisplayName("reply — 답변 등록") + class ReplyPost { + + @Test + @DisplayName("답변 등록 시 게시글 status가 ANSWERED로 변경된다") + void reply_changesStatusToAnswered() { + Board board = inquiryBoard(); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + boardAdminService.reply(1L, "admin1", "답변내용"); + + assertThat(board.getStatus()).isEqualTo(BoardStatus.ANSWERED); + } + + @Test + @DisplayName("답변 등록 시 Reply가 저장된다") + void reply_savesReply() { + Board board = inquiryBoard(); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + boardAdminService.reply(1L, "admin1", "답변내용"); + + verify(replyRepository).save(any(com.icc.qasker.board.entity.Reply.class)); + } + } + + @Nested + @DisplayName("updateUpdateLog — 업데이트 로그 수정") + class UpdateUpdateLog { + + @Test + @DisplayName("UPDATE_LOG가 아닌 게시글 → NOT_ENOUGH_ACCESS 예외") + void nonUpdateLog_notEnoughAccess() { + when(boardRepository.findById(1L)).thenReturn(Optional.of(inquiryBoard())); + + assertThatThrownBy( + () -> boardAdminService.updateUpdateLog(1L, new PostRequest("제목", "내용"), "admin1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + @Test + @DisplayName("UPDATE_LOG 게시글 — 제목·내용이 변경된다") + void success() { + Board board = updateLogBoard(); + when(boardRepository.findById(2L)).thenReturn(Optional.of(board)); + + boardAdminService.updateUpdateLog(2L, new PostRequest("새제목", "새내용"), "admin1"); + + assertThat(board.getTitle()).isEqualTo("새제목"); + assertThat(board.getContent()).isEqualTo("새내용"); + } + } +} diff --git a/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardServiceTest.java b/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardServiceTest.java new file mode 100644 index 00000000..86189b67 --- /dev/null +++ b/modules/board/impl/src/test/java/com/icc/qasker/board/service/BoardServiceTest.java @@ -0,0 +1,368 @@ +package com.icc.qasker.board.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.auth.UserService; +import com.icc.qasker.board.dto.BoardCategory; +import com.icc.qasker.board.dto.request.PostRequest; +import com.icc.qasker.board.dto.response.PostDetailResponse; +import com.icc.qasker.board.dto.response.PostPageResponse; +import com.icc.qasker.board.entity.Board; +import com.icc.qasker.board.entity.BoardStatus; +import com.icc.qasker.board.entity.Reply; +import com.icc.qasker.board.mapper.PostResponseMapper; +import com.icc.qasker.board.repository.BoardRepository; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BoardServiceTest { + + @Mock BoardRepository boardRepository; + + @Mock(answer = Answers.CALLS_REAL_METHODS) + PostResponseMapper postResponseMapper; + + @Mock UserService userService; + + @InjectMocks BoardService boardService; + + private Board inquiryBoard(String userId) { + Board board = Board.builder().userId(userId).title("제목").content("내용").build(); + ReflectionTestUtils.setField(board, "boardId", 1L); + return board; + } + + private Board updateLogBoard(String userId) { + Board board = + Board.builder() + .userId(userId) + .title("업데이트") + .content("내용") + .category(BoardCategory.UPDATE_LOG) + .build(); + ReflectionTestUtils.setField(board, "boardId", 2L); + return board; + } + + @Nested + @DisplayName("getPosts — 목록 조회") + class GetPosts { + + @Test + @DisplayName("INQUIRY: 닉네임이 올바르게 매핑된다") + void inquiry_nicknameMapping() { + Board board = inquiryBoard("user1"); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(board), pageable, 1); + + when(boardRepository.findByCategory(BoardCategory.INQUIRY, pageable)).thenReturn(page); + when(userService.getNickNames(List.of("user1"))).thenReturn(Map.of("user1", "닉네임1")); + + PostPageResponse result = boardService.getPosts(BoardCategory.INQUIRY, pageable); + + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).userName()).isEqualTo("닉네임1"); + } + + @Test + @DisplayName("INQUIRY: userId가 없으면 '알 수 없음'으로 대체된다") + void inquiry_unknownUser() { + Board board = inquiryBoard("user-ghost"); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(board), pageable, 1); + + when(boardRepository.findByCategory(BoardCategory.INQUIRY, pageable)).thenReturn(page); + when(userService.getNickNames(any())).thenReturn(Map.of()); + + PostPageResponse result = boardService.getPosts(BoardCategory.INQUIRY, pageable); + + assertThat(result.posts().get(0).userName()).isEqualTo("알 수 없음"); + } + + @Test + @DisplayName("INQUIRY: 페이징 필드가 정확하다") + void inquiry_pagingFields() { + Board board = inquiryBoard("user1"); + Pageable pageable = PageRequest.of(1, 5); + Page page = new PageImpl<>(List.of(board), pageable, 6); + + when(boardRepository.findByCategory(BoardCategory.INQUIRY, pageable)).thenReturn(page); + when(userService.getNickNames(any())).thenReturn(Map.of("user1", "닉1")); + + PostPageResponse result = boardService.getPosts(BoardCategory.INQUIRY, pageable); + + assertThat(result.totalElements()).isEqualTo(6); + assertThat(result.totalPages()).isEqualTo(2); + assertThat(result.size()).isEqualTo(5); + assertThat(result.number()).isEqualTo(1); + } + + @Test + @DisplayName("UPDATE_LOG: 작성자가 '운영팀'으로 표기된다") + void updateLog_operationsTeamUsername() { + Board board = updateLogBoard("admin1"); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(board), pageable, 1); + + when(boardRepository.findByCategory(BoardCategory.UPDATE_LOG, pageable)).thenReturn(page); + + PostPageResponse result = boardService.getPosts(BoardCategory.UPDATE_LOG, pageable); + + assertThat(result.posts().get(0).userName()).isEqualTo("운영팀"); + } + + @Test + @DisplayName("UPDATE_LOG: status가 null이다") + void updateLog_statusIsNull() { + Board board = updateLogBoard("admin1"); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(board), pageable, 1); + + when(boardRepository.findByCategory(BoardCategory.UPDATE_LOG, pageable)).thenReturn(page); + + PostPageResponse result = boardService.getPosts(BoardCategory.UPDATE_LOG, pageable); + + assertThat(result.posts().get(0).status()).isNull(); + } + } + + @Nested + @DisplayName("getPost — 상세 조회") + class GetPost { + + @Test + @DisplayName("일반 게시글: 닉네임·replies·status·category가 올바르다") + void inquiry_detail() { + Board board = inquiryBoard("user1"); + Reply reply = Reply.builder().board(board).adminId("admin1").content("답변내용").build(); + board.getReplies().add(reply); + + when(boardRepository.findByIdWithReplies(1L)).thenReturn(Optional.of(board)); + when(userService.getUserNickname("user1")).thenReturn("닉네임1"); + + PostDetailResponse result = boardService.getPost(1L, "user1"); + + assertThat(result.username()).isEqualTo("닉네임1"); + assertThat(result.replies()).containsExactly("답변내용"); + assertThat(result.status()).isEqualTo("IN_PROGRESS"); + assertThat(result.category()).isEqualTo("INQUIRY"); + } + + @Test + @DisplayName("일반 게시글: 요청자가 작성자이면 isWriter=true") + void inquiry_isWriter_true() { + Board board = inquiryBoard("user1"); + when(boardRepository.findByIdWithReplies(1L)).thenReturn(Optional.of(board)); + when(userService.getUserNickname(any())).thenReturn("닉네임"); + + PostDetailResponse result = boardService.getPost(1L, "user1"); + + assertThat(result.isWriter()).isTrue(); + } + + @Test + @DisplayName("일반 게시글: 요청자가 작성자가 아니면 isWriter=false") + void inquiry_isWriter_false() { + Board board = inquiryBoard("user1"); + when(boardRepository.findByIdWithReplies(1L)).thenReturn(Optional.of(board)); + when(userService.getUserNickname(any())).thenReturn("닉네임"); + + PostDetailResponse result = boardService.getPost(1L, "other-user"); + + assertThat(result.isWriter()).isFalse(); + } + + @Test + @DisplayName("일반 게시글: 조회 시 viewCount가 1 증가한다") + void inquiry_viewCountIncrement() { + Board board = inquiryBoard("user1"); + when(boardRepository.findByIdWithReplies(1L)).thenReturn(Optional.of(board)); + when(userService.getUserNickname(any())).thenReturn("닉네임"); + + PostDetailResponse result = boardService.getPost(1L, "user1"); + + assertThat(result.viewCount()).isEqualTo(1L); + } + + @Test + @DisplayName("UPDATE_LOG: 작성자가 '운영팀'이고 status=null, replies 빈 리스트") + void updateLog_detail() { + Board board = updateLogBoard("admin1"); + when(boardRepository.findByIdWithReplies(2L)).thenReturn(Optional.of(board)); + + PostDetailResponse result = boardService.getPost(2L, "any-user"); + + assertThat(result.username()).isEqualTo("운영팀"); + assertThat(result.status()).isNull(); + assertThat(result.replies()).isEmpty(); + assertThat(result.isWriter()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 boardId → POST_NOT_FOUND 예외") + void notFound() { + when(boardRepository.findByIdWithReplies(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> boardService.getPost(999L, "user1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.POST_NOT_FOUND.getMessage()); + } + } + + @Nested + @DisplayName("createPost — 게시글 생성") + class CreatePost { + + @Test + @DisplayName("userId=null → UNAUTHORIZED 예외") + void nullUserId_unauthorized() { + assertThatThrownBy(() -> boardService.createPost(new PostRequest("제목", "내용"), null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.UNAUTHORIZED.getMessage()); + } + + @Test + @DisplayName("정상 저장 — boardRepository.save가 호출된다") + void success() { + boardService.createPost(new PostRequest("제목", "내용"), "user1"); + + verify(boardRepository).save(any(Board.class)); + } + } + + @Nested + @DisplayName("updatePost — 게시글 수정") + class UpdatePost { + + private final PostRequest updateRequest = new PostRequest("수정제목", "수정내용"); + + @Test + @DisplayName("userId=null → UNAUTHORIZED 예외") + void nullUserId_unauthorized() { + assertThatThrownBy(() -> boardService.updatePost(1L, updateRequest, null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.UNAUTHORIZED.getMessage()); + } + + @Test + @DisplayName("UPDATE_LOG 게시글 → NOT_ENOUGH_ACCESS 예외") + void updateLog_notEnoughAccess() { + when(boardRepository.findById(2L)).thenReturn(Optional.of(updateLogBoard("admin1"))); + + assertThatThrownBy(() -> boardService.updatePost(2L, updateRequest, "admin1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + @Test + @DisplayName("ANSWERED 상태 게시글 → ALREADY_ANSWERED 예외") + void answered_alreadyAnswered() { + Board board = inquiryBoard("user1"); + board.changeStatus(BoardStatus.ANSWERED); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + assertThatThrownBy(() -> boardService.updatePost(1L, updateRequest, "user1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.ALREADY_ANSWERED.getMessage()); + } + + @Test + @DisplayName("비소유자 → NOT_ENOUGH_ACCESS 예외") + void nonOwner_notEnoughAccess() { + when(boardRepository.findById(1L)).thenReturn(Optional.of(inquiryBoard("user1"))); + + assertThatThrownBy(() -> boardService.updatePost(1L, updateRequest, "other-user")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + @Test + @DisplayName("정상 수정 — 제목·내용이 변경된다") + void success() { + Board board = inquiryBoard("user1"); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + boardService.updatePost(1L, updateRequest, "user1"); + + assertThat(board.getTitle()).isEqualTo("수정제목"); + assertThat(board.getContent()).isEqualTo("수정내용"); + } + } + + @Nested + @DisplayName("deletePost — 게시글 삭제") + class DeletePost { + + @Test + @DisplayName("userId=null → UNAUTHORIZED 예외") + void nullUserId_unauthorized() { + assertThatThrownBy(() -> boardService.deletePost(1L, null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.UNAUTHORIZED.getMessage()); + } + + @Test + @DisplayName("UPDATE_LOG 게시글 → NOT_ENOUGH_ACCESS 예외") + void updateLog_notEnoughAccess() { + when(boardRepository.findById(2L)).thenReturn(Optional.of(updateLogBoard("admin1"))); + + assertThatThrownBy(() -> boardService.deletePost(2L, "admin1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + @Test + @DisplayName("ANSWERED 상태 게시글 → ALREADY_ANSWERED 예외") + void answered_alreadyAnswered() { + Board board = inquiryBoard("user1"); + board.changeStatus(BoardStatus.ANSWERED); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + assertThatThrownBy(() -> boardService.deletePost(1L, "user1")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.ALREADY_ANSWERED.getMessage()); + } + + @Test + @DisplayName("비소유자 → NOT_ENOUGH_ACCESS 예외") + void nonOwner_notEnoughAccess() { + when(boardRepository.findById(1L)).thenReturn(Optional.of(inquiryBoard("user1"))); + + assertThatThrownBy(() -> boardService.deletePost(1L, "other-user")) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + @Test + @DisplayName("정상 삭제 — status가 DELETED로 변경된다 (소프트 삭제)") + void success_softDelete() { + Board board = inquiryBoard("user1"); + when(boardRepository.findById(1L)).thenReturn(Optional.of(board)); + + boardService.deletePost(1L, "user1"); + + assertThat(board.getStatus()).isEqualTo(BoardStatus.DELETED); + } + } +} From 45e71904b3f028c4c40adb6ea57a52d7d5a5986d Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:25:59 +0900 Subject: [PATCH 04/10] =?UTF-8?q?ICC-384=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20auth=20=EB=AA=A8=EB=93=88=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtProvider: JWT 생성/검증 추출 - PrincipalExtractor: 인증 주체 추출 로직 분리 - SecurityErrorResponder: 보안 에러 응답 일원화 - RateLimitFilter 분해: RateLimitBucketRegistry / RateLimitMetrics / RateLimitResponseWriter - TokenCrypto: 토큰 암호화 유틸 추출 - 회귀 테스트: ClientKeyResolver / JwtTokenAuthenticationFilter / RateLimitFilter / RefreshTokenCrypto / UserIdArgumentResolver Co-Authored-By: Claude Sonnet 4.6 --- modules/auth/impl/build.gradle | 7 + .../auth/component/AccessTokenHandler.java | 19 +- .../auth/component/ClientKeyResolver.java | 15 +- .../qasker/auth/component/JwtProvider.java | 45 ++++ .../auth/component/PrincipalExtractor.java | 32 +++ .../qasker/auth/config/SecurityConfig.java | 39 +--- .../security/SecurityErrorResponder.java | 35 +++ .../filter/JwtTokenAuthenticationFilter.java | 62 +++-- .../filter/RateLimitBucketRegistry.java | 80 +++++++ .../security/filter/RateLimitFilter.java | 113 +--------- .../security/filter/RateLimitMetrics.java | 21 ++ .../filter/RateLimitResponseWriter.java | 48 ++++ .../auth/controller/AuthController.java | 1 - .../qasker/auth/util/RefreshTokenUtil.java | 42 +--- .../com/icc/qasker/auth/util/TokenCrypto.java | 34 +++ .../auth/util/UserIdArgumentResolver.java | 33 +-- .../auth/component/ClientKeyResolverTest.java | 74 ++++++ .../JwtTokenAuthenticationFilterTest.java | 197 ++++++++++++++++ .../auth/filter/RateLimitFilterTest.java | 212 ++++++++++++++++++ .../auth/util/RefreshTokenCryptoTest.java | 69 ++++++ .../auth/util/UserIdArgumentResolverTest.java | 131 +++++++++++ 21 files changed, 1052 insertions(+), 257 deletions(-) create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/component/JwtProvider.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/component/PrincipalExtractor.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/SecurityErrorResponder.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitBucketRegistry.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitMetrics.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitResponseWriter.java create mode 100644 modules/auth/impl/src/main/java/com/icc/qasker/auth/util/TokenCrypto.java create mode 100644 modules/auth/impl/src/test/java/com/icc/qasker/auth/component/ClientKeyResolverTest.java create mode 100644 modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/JwtTokenAuthenticationFilterTest.java create mode 100644 modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/RateLimitFilterTest.java create mode 100644 modules/auth/impl/src/test/java/com/icc/qasker/auth/util/RefreshTokenCryptoTest.java create mode 100644 modules/auth/impl/src/test/java/com/icc/qasker/auth/util/UserIdArgumentResolverTest.java diff --git a/modules/auth/impl/build.gradle b/modules/auth/impl/build.gradle index 62ea7fc5..e72f5c48 100644 --- a/modules/auth/impl/build.gradle +++ b/modules/auth/impl/build.gradle @@ -62,4 +62,11 @@ dependencies { // ──────────────────────────────── // SpringDoc OpenAPI: API 문서화 어노테이션(@Operation, @Schema) + Swagger UI 제공 implementation libs.springdoc.openapi.starter.webmvc.ui + + // ──────────────────────────────── + // 테스트 + // ──────────────────────────────── + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation platform(libs.junit.bom) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java index 39ff167c..45ddfb1c 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java @@ -1,12 +1,8 @@ package com.icc.qasker.auth.component; -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; import com.icc.qasker.auth.repository.UserRepository; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.properties.JwtProperties; -import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,24 +10,13 @@ @RequiredArgsConstructor public class AccessTokenHandler { - private final JwtProperties jwtProperties; + private final JwtProvider jwtProvider; private final UserRepository userRepository; public String validateAndGenerate(String userId) { return userRepository .findById(userId) - .map( - user -> - JWT.create() - .withSubject(user.getUserId()) - .withClaim("userId", user.getUserId()) - .withClaim("nickname", user.getNickname()) - .withClaim("role", user.getRole()) - .withExpiresAt( - new Date( - System.currentTimeMillis() - + jwtProperties.getAccessExpirationSecond() * 1000)) - .sign(Algorithm.HMAC512(jwtProperties.getSecret()))) + .map(jwtProvider::sign) .orElseThrow(() -> new CustomException(ExceptionMessage.USER_NOT_FOUND)); } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/ClientKeyResolver.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/ClientKeyResolver.java index 66428898..ff922f8f 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/ClientKeyResolver.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/ClientKeyResolver.java @@ -1,21 +1,22 @@ package com.icc.qasker.auth.component; -import com.icc.qasker.auth.entity.User; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.core.Authentication; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; /** 요청에서 Rate Limit 버킷 키를 추출한다. 인증 사용자: "user:{userId}", 비인증: "ip:{ip}" */ @Component +@RequiredArgsConstructor public class ClientKeyResolver { + private final PrincipalExtractor principalExtractor; + public String resolve(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) { - return "user:" + user.getUserId(); - } - return "ip:" + extractIp(request); + return principalExtractor + .extractUserId(SecurityContextHolder.getContext().getAuthentication()) + .map(userId -> "user:" + userId) + .orElseGet(() -> "ip:" + extractIp(request)); } private String extractIp(HttpServletRequest request) { diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/JwtProvider.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/JwtProvider.java new file mode 100644 index 00000000..a55a1d6f --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/JwtProvider.java @@ -0,0 +1,45 @@ +package com.icc.qasker.auth.component; + +import static com.auth0.jwt.JWT.require; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.global.properties.JwtProperties; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** JWT 서명·검증의 단일 진입점. 알고리즘·시크릿 변경은 이 클래스만 수정하면 된다. */ +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + + public String sign(User user) { + return JWT.create() + .withSubject(user.getUserId()) + .withClaim("userId", user.getUserId()) + .withClaim("nickname", user.getNickname()) + .withClaim("role", user.getRole()) + .withExpiresAt( + new Date(System.currentTimeMillis() + jwtProperties.getAccessExpirationSecond() * 1000)) + .sign(Algorithm.HMAC512(jwtProperties.getSecret())); + } + + /** + * 토큰을 검증하고 userId claim을 반환한다. 만료·위조·손상 등 모든 검증 실패는 null로 흡수한다(익명 통과 정책). + * + * @return userId, 또는 검증 실패/claim 없음 시 null + */ + public String verifyAndExtractUserId(String token) { + try { + var decoded = require(Algorithm.HMAC512(jwtProperties.getSecret())).build().verify(token); + return decoded.getClaim("userId").asString(); + } catch (JWTVerificationException ex) { + return null; + } + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/PrincipalExtractor.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/PrincipalExtractor.java new file mode 100644 index 00000000..6343109a --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/PrincipalExtractor.java @@ -0,0 +1,32 @@ +package com.icc.qasker.auth.component; + +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.principal.UserPrincipal; +import java.util.Optional; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +/** SecurityContext에서 userId를 추출하는 단일 진입점. ClientKeyResolver와 UserIdArgumentResolver가 공유한다. */ +@Component +public class PrincipalExtractor { + + public Optional extractUserId(Authentication authentication) { + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return Optional.empty(); + } + Object principal = authentication.getPrincipal(); + if (principal instanceof User user) { + return Optional.of(user.getUserId()); + } + if (principal instanceof UserPrincipal userPrincipal) { + return Optional.of(userPrincipal.getUser().getUserId()); + } + if (principal instanceof String s && !s.isBlank() && !"anonymousUser".equalsIgnoreCase(s)) { + return Optional.of(s); + } + return Optional.empty(); + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java index 61020179..f080740e 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java @@ -2,15 +2,13 @@ import static org.springframework.security.config.Customizer.withDefaults; +import com.icc.qasker.auth.component.JwtProvider; +import com.icc.qasker.auth.config.security.SecurityErrorResponder; import com.icc.qasker.auth.config.security.filter.JwtTokenAuthenticationFilter; import com.icc.qasker.auth.config.security.filter.RateLimitFilter; import com.icc.qasker.auth.config.security.handler.OAuth2LoginSuccessHandler; import com.icc.qasker.auth.config.security.service.PrincipalOAuth2UserService; import com.icc.qasker.auth.repository.UserRepository; -import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.properties.JwtProperties; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,6 +31,8 @@ public class SecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final PrincipalOAuth2UserService principalOauth2UserService; private final RateLimitFilter rateLimitFilter; + private final JwtProvider jwtProvider; + private final SecurityErrorResponder securityErrorResponder; @Bean public AuthenticationManager authenticationManager( @@ -42,8 +42,7 @@ public AuthenticationManager authenticationManager( @Bean public SecurityFilterChain apiFilterChain( - HttpSecurity http, AuthenticationManager authenticationManager, JwtProperties jwtProperties) - throws Exception { + HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { http.cors(withDefaults()) .csrf(AbstractHttpConfigurer::disable) .sessionManagement( @@ -53,15 +52,13 @@ public SecurityFilterChain apiFilterChain( .exceptionHandling( ex -> ex.authenticationEntryPoint( - (request, response, authException) -> { - createUnauthorizedResponse(response); - }) + (request, response, authException) -> + securityErrorResponder.writeUnauthorized(response)) .accessDeniedHandler( - (request, response, accessDeniedException) -> { - createForbiddenResponse(response); - })) + (request, response, accessDeniedException) -> + securityErrorResponder.writeForbidden(response))) .addFilterBefore( - new JwtTokenAuthenticationFilter(authenticationManager, userRepository, jwtProperties), + new JwtTokenAuthenticationFilter(authenticationManager, userRepository, jwtProvider), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(rateLimitFilter, JwtTokenAuthenticationFilter.class) .authorizeHttpRequests( @@ -89,20 +86,4 @@ public SecurityFilterChain apiFilterChain( .successHandler(oAuth2LoginSuccessHandler)); return http.build(); } - - public void createUnauthorizedResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - response - .getWriter() - .write("{\"message\": \"" + ExceptionMessage.UNAUTHORIZED.getMessage() + "\"}"); - } - - public void createForbiddenResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - response - .getWriter() - .write("{\"message\": \"" + ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage() + "\"}"); - } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/SecurityErrorResponder.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/SecurityErrorResponder.java new file mode 100644 index 00000000..71c9e136 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/SecurityErrorResponder.java @@ -0,0 +1,35 @@ +package com.icc.qasker.auth.config.security; + +import com.icc.qasker.global.error.CustomErrorResponse; +import com.icc.qasker.global.error.ExceptionMessage; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +/** 401/403 에러 응답을 CustomErrorResponse 포맷으로 통일하여 RateLimitFilter(429)와 스키마를 맞춘다. */ +@Component +@RequiredArgsConstructor +public class SecurityErrorResponder { + + private final ObjectMapper objectMapper; + + public void writeUnauthorized(HttpServletResponse response) throws IOException { + write( + response, HttpServletResponse.SC_UNAUTHORIZED, ExceptionMessage.UNAUTHORIZED.getMessage()); + } + + public void writeForbidden(HttpServletResponse response) throws IOException { + write( + response, + HttpServletResponse.SC_FORBIDDEN, + ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); + } + + private void write(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), new CustomErrorResponse(message)); + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java index e6237b5c..1e5ac903 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java @@ -1,12 +1,8 @@ package com.icc.qasker.auth.config.security.filter; -import static com.auth0.jwt.JWT.require; - -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.TokenExpiredException; +import com.icc.qasker.auth.component.JwtProvider; import com.icc.qasker.auth.entity.User; import com.icc.qasker.auth.repository.UserRepository; -import com.icc.qasker.global.properties.JwtProperties; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -25,15 +21,13 @@ public class JwtTokenAuthenticationFilter extends BasicAuthenticationFilter { private static final String BEARER_PREFIX = "Bearer "; - private final JwtProperties jwtProperties; + private final JwtProvider jwtProvider; private final UserRepository userRepository; public JwtTokenAuthenticationFilter( - AuthenticationManager authManager, - UserRepository userRepository, - JwtProperties jwtProperties) { + AuthenticationManager authManager, UserRepository userRepository, JwtProvider jwtProvider) { super(authManager); - this.jwtProperties = jwtProperties; + this.jwtProvider = jwtProvider; this.userRepository = userRepository; } @@ -48,37 +42,31 @@ protected void doFilterInternal( return; } String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); - try { - var decoded = - require(Algorithm.HMAC512(jwtProperties.getSecret())).build().verify(accessToken); - - String userId = decoded.getClaim("userId").asString(); - if (userId == null || userId.isBlank()) { - chain.doFilter(request, response); - return; - } - - Authentication existing = SecurityContextHolder.getContext().getAuthentication(); - if (existing != null && existing.isAuthenticated()) { - chain.doFilter(request, response); - return; - } - User user = userRepository.findById(userId).orElse(null); - if (user == null) { - chain.doFilter(request, response); - return; - } - String role = Objects.toString(user.getRole(), "ROLE_USER"); - var authorities = List.of(new SimpleGrantedAuthority(role)); + String userId = jwtProvider.verifyAndExtractUserId(accessToken); + if (userId == null || userId.isBlank()) { + chain.doFilter(request, response); + return; + } - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(user, null, authorities); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); + Authentication existing = SecurityContextHolder.getContext().getAuthentication(); + if (existing != null && existing.isAuthenticated()) { chain.doFilter(request, response); - } catch (TokenExpiredException ex) { + return; + } + + User user = userRepository.findById(userId).orElse(null); + if (user == null) { chain.doFilter(request, response); + return; } + + String role = Objects.toString(user.getRole(), "ROLE_USER"); + var authorities = List.of(new SimpleGrantedAuthority(role)); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + chain.doFilter(request, response); } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitBucketRegistry.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitBucketRegistry.java new file mode 100644 index 00000000..b4f9b7f2 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitBucketRegistry.java @@ -0,0 +1,80 @@ +package com.icc.qasker.auth.config.security.filter; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.icc.qasker.global.properties.RateLimitProperties; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** 버킷 생성·캐싱·Gauge 등록을 담당한다. Gauge WeakReference 문제를 피하기 위해 글로벌 버킷은 강참조 Map으로 따로 보관한다. */ +@Component +@RequiredArgsConstructor +public class RateLimitBucketRegistry { + + private final RateLimitProperties rateLimitProperties; + private final MeterRegistry meterRegistry; + + private Cache bucketCache; + + // 글로벌 버킷은 만료 없이 영구 보관 — Micrometer Gauge의 WeakReference가 끊기지 않도록 강참조 유지 + private final Map globalBucketStore = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + bucketCache = + Caffeine.newBuilder() + .expireAfterAccess(rateLimitProperties.getBucketExpireMinutes(), TimeUnit.MINUTES) + .maximumSize(rateLimitProperties.getMaxBucketSize()) + .build(); + } + + public Bucket getOrCreate(String bucketKey, RateLimitTier tier, boolean isGlobal) { + if (isGlobal) { + // 글로벌 버킷은 Caffeine 캐시 만료 대상에서 제외 — Gauge WeakReference가 끊기지 않도록 강참조 유지 + return globalBucketStore.computeIfAbsent( + bucketKey, + k -> { + Bucket b = createBucket(tier); + meterRegistry.gauge( + "rate_limit_global_remaining_tokens", + Tags.of("tier", tier.name()), + b, + bkt -> (double) bkt.getAvailableTokens()); + return b; + }); + } + return bucketCache.get(bucketKey, k -> createBucket(tier)); + } + + public long getCapacity(RateLimitTier tier) { + return getTierConfig(tier).capacity(); + } + + private Bucket createBucket(RateLimitTier tier) { + Bandwidth bandwidth = + Bandwidth.builder() + .capacity(getCapacity(tier)) + .refillGreedy(getTierConfig(tier).refillPerMinute(), Duration.ofMinutes(1)) + .build(); + return Bucket.builder().addLimit(bandwidth).build(); + } + + private RateLimitProperties.TierConfig getTierConfig(RateLimitTier tier) { + RateLimitProperties.TierConfig config = rateLimitProperties.getTiers().get(tier.name()); + if (config == null) { + throw new IllegalStateException( + "YAML에 Rate Limit Tier 설정이 없습니다: q-asker.rate-limit.tiers." + tier.name()); + } + return config; + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitFilter.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitFilter.java index 1941bb9d..aecd61ab 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitFilter.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitFilter.java @@ -1,67 +1,34 @@ package com.icc.qasker.auth.config.security.filter; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import com.icc.qasker.auth.component.ClientKeyResolver; -import com.icc.qasker.global.error.CustomErrorResponse; -import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.global.properties.RateLimitProperties; import com.icc.qasker.global.ratelimit.RateLimitPlanResolver; import com.icc.qasker.global.ratelimit.RateLimitTier; -import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.ConsumptionProbe; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tags; -import jakarta.annotation.PostConstruct; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import tools.jackson.databind.ObjectMapper; /** Bucket4j Token Bucket 기반 Rate Limit 필터. JWT 인증 필터 이후에 실행되어 인증 정보를 활용한다. */ @Component @RequiredArgsConstructor public class RateLimitFilter extends OncePerRequestFilter { - private static final String HEADER_LIMIT = "X-RateLimit-Limit"; - private static final String HEADER_REMAINING = "X-RateLimit-Remaining"; - private static final String HEADER_RESET = "X-RateLimit-Reset"; - private static final String HEADER_RETRY_AFTER = "Retry-After"; - private final ClientKeyResolver keyResolver; private final RateLimitPlanResolver planResolver; private final RateLimitProperties rateLimitProperties; - private final ObjectMapper objectMapper; - private final MeterRegistry meterRegistry; - - private Cache bucketCache; - - // 글로벌 버킷은 만료 없이 영구 보관 — Micrometer Gauge의 WeakReference가 끊기지 않도록 강참조 유지 - private final Map globalBucketStore = new ConcurrentHashMap<>(); - - @PostConstruct - public void initCache() { - bucketCache = - Caffeine.newBuilder() - .expireAfterAccess(rateLimitProperties.getBucketExpireMinutes(), TimeUnit.MINUTES) - .maximumSize(rateLimitProperties.getMaxBucketSize()) - .build(); - } + private final RateLimitBucketRegistry bucketRegistry; + private final RateLimitResponseWriter responseWriter; + private final RateLimitMetrics metrics; @Override protected boolean shouldNotFilter(HttpServletRequest request) { - // actuator 엔드포인트는 Rate Limit 제외 return request.getRequestURI().startsWith("/actuator"); } @@ -85,81 +52,17 @@ protected void doFilterInternal( String clientKey = isGlobal ? "global" : keyResolver.resolve(request); String bucketKey = clientKey + ":" + tier.name(); - Bucket bucket; - if (isGlobal) { - // 글로벌 버킷은 Caffeine 캐시 만료 대상에서 제외 — Gauge WeakReference가 끊기지 않도록 강참조 유지 - bucket = - globalBucketStore.computeIfAbsent( - bucketKey, - k -> { - Bucket b = createBucket(tier); - meterRegistry.gauge( - "rate_limit_global_remaining_tokens", - Tags.of("tier", tier.name()), - b, - bkt -> (double) bkt.getAvailableTokens()); - return b; - }); - } else { - bucket = bucketCache.get(bucketKey, k -> createBucket(tier)); - } - + Bucket bucket = bucketRegistry.getOrCreate(bucketKey, tier, isGlobal); ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); String scope = isGlobal ? "global" : "client"; if (probe.isConsumed()) { - meterRegistry - .counter("rate_limit_consumed_total", "tier", tier.name(), "scope", scope) - .increment(); - addRateLimitHeaders(response, tier, probe); + metrics.incrementConsumed(tier.name(), scope); + responseWriter.addRateLimitHeaders(response, tier, probe); chain.doFilter(request, response); } else { - meterRegistry - .counter("rate_limit_rejected_total", "tier", tier.name(), "scope", scope) - .increment(); - long retryAfter = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()) + 1; - response.setStatus(429); - response.setContentType("application/json;charset=UTF-8"); - response.setHeader(HEADER_RETRY_AFTER, String.valueOf(retryAfter)); - addRateLimitHeaders(response, tier, probe); - objectMapper.writeValue( - response.getWriter(), - new CustomErrorResponse(ExceptionMessage.RATE_LIMIT_EXCEEDED.getMessage())); - } - } - - private void addRateLimitHeaders( - HttpServletResponse response, RateLimitTier tier, ConsumptionProbe probe) { - long resetEpochSeconds = - Instant.now().plusNanos(probe.getNanosToWaitForRefill()).getEpochSecond(); - response.setHeader(HEADER_LIMIT, String.valueOf(getCapacity(tier))); - response.setHeader(HEADER_REMAINING, String.valueOf(Math.max(0, probe.getRemainingTokens()))); - response.setHeader(HEADER_RESET, String.valueOf(resetEpochSeconds)); - } - - private Bucket createBucket(RateLimitTier tier) { - Bandwidth bandwidth = - Bandwidth.builder() - .capacity(getCapacity(tier)) - .refillGreedy(getRefillPerMinute(tier), Duration.ofMinutes(1)) - .build(); - return Bucket.builder().addLimit(bandwidth).build(); - } - - private long getCapacity(RateLimitTier tier) { - return getTierConfig(tier).capacity(); - } - - private long getRefillPerMinute(RateLimitTier tier) { - return getTierConfig(tier).refillPerMinute(); - } - - private RateLimitProperties.TierConfig getTierConfig(RateLimitTier tier) { - RateLimitProperties.TierConfig config = rateLimitProperties.getTiers().get(tier.name()); - if (config == null) { - throw new IllegalStateException( - "YAML에 Rate Limit Tier 설정이 없습니다: q-asker.rate-limit.tiers." + tier.name()); + metrics.incrementRejected(tier.name(), scope); + responseWriter.writeTooManyRequests(response, tier, probe); } - return config; } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitMetrics.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitMetrics.java new file mode 100644 index 00000000..661b7f44 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitMetrics.java @@ -0,0 +1,21 @@ +package com.icc.qasker.auth.config.security.filter; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** Rate Limit consumed/rejected 카운터를 담당한다. */ +@Component +@RequiredArgsConstructor +public class RateLimitMetrics { + + private final MeterRegistry meterRegistry; + + public void incrementConsumed(String tier, String scope) { + meterRegistry.counter("rate_limit_consumed_total", "tier", tier, "scope", scope).increment(); + } + + public void incrementRejected(String tier, String scope) { + meterRegistry.counter("rate_limit_rejected_total", "tier", tier, "scope", scope).increment(); + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitResponseWriter.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitResponseWriter.java new file mode 100644 index 00000000..f684149d --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/RateLimitResponseWriter.java @@ -0,0 +1,48 @@ +package com.icc.qasker.auth.config.security.filter; + +import com.icc.qasker.global.error.CustomErrorResponse; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import io.github.bucket4j.ConsumptionProbe; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +/** Rate Limit 관련 응답 헤더 및 429 본문 직렬화를 담당한다. */ +@Component +@RequiredArgsConstructor +public class RateLimitResponseWriter { + + private static final String HEADER_LIMIT = "X-RateLimit-Limit"; + private static final String HEADER_REMAINING = "X-RateLimit-Remaining"; + private static final String HEADER_RESET = "X-RateLimit-Reset"; + private static final String HEADER_RETRY_AFTER = "Retry-After"; + + private final ObjectMapper objectMapper; + private final RateLimitBucketRegistry bucketRegistry; + + public void addRateLimitHeaders( + HttpServletResponse response, RateLimitTier tier, ConsumptionProbe probe) { + long resetEpochSeconds = + Instant.now().plusNanos(probe.getNanosToWaitForRefill()).getEpochSecond(); + response.setHeader(HEADER_LIMIT, String.valueOf(bucketRegistry.getCapacity(tier))); + response.setHeader(HEADER_REMAINING, String.valueOf(Math.max(0, probe.getRemainingTokens()))); + response.setHeader(HEADER_RESET, String.valueOf(resetEpochSeconds)); + } + + public void writeTooManyRequests( + HttpServletResponse response, RateLimitTier tier, ConsumptionProbe probe) throws IOException { + long retryAfter = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()) + 1; + response.setStatus(429); + response.setContentType("application/json;charset=UTF-8"); + response.setHeader(HEADER_RETRY_AFTER, String.valueOf(retryAfter)); + addRateLimitHeaders(response, tier, probe); + objectMapper.writeValue( + response.getWriter(), + new CustomErrorResponse(ExceptionMessage.RATE_LIMIT_EXCEEDED.getMessage())); + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java index a8d0a182..0c39c5fa 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java @@ -32,7 +32,6 @@ public class AuthController { @RateLimit(RateLimitTier.NONE) @GetMapping("/test") public ResponseEntity test() { - System.out.println("test 성공"); return ResponseEntity.ok().build(); } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java index 8c13f1a2..f53f2cd8 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java @@ -6,11 +6,7 @@ import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.global.properties.JwtProperties; import jakarta.transaction.Transactional; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; import java.time.Instant; -import java.util.Base64; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -23,10 +19,9 @@ public class RefreshTokenUtil { public String issue(String userId) { try { - String rtPlain = TokenUtils.randomUrlSafe(64); - String rtHash = TokenUtils.sha256Hex(rtPlain); + String rtPlain = TokenCrypto.randomUrlSafe(64); + String rtHash = TokenCrypto.sha256Hex(rtPlain); refreshTokenRepository.save(new RefreshToken(userId, rtHash, nextExpiry())); - return rtPlain; } catch (Exception e) { throw new CustomException(ExceptionMessage.TOKEN_GENERATION_FAILED, e); @@ -35,7 +30,7 @@ public String issue(String userId) { @Transactional public RotateResult validateAndRotate(String oldRtPlain) { - String oldRtHash = TokenUtils.sha256Hex(oldRtPlain); + String oldRtHash = TokenCrypto.sha256Hex(oldRtPlain); RefreshToken refreshToken = refreshTokenRepository @@ -46,8 +41,8 @@ public RotateResult validateAndRotate(String oldRtPlain) { throw new CustomException(ExceptionMessage.UNAUTHORIZED); } - String newRtPlain = TokenUtils.randomUrlSafe(64); - String newRtHash = TokenUtils.sha256Hex(newRtPlain); + String newRtPlain = TokenCrypto.randomUrlSafe(64); + String newRtHash = TokenCrypto.sha256Hex(newRtPlain); refreshToken.rotate(newRtHash, nextExpiry()); refreshTokenRepository.save(refreshToken); @@ -56,7 +51,7 @@ public RotateResult validateAndRotate(String oldRtPlain) { @Transactional public void revoke(String presentedRtPlain) { - String rtHash = TokenUtils.sha256Hex(presentedRtPlain); + String rtHash = TokenCrypto.sha256Hex(presentedRtPlain); refreshTokenRepository.findByRtHash(rtHash).ifPresent(refreshTokenRepository::delete); } @@ -65,29 +60,4 @@ private Instant nextExpiry() { } public record RotateResult(String userId, String newRtPlain) {} - - public static class TokenUtils { - - private static final SecureRandom RAND = new SecureRandom(); - - public static String randomUrlSafe(int bytes) { - byte[] buf = new byte[bytes]; - RAND.nextBytes(buf); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - - public static String sha256Hex(String v) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] d = md.digest(v.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(d.length * 2); - for (byte b : d) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/TokenCrypto.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/TokenCrypto.java new file mode 100644 index 00000000..305f60be --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/TokenCrypto.java @@ -0,0 +1,34 @@ +package com.icc.qasker.auth.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** Refresh Token에 사용하는 암호화 유틸리티. SHA-256 hex 해시와 URL-safe Base64 난수 생성을 담당한다. */ +public final class TokenCrypto { + + private static final SecureRandom RAND = new SecureRandom(); + + private TokenCrypto() {} + + public static String randomUrlSafe(int bytes) { + byte[] buf = new byte[bytes]; + RAND.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + public static String sha256Hex(String v) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(v.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(d.length * 2); + for (byte b : d) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java index fa05eead..caa32742 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java @@ -1,12 +1,10 @@ package com.icc.qasker.auth.util; -import com.icc.qasker.auth.entity.User; -import com.icc.qasker.auth.principal.UserPrincipal; +import com.icc.qasker.auth.component.PrincipalExtractor; import com.icc.qasker.global.annotation.UserId; +import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -15,8 +13,11 @@ import org.springframework.web.method.support.ModelAndViewContainer; @Component +@RequiredArgsConstructor public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + private final PrincipalExtractor principalExtractor; + @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(UserId.class) @@ -29,26 +30,8 @@ public boolean supportsParameter(MethodParameter parameter) { @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null - || !authentication.isAuthenticated() - || authentication instanceof AnonymousAuthenticationToken) { - return null; - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof User user) { - return user.getUserId(); - } - if (principal instanceof UserPrincipal userPrincipal) { - return userPrincipal.getUser().getUserId(); - } - if (principal instanceof String userId - && !userId.isBlank() - && !"anonymousUser".equalsIgnoreCase(userId)) { - return userId; - } - return null; + return principalExtractor + .extractUserId(SecurityContextHolder.getContext().getAuthentication()) + .orElse(null); } } diff --git a/modules/auth/impl/src/test/java/com/icc/qasker/auth/component/ClientKeyResolverTest.java b/modules/auth/impl/src/test/java/com/icc/qasker/auth/component/ClientKeyResolverTest.java new file mode 100644 index 00000000..3ee9aac7 --- /dev/null +++ b/modules/auth/impl/src/test/java/com/icc/qasker/auth/component/ClientKeyResolverTest.java @@ -0,0 +1,74 @@ +package com.icc.qasker.auth.component; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.icc.qasker.auth.entity.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * ClientKeyResolver 회귀 테스트. + * + *

PrincipalExtractor 통합 리팩터링의 안전망. 인증/비인증 시 키 포맷이 리팩터링 전후로 동일함을 보장한다. + */ +class ClientKeyResolverTest { + + private ClientKeyResolver resolver; + + @BeforeEach + void setUp() { + resolver = new ClientKeyResolver(new PrincipalExtractor()); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("인증된 사용자") + class Authenticated { + + @Test + @DisplayName("User principal이면 'user:{userId}' 형식을 반환한다") + void returnsUserKey() { + User user = User.builder().userId("user-abc").role("ROLE_USER").build(); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + user, null, AuthorityUtils.createAuthorityList("ROLE_USER"))); + + assertThat(resolver.resolve(new MockHttpServletRequest())).isEqualTo("user:user-abc"); + } + } + + @Nested + @DisplayName("비인증 사용자") + class Unauthenticated { + + @Test + @DisplayName("인증 없으면 'ip:{remoteAddr}' 형식을 반환한다") + void returnsIpKey() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRemoteAddr("192.168.1.1"); + + assertThat(resolver.resolve(req)).isEqualTo("ip:192.168.1.1"); + } + + @Test + @DisplayName("X-Forwarded-For 헤더가 있으면 첫 번째 IP를 사용한다") + void usesFirstForwardedIp() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.addHeader("X-Forwarded-For", "10.0.0.1, 172.16.0.1"); + + assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1"); + } + } +} diff --git a/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/JwtTokenAuthenticationFilterTest.java b/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/JwtTokenAuthenticationFilterTest.java new file mode 100644 index 00000000..28b75986 --- /dev/null +++ b/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/JwtTokenAuthenticationFilterTest.java @@ -0,0 +1,197 @@ +package com.icc.qasker.auth.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.icc.qasker.auth.component.JwtProvider; +import com.icc.qasker.auth.config.security.filter.JwtTokenAuthenticationFilter; +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.repository.UserRepository; +import com.icc.qasker.global.properties.JwtProperties; +import java.util.Date; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * JwtTokenAuthenticationFilter 회귀 테스트. + * + *

JwtProvider 추출 리팩터링의 안전망. 유효/만료/위조/손상 토큰 각각의 동작이 리팩터링 전후로 동일함을 보장한다. + */ +class JwtTokenAuthenticationFilterTest { + + private static final String SECRET = + "test-secret-key-that-is-long-enough-for-hmac512-algorithm-32bytes-minimum"; + private static final String USER_ID = "user-abc"; + + private JwtProvider jwtProvider; + private UserRepository userRepository; + private JwtTokenAuthenticationFilter filter; + + @BeforeEach + void setUp() { + JwtProperties jwtProperties = new JwtProperties(SECRET, 3600L, 86400L); + jwtProvider = new JwtProvider(jwtProperties); + userRepository = mock(UserRepository.class); + filter = + new JwtTokenAuthenticationFilter( + mock(AuthenticationManager.class), userRepository, jwtProvider); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private String validToken() { + return JWT.create() + .withClaim("userId", USER_ID) + .withExpiresAt(new Date(System.currentTimeMillis() + 60_000)) + .sign(Algorithm.HMAC512(SECRET)); + } + + private String expiredToken() { + return JWT.create() + .withClaim("userId", USER_ID) + .withExpiresAt(new Date(System.currentTimeMillis() - 1_000)) + .sign(Algorithm.HMAC512(SECRET)); + } + + private String forgedToken() { + return JWT.create() + .withClaim("userId", USER_ID) + .withExpiresAt(new Date(System.currentTimeMillis() + 60_000)) + .sign(Algorithm.HMAC512("different-secret-that-is-long-enough-for-hmac512-algorithm-here")); + } + + private MockHttpServletRequest requestWithBearer(String token) { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.addHeader("Authorization", "Bearer " + token); + return req; + } + + @Nested + @DisplayName("유효 토큰") + class ValidToken { + + @Test + @DisplayName("SecurityContext에 User principal과 role이 세팅된다") + void setsAuthentication() throws Exception { + User user = User.builder().userId(USER_ID).role("ROLE_USER").build(); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + + filter.doFilter( + requestWithBearer(validToken()), new MockHttpServletResponse(), new MockFilterChain()); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth).isNotNull(); + assertThat(auth.isAuthenticated()).isTrue(); + assertThat(((User) auth.getPrincipal()).getUserId()).isEqualTo(USER_ID); + assertThat(auth.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_USER"); + } + + @Test + @DisplayName("DB에 존재하지 않는 userId면 익명으로 통과한다") + void userNotFoundPassesAnonymously() throws Exception { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + filter.doFilter( + requestWithBearer(validToken()), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } + + @Nested + @DisplayName("만료 토큰") + class ExpiredToken { + + @Test + @DisplayName("익명으로 통과한다 (SecurityContext 비어 있음)") + void passesAnonymously() throws Exception { + filter.doFilter( + requestWithBearer(expiredToken()), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } + + @Nested + @DisplayName("위조/손상 토큰") + class ForgedToken { + + @Test + @DisplayName("위조 서명 토큰은 500이 아니라 익명으로 통과한다 (보안 회귀 고정)") + void forgedSignaturePassesAnonymously() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(requestWithBearer(forgedToken()), response, new MockFilterChain()); + + assertThat(response.getStatus()).isNotEqualTo(500); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("손상된 토큰(형식 오류)은 익명으로 통과한다") + void malformedTokenPassesAnonymously() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(requestWithBearer("not.a.jwt"), response, new MockFilterChain()); + + assertThat(response.getStatus()).isNotEqualTo(500); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } + + @Nested + @DisplayName("Authorization 헤더 없음 / 비정상") + class NoAuthHeader { + + @Test + @DisplayName("헤더가 없으면 익명으로 통과한다") + void noHeader() throws Exception { + filter.doFilter( + new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Bearer 접두사가 없으면 익명으로 통과한다") + void noBearerPrefix() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.addHeader("Authorization", validToken()); + + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("userId claim이 비어 있으면 익명으로 통과한다") + void emptyUserIdClaim() throws Exception { + String token = + JWT.create() + .withClaim("userId", "") + .withExpiresAt(new Date(System.currentTimeMillis() + 60_000)) + .sign(Algorithm.HMAC512(SECRET)); + + filter.doFilter( + requestWithBearer(token), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } +} diff --git a/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/RateLimitFilterTest.java b/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/RateLimitFilterTest.java new file mode 100644 index 00000000..2fc608c9 --- /dev/null +++ b/modules/auth/impl/src/test/java/com/icc/qasker/auth/filter/RateLimitFilterTest.java @@ -0,0 +1,212 @@ +package com.icc.qasker.auth.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.icc.qasker.auth.component.ClientKeyResolver; +import com.icc.qasker.auth.config.security.filter.RateLimitBucketRegistry; +import com.icc.qasker.auth.config.security.filter.RateLimitFilter; +import com.icc.qasker.auth.config.security.filter.RateLimitMetrics; +import com.icc.qasker.auth.config.security.filter.RateLimitResponseWriter; +import com.icc.qasker.global.properties.RateLimitProperties; +import com.icc.qasker.global.ratelimit.RateLimitPlanResolver; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import tools.jackson.databind.ObjectMapper; + +/** + * RateLimitFilter 회귀 테스트. + * + *

책임 분리 리팩터링의 안전망. 통과/차단 조건, 응답 스키마, 메트릭 카운터가 리팩터링 전후로 동일함을 보장한다. + */ +class RateLimitFilterTest { + + private RateLimitPlanResolver planResolver; + private ClientKeyResolver keyResolver; + private SimpleMeterRegistry meterRegistry; + private RateLimitFilter filter; + private RateLimitBucketRegistry bucketRegistry; + private RateLimitMetrics metrics; + + private RateLimitProperties props(boolean enabled) { + RateLimitProperties.TierConfig cfg = new RateLimitProperties.TierConfig(5, 5); + return new RateLimitProperties(enabled, 60, 10_000, Map.of("STANDARD", cfg, "READ", cfg)); + } + + @BeforeEach + void setUp() { + planResolver = mock(RateLimitPlanResolver.class); + keyResolver = mock(ClientKeyResolver.class); + meterRegistry = new SimpleMeterRegistry(); + + RateLimitProperties properties = props(true); + bucketRegistry = new RateLimitBucketRegistry(properties, meterRegistry); + bucketRegistry.init(); + + ObjectMapper objectMapper = new ObjectMapper(); + RateLimitResponseWriter responseWriter = + new RateLimitResponseWriter(objectMapper, bucketRegistry); + metrics = new RateLimitMetrics(meterRegistry); + + filter = + new RateLimitFilter( + keyResolver, planResolver, properties, bucketRegistry, responseWriter, metrics); + } + + @Nested + @DisplayName("필터 비활성화 / NONE tier") + class Bypass { + + @Test + @DisplayName("enabled=false 이면 무조건 통과한다") + void disabledPassesThrough() throws Exception { + RateLimitProperties disabledProps = props(false); + RateLimitBucketRegistry reg = new RateLimitBucketRegistry(disabledProps, meterRegistry); + reg.init(); + ObjectMapper om = new ObjectMapper(); + RateLimitFilter disabled = + new RateLimitFilter( + keyResolver, + planResolver, + disabledProps, + reg, + new RateLimitResponseWriter(om, reg), + new RateLimitMetrics(meterRegistry)); + + when(planResolver.resolve(any())).thenReturn(RateLimitTier.READ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + disabled.doFilter(new MockHttpServletRequest(), response, new MockFilterChain()); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("tier=NONE 이면 버킷 소비 없이 통과한다") + void noneTierPassesThrough() throws Exception { + when(planResolver.resolve(any())).thenReturn(RateLimitTier.NONE); + + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(new MockHttpServletRequest(), response, new MockFilterChain()); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("/actuator/** 는 rate limit이 적용되지 않는다 (capacity 초과해도 통과)") + void actuatorNotRateLimited() throws Exception { + when(planResolver.resolve(any())).thenReturn(RateLimitTier.STANDARD); + when(planResolver.resolveGlobal(any())).thenReturn(false); + when(keyResolver.resolve(any())).thenReturn("ip:127.0.0.1"); + + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/actuator/health"); + + // capacity(5) 초과해도 actuator 경로는 항상 통과해야 한다 + for (int i = 0; i < 10; i++) { + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(req, response, new MockFilterChain()); + assertThat(response.getStatus()).isEqualTo(200); + } + } + } + + @Nested + @DisplayName("버킷 소진 → 429") + class BucketExhausted { + + @BeforeEach + void stubTierAndKey() { + when(planResolver.resolve(any())).thenReturn(RateLimitTier.STANDARD); + when(planResolver.resolveGlobal(any())).thenReturn(false); + when(keyResolver.resolve(any())).thenReturn("ip:127.0.0.1"); + } + + @Test + @DisplayName("capacity(5) 소진 후 429 + Retry-After + X-RateLimit-* 헤더가 반환된다") + void returns429WithHeaders() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse response = null; + + for (int i = 0; i < 5; i++) { + response = new MockHttpServletResponse(); + filter.doFilter(req, response, new MockFilterChain()); + assertThat(response.getStatus()).isEqualTo(200); + } + + response = new MockHttpServletResponse(); + filter.doFilter(req, response, new MockFilterChain()); + + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getHeader("Retry-After")).isNotNull(); + assertThat(response.getHeader("X-RateLimit-Limit")).isEqualTo("5"); + assertThat(response.getHeader("X-RateLimit-Remaining")).isEqualTo("0"); + assertThat(response.getHeader("X-RateLimit-Reset")).isNotNull(); + } + + @Test + @DisplayName("429 응답 본문에 message 키가 포함된다") + void responseBodyHasMessageKey() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + for (int i = 0; i < 5; i++) { + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(req, response, new MockFilterChain()); + + String body = response.getContentAsString(); + assertThat(body).contains("\"message\""); + } + } + + @Nested + @DisplayName("메트릭 카운터") + class Metrics { + + @BeforeEach + void stubTierAndKey() { + when(planResolver.resolve(any())).thenReturn(RateLimitTier.STANDARD); + when(planResolver.resolveGlobal(any())).thenReturn(false); + when(keyResolver.resolve(any())).thenReturn("ip:10.0.0.1"); + } + + @Test + @DisplayName("요청 통과 시 consumed 카운터가 증가한다") + void consumedCounterIncrement() throws Exception { + filter.doFilter( + new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + + double count = + meterRegistry + .counter("rate_limit_consumed_total", "tier", "STANDARD", "scope", "client") + .count(); + assertThat(count).isEqualTo(1.0); + } + + @Test + @DisplayName("요청 차단 시 rejected 카운터가 증가한다") + void rejectedCounterIncrement() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + for (int i = 0; i < 5; i++) { + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + } + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + + double count = + meterRegistry + .counter("rate_limit_rejected_total", "tier", "STANDARD", "scope", "client") + .count(); + assertThat(count).isEqualTo(1.0); + } + } +} diff --git a/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/RefreshTokenCryptoTest.java b/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/RefreshTokenCryptoTest.java new file mode 100644 index 00000000..40104cee --- /dev/null +++ b/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/RefreshTokenCryptoTest.java @@ -0,0 +1,69 @@ +package com.icc.qasker.auth.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * TokenCrypto 회귀 테스트. + * + *

TokenUtils top-level 분리 리팩터링의 안전망. 해시 알고리즘(SHA-256 hex)·토큰 바이트 길이(64) 상수가 변하면 기존 저장 refresh + * token이 전부 무효화되므로 이 테스트가 항상 통과해야 한다. + */ +class RefreshTokenCryptoTest { + + @Nested + @DisplayName("sha256Hex") + class Sha256Hex { + + @Test + @DisplayName("알려진 입력에 대해 고정 hex 값을 반환한다") + void knownInput() { + String result = TokenCrypto.sha256Hex("hello"); + assertThat(result) + .isEqualTo("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + } + + @Test + @DisplayName("결과는 64자 소문자 hex 문자열이다") + void resultIs64LowercaseHex() { + String result = TokenCrypto.sha256Hex("any-token-value"); + assertThat(result).hasSize(64).matches("[0-9a-f]+"); + } + + @Test + @DisplayName("같은 입력은 항상 같은 출력을 낸다 (결정론적)") + void deterministic() { + String v = "some-refresh-token"; + assertThat(TokenCrypto.sha256Hex(v)).isEqualTo(TokenCrypto.sha256Hex(v)); + } + } + + @Nested + @DisplayName("randomUrlSafe") + class RandomUrlSafe { + + @Test + @DisplayName("bytes=64 호출 시 Base64url 86자를 반환한다 (패딩 없음)") + void length64BytesProduces86Chars() { + // Base64url without padding: ceil(64 * 8 / 6) = 86 + String token = TokenCrypto.randomUrlSafe(64); + assertThat(token).hasSize(86); + } + + @Test + @DisplayName("URL-safe Base64 문자만 포함한다 (패딩 없음)") + void urlSafeCharsOnly() { + String token = TokenCrypto.randomUrlSafe(64); + assertThat(token).matches("[A-Za-z0-9_-]+"); + } + + @Test + @DisplayName("연속 두 호출은 서로 다른 값을 반환한다") + void uniqueValues() { + assertThat(TokenCrypto.randomUrlSafe(64)).isNotEqualTo(TokenCrypto.randomUrlSafe(64)); + } + } +} diff --git a/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/UserIdArgumentResolverTest.java b/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/UserIdArgumentResolverTest.java new file mode 100644 index 00000000..195bed0d --- /dev/null +++ b/modules/auth/impl/src/test/java/com/icc/qasker/auth/util/UserIdArgumentResolverTest.java @@ -0,0 +1,131 @@ +package com.icc.qasker.auth.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.icc.qasker.auth.component.PrincipalExtractor; +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.principal.UserPrincipal; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * UserIdArgumentResolver 회귀 테스트. + * + *

PrincipalExtractor 통합 리팩터링의 안전망. User/UserPrincipal/String/anonymous 각 케이스의 반환값이 리팩터링 전후로 동일함을 + * 보장한다. + */ +class UserIdArgumentResolverTest { + + private UserIdArgumentResolver resolver; + + @BeforeEach + void setUp() { + resolver = new UserIdArgumentResolver(new PrincipalExtractor()); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private Object resolve() throws Exception { + return resolver.resolveArgument( + mock(org.springframework.core.MethodParameter.class), + null, + mock(org.springframework.web.context.request.NativeWebRequest.class), + null); + } + + @Nested + @DisplayName("User principal") + class UserPrincipalCase { + + @Test + @DisplayName("User 타입이면 userId를 반환한다") + void returnsUserId() throws Exception { + User user = User.builder().userId("user-123").role("ROLE_USER").build(); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + user, null, AuthorityUtils.createAuthorityList("ROLE_USER"))); + + assertThat(resolve()).isEqualTo("user-123"); + } + } + + @Nested + @DisplayName("UserPrincipal (OAuth2)") + class OAuth2PrincipalCase { + + @Test + @DisplayName("UserPrincipal 타입이면 내부 User의 userId를 반환한다") + void returnsUserId() throws Exception { + User user = User.builder().userId("oauth-456").role("ROLE_USER").build(); + UserPrincipal principal = new UserPrincipal(user, Map.of()); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + principal, null, AuthorityUtils.createAuthorityList("ROLE_USER"))); + + assertThat(resolve()).isEqualTo("oauth-456"); + } + } + + @Nested + @DisplayName("String principal") + class StringPrincipalCase { + + @Test + @DisplayName("비어 있지 않은 String이면 그대로 반환한다") + void returnsString() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "user-789", null, AuthorityUtils.createAuthorityList("ROLE_USER"))); + + assertThat(resolve()).isEqualTo("user-789"); + } + + @Test + @DisplayName("'anonymousUser' 문자열이면 null을 반환한다") + void anonymousUserStringReturnsNull() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "anonymousUser", null, AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + + assertThat(resolve()).isNull(); + } + } + + @Nested + @DisplayName("비인증 / anonymous") + class Unauthenticated { + + @Test + @DisplayName("SecurityContext가 비어 있으면 null을 반환한다") + void nullAuthentication() throws Exception { + assertThat(resolve()).isNull(); + } + + @Test + @DisplayName("AnonymousAuthenticationToken이면 null을 반환한다") + void anonymousTokenReturnsNull() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + + assertThat(resolve()).isNull(); + } + } +} From f51e2e0cd7ed97fcf5e3a9aca5aed1a71377adbd Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:30:31 +0900 Subject: [PATCH 05/10] =?UTF-8?q?ICC-385=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20document=20=EB=AA=A8=EB=93=88=20=EB=A9=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=20=EC=A1=B0=EB=A6=BD=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제안1(DRY): Timer 조립 4곳 중복 → conversionTimer() 헬퍼로 집약 - 제안1(DRY): 실패 catch 2곳 → 단일 catch + instanceof 분기로 통합 - 제안3(LSP): ConvertService Javadoc에 UnsupportedOperationException 계약 명시 - 회귀 테스트 보강: 성공/실패 Timer 기록·사전 등록 8개 시계열·no-op 검증 (4 → 7케이스) - ConvertServiceImpl 113줄 → 96줄 Co-Authored-By: Claude Sonnet 4.6 --- .../icc/qasker/document/ConvertService.java | 1 + .../document/service/ConvertServiceImpl.java | 63 +++++++------------ .../service/ConvertServiceImplTest.java | 44 ++++++++++++- .../service/NoOpConvertServiceTest.java | 20 ++++++ 4 files changed, 87 insertions(+), 41 deletions(-) create mode 100644 modules/document/impl/src/test/java/com/icc/qasker/document/service/NoOpConvertServiceTest.java diff --git a/modules/document/api/src/main/java/com/icc/qasker/document/ConvertService.java b/modules/document/api/src/main/java/com/icc/qasker/document/ConvertService.java index efb43842..048a1632 100644 --- a/modules/document/api/src/main/java/com/icc/qasker/document/ConvertService.java +++ b/modules/document/api/src/main/java/com/icc/qasker/document/ConvertService.java @@ -10,6 +10,7 @@ public interface ConvertService { * * @param inputFile 변환할 원본 파일의 로컬 경로 * @return 변환된 PDF 파일의 로컬 경로 + * @throws UnsupportedOperationException LibreOffice가 비활성화된 환경(CI/test)에서는 변환이 지원되지 않는다 */ Path convertToPdf(Path inputFile); } diff --git a/modules/document/impl/src/main/java/com/icc/qasker/document/service/ConvertServiceImpl.java b/modules/document/impl/src/main/java/com/icc/qasker/document/service/ConvertServiceImpl.java index 6c69666d..0e05becc 100644 --- a/modules/document/impl/src/main/java/com/icc/qasker/document/service/ConvertServiceImpl.java +++ b/modules/document/impl/src/main/java/com/icc/qasker/document/service/ConvertServiceImpl.java @@ -36,22 +36,27 @@ public class ConvertServiceImpl implements ConvertService { void eagerRegisterMetrics() { for (String ext : new String[] {"pptx", "ppt", "docx", "doc"}) { for (String result : new String[] {"success", "fail"}) { - Timer.builder("document.conversion.duration") - .publishPercentileHistogram(true) - .serviceLevelObjectives( - Duration.ofSeconds(1), - Duration.ofSeconds(3), - Duration.ofSeconds(5), - Duration.ofSeconds(10), - Duration.ofSeconds(30)) - .tag("source_type", ext) - .tag("result", result) - .description("문서 변환(PPT/DOCX → PDF) 소요 시간") - .register(registry); + conversionTimer(ext, result); } } } + /** 문서 변환 Timer를 SLO 히스토그램 설정과 함께 등록하고 반환한다 (동일 태그면 기존 인스턴스 재사용). */ + private Timer conversionTimer(String sourceType, String result) { + return Timer.builder("document.conversion.duration") + .publishPercentileHistogram(true) + .serviceLevelObjectives( + Duration.ofSeconds(1), + Duration.ofSeconds(3), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + Duration.ofSeconds(30)) + .tag("source_type", sourceType) + .tag("result", result) + .description("문서 변환(PPT/DOCX → PDF) 소요 시간") + .register(registry); + } + @Override public Path convertToPdf(Path inputFile) { String fileName = inputFile.getFileName().toString(); @@ -70,6 +75,7 @@ public Path convertToPdf(Path inputFile) { String pdfFileName = fileName.substring(0, fileName.lastIndexOf(".")) + ".pdf"; File pdfFile = new File(TEMP_DIR, pdfFileName); + String sourceType = extension.substring(1); Timer.Sample sample = Timer.start(registry); try { @@ -77,36 +83,13 @@ public Path convertToPdf(Path inputFile) { documentConverter.convert(inputFile.toFile()).to(pdfFile).execute(); log.info("PDF 변환 완료: {}", pdfFile.getAbsolutePath()); - // 문서 변환 Percentile Histogram Timer — tail latency SLO 설정용 - sample.stop( - Timer.builder("document.conversion.duration") - .publishPercentileHistogram(true) - .serviceLevelObjectives( - Duration.ofSeconds(1), - Duration.ofSeconds(3), - Duration.ofSeconds(5), - Duration.ofSeconds(10), - Duration.ofSeconds(30)) - .tag("source_type", extension.substring(1)) - .tag("result", "success") - .description("문서 변환(PPT/DOCX → PDF) 소요 시간") - .register(registry)); + sample.stop(conversionTimer(sourceType, "success")); return pdfFile.toPath(); - } catch (CustomException e) { - sample.stop( - Timer.builder("document.conversion.duration") - .tag("source_type", extension.substring(1)) - .tag("result", "fail") - .description("문서 변환(PPT/DOCX → PDF) 소요 시간") - .register(registry)); - throw e; } catch (Exception e) { - sample.stop( - Timer.builder("document.conversion.duration") - .tag("source_type", extension.substring(1)) - .tag("result", "fail") - .description("문서 변환(PPT/DOCX → PDF) 소요 시간") - .register(registry)); + sample.stop(conversionTimer(sourceType, "fail")); + if (e instanceof CustomException ce) { + throw ce; + } throw new CustomException(ExceptionMessage.CONVERT_FAILED, "PDF 변환 중 오류: " + inputFile, e); } } diff --git a/modules/document/impl/src/test/java/com/icc/qasker/document/service/ConvertServiceImplTest.java b/modules/document/impl/src/test/java/com/icc/qasker/document/service/ConvertServiceImplTest.java index e236f35a..eb9eebbb 100644 --- a/modules/document/impl/src/test/java/com/icc/qasker/document/service/ConvertServiceImplTest.java +++ b/modules/document/impl/src/test/java/com/icc/qasker/document/service/ConvertServiceImplTest.java @@ -10,6 +10,7 @@ import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; +import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.io.File; import java.net.URL; @@ -26,6 +27,8 @@ class ConvertServiceImplTest { // JODConverter의 DocumentConverter를 모킹하여 LibreOffice 없이 테스트한다 private DocumentConverter documentConverter; + // Timer 기록을 검증하기 위해 registry를 필드로 보관한다 + private SimpleMeterRegistry registry; // 테스트 대상: ConvertServiceImpl (DocumentConverter를 주입받는 구현체) private ConvertServiceImpl convertService; @@ -33,8 +36,20 @@ class ConvertServiceImplTest { void setUp() { // DocumentConverter의 가짜 객체를 생성한다 (실제 LibreOffice 프로세스를 띄우지 않음) documentConverter = mock(DocumentConverter.class); + registry = new SimpleMeterRegistry(); // 모킹된 DocumentConverter를 주입하여 테스트 대상 서비스를 생성한다 - convertService = new ConvertServiceImpl(documentConverter, new SimpleMeterRegistry()); + convertService = new ConvertServiceImpl(documentConverter, registry); + } + + // 특정 source_type/result 태그를 가진 Timer의 기록 횟수를 조회하는 헬퍼 + private long timerCount(String sourceType, String result) { + Timer timer = + registry + .find("document.conversion.duration") + .tag("source_type", sourceType) + .tag("result", result) + .timer(); + return timer == null ? 0 : timer.count(); } // 클래스패스(src/test/resources)에서 테스트 파일의 절대 경로를 가져오는 헬퍼 @@ -107,6 +122,8 @@ void convertToPdf_pptxFile_convertsToPdf() throws Exception { verify(documentConverter).convert(pptxFile.toFile()); // Then: 최종적으로 execute()가 호출되어 실제 변환이 트리거되었는지 검증한다 verify(targetJob).execute(); + // Then: source_type=pptx/result=success Timer가 1회 기록되었는지 검증한다 + assertThat(timerCount("pptx", "success")).isEqualTo(1); } // 보장: DOCX → PDF 변환이 PPTX와 동일한 경로로 동작하여 Word 문서 업로드를 지원한다 @@ -152,5 +169,30 @@ void convertToPdf_conversionFails_throwsConvertFailed() { assertThatThrownBy(() -> convertService.convertToPdf(pptxFile)) .isInstanceOf(CustomException.class) .hasMessage(ExceptionMessage.CONVERT_FAILED.getMessage()); + // Then: 실패 경로에서 source_type=pptx/result=fail Timer가 1회 기록되었는지 검증한다 + // (리팩터링이 실패 계측을 헬퍼로 통합하므로 회귀 방지 필수) + assertThat(timerCount("pptx", "fail")).isEqualTo(1); + } + + // 보장: 첫 요청 전에도 (확장자 × 결과) 시계열이 대시보드에 0값으로 노출되도록 사전 등록된다 + @Test + @DisplayName("기동 시 4확장자 × 2결과 = 8개 Timer 시계열을 사전 등록한다") + void eagerRegisterMetrics_registersAllTimeSeries() { + // When: @PostConstruct 메서드를 수동 호출한다 (단위 테스트에서는 컨테이너가 없음) + convertService.eagerRegisterMetrics(); + + // Then: 4개 확장자 × 2개 결과 = 8개 Timer가 모두 등록되어야 한다 + for (String ext : new String[] {"pptx", "ppt", "docx", "doc"}) { + for (String result : new String[] {"success", "fail"}) { + assertThat( + registry + .find("document.conversion.duration") + .tag("source_type", ext) + .tag("result", result) + .timer()) + .as("Timer source_type=%s result=%s", ext, result) + .isNotNull(); + } + } } } diff --git a/modules/document/impl/src/test/java/com/icc/qasker/document/service/NoOpConvertServiceTest.java b/modules/document/impl/src/test/java/com/icc/qasker/document/service/NoOpConvertServiceTest.java new file mode 100644 index 00000000..333915bf --- /dev/null +++ b/modules/document/impl/src/test/java/com/icc/qasker/document/service/NoOpConvertServiceTest.java @@ -0,0 +1,20 @@ +package com.icc.qasker.document.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NoOpConvertServiceTest { + + private final NoOpConvertService convertService = new NoOpConvertService(); + + // 보장: LibreOffice 비활성 환경(CI/test)에서 변환 요청이 명확한 예외로 차단된다 + @Test + @DisplayName("변환 요청 시 UnsupportedOperationException을 던진다") + void convertToPdf_throwsUnsupportedOperation() { + assertThatThrownBy(() -> convertService.convertToPdf(Path.of("sample.pptx"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} From fa3f022cad1dbfbfdc85f4d73f4c6bd13aeef392 Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:30:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?ICC-386=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20global=20=EB=AA=A8=EB=93=88=20SOLID=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HashUtil.decode() 도메인 에러 결합 제거 → 중립 INVALID_URL_REQUEST (시그니처 유지, 하위 무파급) - RateLimitPlanResolver 핸들러 조회 중복 → findAnnotation() 통합 (56→40줄) - GlobalExceptionHandler 인라인 로깅 분기 → logCustomException() 추출 - 회귀 테스트 24개 선작성 (GlobalExceptionHandler/CustomException/HashUtil/RateLimitPlanResolver) Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/global/build.gradle | 7 + modules/global/gradle.lockfile | 30 ++- .../icc/qasker/global/component/HashUtil.java | 2 +- .../global/error/GlobalExceptionHandler.java | 10 +- .../ratelimit/RateLimitPlanResolver.java | 25 +-- .../qasker/global/component/HashUtilTest.java | 32 ++++ .../global/error/CustomExceptionTest.java | 69 +++++++ .../error/GlobalExceptionHandlerTest.java | 173 ++++++++++++++++++ .../ratelimit/RateLimitPlanResolverTest.java | 78 ++++++++ modules/quiz-history/impl/gradle.lockfile | 30 ++- modules/quiz-set/impl/gradle.lockfile | 36 +++- 11 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 modules/global/src/test/java/com/icc/qasker/global/component/HashUtilTest.java create mode 100644 modules/global/src/test/java/com/icc/qasker/global/error/CustomExceptionTest.java create mode 100644 modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java create mode 100644 modules/global/src/test/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolverTest.java diff --git a/modules/global/build.gradle b/modules/global/build.gradle index 84b2238c..e3a951e7 100644 --- a/modules/global/build.gradle +++ b/modules/global/build.gradle @@ -48,4 +48,11 @@ dependencies { // Bucket4j: Token Bucket 알고리즘 기반 요청 속도 제한 // 예: 사용자별/엔드포인트별 차등 Rate Limit 적용, 버스트 트래픽 허용 + 평균 속도 제한 implementation libs.bucket4j.jdk17.core + + // ──────────────────────────────── + // 테스트 + // ──────────────────────────────── + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation platform(libs.junit.bom) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/modules/global/gradle.lockfile b/modules/global/gradle.lockfile index c99880db..f4667adf 100644 --- a/modules/global/gradle.lockfile +++ b/modules/global/gradle.lockfile @@ -14,8 +14,10 @@ com.fasterxml.jackson:jackson-bom:2.21.4=compileClasspath,runtimeClasspath,testC com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -44,7 +46,10 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClassp jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -52,11 +57,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -65,11 +74,25 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=compileClasspath,runtimeClasspa org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -92,11 +115,14 @@ org.springframework.boot:spring-boot-starter-data-jpa:4.1.0=compileClasspath,run org.springframework.boot:spring-boot-starter-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -113,11 +139,13 @@ org.springframework:spring-core:7.0.8=compileClasspath,runtimeClasspath,testComp org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java b/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java index 288bc2fd..aa4250db 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java +++ b/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java @@ -21,7 +21,7 @@ public String encode(long id) { public long decode(String hashId) { long[] decoded = hashids.decode(hashId); if (decoded.length == 0) { - throw new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND); + throw new CustomException(ExceptionMessage.INVALID_URL_REQUEST); } if (decoded.length > 1) { log.error("[해시 디코딩 이상] 중복된 ID 발견 hashId={}", hashId); diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java index 73130cf4..9cfdd584 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java @@ -24,6 +24,13 @@ public ResponseEntity handleNoResourceFoundException(NoResourceFoundExcept @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException( CustomException customException) { + logCustomException(customException); + + return ResponseEntity.status(customException.getHttpStatus()) + .body(new CustomErrorResponse(customException.getMessage())); + } + + private void logCustomException(CustomException customException) { if (customException.getHttpStatus().is4xxClientError()) { if (customException.getContext() != null) { log.warn( @@ -39,9 +46,6 @@ public ResponseEntity handleCustomException( log.error("[서버 오류] 처리 중 예외 발생", customException); } } - - return ResponseEntity.status(customException.getHttpStatus()) - .body(new CustomErrorResponse(customException.getMessage())); } @ExceptionHandler(CallNotPermittedException.class) diff --git a/modules/global/src/main/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolver.java b/modules/global/src/main/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolver.java index 991d9f29..668593dd 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolver.java +++ b/modules/global/src/main/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolver.java @@ -2,6 +2,7 @@ import com.icc.qasker.global.annotation.RateLimit; import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; @@ -24,33 +25,23 @@ public RateLimitPlanResolver( } public RateLimitTier resolve(HttpServletRequest request) { - try { - HandlerExecutionChain chain = handlerMapping.getHandler(request); - if (chain != null && chain.getHandler() instanceof HandlerMethod handlerMethod) { - RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); - if (rateLimit != null) { - return rateLimit.value(); - } - } - } catch (Exception ignored) { - // 핸들러 조회 실패 시 기본값 사용 - } - return RateLimitTier.READ; + return findAnnotation(request).map(RateLimit::value).orElse(RateLimitTier.READ); } /** 요청 핸들러에 global=true 가 선언되어 있으면 true를 반환한다. */ public boolean resolveGlobal(HttpServletRequest request) { + return findAnnotation(request).map(RateLimit::global).orElse(false); + } + + private Optional findAnnotation(HttpServletRequest request) { try { HandlerExecutionChain chain = handlerMapping.getHandler(request); if (chain != null && chain.getHandler() instanceof HandlerMethod handlerMethod) { - RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); - if (rateLimit != null) { - return rateLimit.global(); - } + return Optional.ofNullable(handlerMethod.getMethodAnnotation(RateLimit.class)); } } catch (Exception ignored) { // 핸들러 조회 실패 시 기본값 사용 } - return false; + return Optional.empty(); } } diff --git a/modules/global/src/test/java/com/icc/qasker/global/component/HashUtilTest.java b/modules/global/src/test/java/com/icc/qasker/global/component/HashUtilTest.java new file mode 100644 index 00000000..b7252798 --- /dev/null +++ b/modules/global/src/test/java/com/icc/qasker/global/component/HashUtilTest.java @@ -0,0 +1,32 @@ +package com.icc.qasker.global.component; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.icc.qasker.global.error.CustomException; +import org.hashids.Hashids; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("HashUtil 인코딩/디코딩 검증") +class HashUtilTest { + + private final HashUtil hashUtil = new HashUtil(new Hashids("test-salt", 6)); + + @ParameterizedTest + @ValueSource(longs = {1L, 42L, 12345L, 9_999_999L}) + @DisplayName("encode 후 decode 하면 원래 id로 복원된다 (왕복 항등)") + void encodeDecodeRoundTrip(long id) { + String encoded = hashUtil.encode(id); + + assertThat(hashUtil.decode(encoded)).isEqualTo(id); + } + + @ParameterizedTest + @ValueSource(strings = {"", "!!!invalid!!!", "###"}) + @DisplayName("디코딩 결과가 비어 있으면 CustomException을 던진다") + void decodeInvalidThrows(String invalid) { + assertThatThrownBy(() -> hashUtil.decode(invalid)).isInstanceOf(CustomException.class); + } +} diff --git a/modules/global/src/test/java/com/icc/qasker/global/error/CustomExceptionTest.java b/modules/global/src/test/java/com/icc/qasker/global/error/CustomExceptionTest.java new file mode 100644 index 00000000..c3417ead --- /dev/null +++ b/modules/global/src/test/java/com/icc/qasker/global/error/CustomExceptionTest.java @@ -0,0 +1,69 @@ +package com.icc.qasker.global.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +@DisplayName("CustomException 생성자 5종 계약 검증") +class CustomExceptionTest { + + @Test + @DisplayName("ExceptionMessage 단일 생성자: httpStatus/message 매핑, context/cause 없음") + void exceptionMessageOnly() { + CustomException ex = new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND); + + assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(ex.getMessage()).isEqualTo("문제 세트를 찾을 수 없습니다."); + assertThat(ex.getContext()).isNull(); + assertThat(ex.getCause()).isNull(); + } + + @Test + @DisplayName("동적 메시지 생성자: 지정한 status/message 그대로, context/cause 없음") + void dynamicStatusAndMessage() { + CustomException ex = new CustomException(HttpStatus.BAD_REQUEST, "직접 지정 메시지"); + + assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getMessage()).isEqualTo("직접 지정 메시지"); + assertThat(ex.getContext()).isNull(); + assertThat(ex.getCause()).isNull(); + } + + @Test + @DisplayName("컨텍스트 생성자: context 보존, cause 없음") + void withContext() { + CustomException ex = new CustomException(ExceptionMessage.PROBLEM_NOT_FOUND, "problemId=5"); + + assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(ex.getMessage()).isEqualTo("해당 문제를 찾을 수 없습니다."); + assertThat(ex.getContext()).isEqualTo("problemId=5"); + assertThat(ex.getCause()).isNull(); + } + + @Test + @DisplayName("원인 체이닝 생성자: cause 보존, context 없음") + void withCause() { + Throwable cause = new IllegalStateException("root"); + CustomException ex = new CustomException(ExceptionMessage.CONVERT_FAILED, cause); + + assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(ex.getMessage()).isEqualTo("파일 변환에 실패했습니다."); + assertThat(ex.getContext()).isNull(); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + @DisplayName("컨텍스트 + 원인 생성자: context와 cause 모두 보존") + void withContextAndCause() { + Throwable cause = new RuntimeException("upload fail"); + CustomException ex = + new CustomException(ExceptionMessage.FILE_UPLOAD_FAILED, "bucket=my-bucket", cause); + + assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(ex.getMessage()).isEqualTo("파일 업로드에 실패했습니다."); + assertThat(ex.getContext()).isEqualTo("bucket=my-bucket"); + assertThat(ex.getCause()).isSameAs(cause); + } +} diff --git a/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..b734a419 --- /dev/null +++ b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java @@ -0,0 +1,173 @@ +package com.icc.qasker.global.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import org.apache.catalina.connector.ClientAbortException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@DisplayName("GlobalExceptionHandler 예외→HTTP 응답/로깅 변환 검증") +class GlobalExceptionHandlerTest { + + private MockMvc mockMvc; + private ListAppender logAppender; + private Logger handlerLogger; + + @BeforeEach + void setUp() { + mockMvc = + MockMvcBuilders.standaloneSetup(new TestController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + + handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + logAppender = new ListAppender<>(); + logAppender.start(); + handlerLogger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + handlerLogger.detachAppender(logAppender); + } + + @Test + @DisplayName("CustomException(4xx) → 해당 status + body message + WARN 로그") + void customException4xx() throws Exception { + mockMvc + .perform(get("/custom-4xx")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("사용자를 찾을 수 없습니다.")); + + assertThat(logAppender.list).anyMatch(e -> e.getLevel() == Level.WARN); + } + + @Test + @DisplayName("CustomException(5xx, context 포함) → status + [context] ERROR 로그") + void customException5xxWithContext() throws Exception { + mockMvc + .perform(get("/custom-5xx")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("서버 오류가 발생했습니다.")); + + assertThat(logAppender.list) + .anyMatch(e -> e.getLevel() == Level.ERROR && e.getFormattedMessage().contains("userId=5")); + } + + @Test + @DisplayName("CallNotPermittedException → 500 + AI 통신 오류 메시지") + void circuitBreakerOpen() throws Exception { + mockMvc + .perform(get("/circuit")) + .andExpect(status().isInternalServerError()) + .andExpect( + jsonPath("$.message") + .value(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR.getMessage())); + } + + @Test + @DisplayName("MaxUploadSizeExceededException → FILE_SIZE_EXCEEDED status/message") + void maxUploadSize() throws Exception { + mockMvc + .perform(get("/upload-too-large")) + .andExpect(status().is(ExceptionMessage.FILE_SIZE_EXCEEDED.getHttpStatus().value())) + .andExpect(jsonPath("$.message").value(ExceptionMessage.FILE_SIZE_EXCEEDED.getMessage())); + } + + @Test + @DisplayName("ClientAbortException(SSE) → 무응답(200, 빈 본문) 정상 종료") + void sseClientAbort() throws Exception { + mockMvc.perform(get("/sse-abort")).andExpect(status().isOk()).andExpect(content().string("")); + } + + @Test + @DisplayName("AsyncRequestNotUsableException(SSE) → 무응답(200, 빈 본문) 정상 종료") + void sseAsyncNotUsable() throws Exception { + mockMvc.perform(get("/sse-async")).andExpect(status().isOk()).andExpect(content().string("")); + } + + @Test + @DisplayName("AsyncRequestTimeoutException → 503 + AI 통신 오류 메시지") + void asyncTimeout() throws Exception { + mockMvc + .perform(get("/async-timeout")) + .andExpect(status().isServiceUnavailable()) + .andExpect( + jsonPath("$.message") + .value(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR.getMessage())); + } + + @Test + @DisplayName("미처리 Exception → 500 + DEFAULT_ERROR 메시지") + void unhandledException() throws Exception { + mockMvc + .perform(get("/boom")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value(ExceptionMessage.DEFAULT_ERROR.getMessage())); + } + + @RestController + static class TestController { + + @GetMapping("/custom-4xx") + public void custom4xx() { + throw new CustomException(ExceptionMessage.USER_NOT_FOUND); + } + + @GetMapping("/custom-5xx") + public void custom5xx() { + throw new CustomException(ExceptionMessage.DEFAULT_ERROR, "userId=5"); + } + + @GetMapping("/circuit") + public void circuit() { + throw CallNotPermittedException.createCallNotPermittedException( + CircuitBreaker.ofDefaults("test")); + } + + @GetMapping("/upload-too-large") + public void uploadTooLarge() { + throw new MaxUploadSizeExceededException(10L); + } + + @GetMapping("/sse-abort") + public void sseAbort() throws ClientAbortException { + throw new ClientAbortException("client gone"); + } + + @GetMapping("/sse-async") + public void sseAsync() throws AsyncRequestNotUsableException { + throw new AsyncRequestNotUsableException("async not usable"); + } + + @GetMapping("/async-timeout") + public void asyncTimeout() { + throw new AsyncRequestTimeoutException(); + } + + @GetMapping("/boom") + public void boom() { + throw new RuntimeException("unexpected"); + } + } +} diff --git a/modules/global/src/test/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolverTest.java b/modules/global/src/test/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolverTest.java new file mode 100644 index 00000000..94c98338 --- /dev/null +++ b/modules/global/src/test/java/com/icc/qasker/global/ratelimit/RateLimitPlanResolverTest.java @@ -0,0 +1,78 @@ +package com.icc.qasker.global.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.icc.qasker.global.annotation.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@DisplayName("RateLimitPlanResolver 등급/글로벌 결정 검증") +class RateLimitPlanResolverTest { + + private RequestMappingHandlerMapping handlerMapping; + private RateLimitPlanResolver resolver; + private final HttpServletRequest request = mock(HttpServletRequest.class); + + @BeforeEach + void setUp() { + handlerMapping = mock(RequestMappingHandlerMapping.class); + resolver = new RateLimitPlanResolver(handlerMapping); + } + + private HandlerExecutionChain chainFor(String methodName) throws Exception { + Method method = TestHandler.class.getMethod(methodName); + return new HandlerExecutionChain(new HandlerMethod(new TestHandler(), method)); + } + + @Test + @DisplayName("@RateLimit 있는 핸들러 → 지정 tier와 global 값 반환") + void annotatedHandler() throws Exception { + when(handlerMapping.getHandler(request)).thenReturn(chainFor("annotated")); + + assertThat(resolver.resolve(request)).isEqualTo(RateLimitTier.CRITICAL); + assertThat(resolver.resolveGlobal(request)).isTrue(); + } + + @Test + @DisplayName("@RateLimit 없는 핸들러 → 기본값 READ / false") + void unannotatedHandler() throws Exception { + when(handlerMapping.getHandler(request)).thenReturn(chainFor("plain")); + + assertThat(resolver.resolve(request)).isEqualTo(RateLimitTier.READ); + assertThat(resolver.resolveGlobal(request)).isFalse(); + } + + @Test + @DisplayName("핸들러 없음(null) → 기본값 READ / false") + void noHandler() throws Exception { + when(handlerMapping.getHandler(request)).thenReturn(null); + + assertThat(resolver.resolve(request)).isEqualTo(RateLimitTier.READ); + assertThat(resolver.resolveGlobal(request)).isFalse(); + } + + @Test + @DisplayName("핸들러 조회 실패(예외) → 기본값 READ / false") + void lookupFails() throws Exception { + when(handlerMapping.getHandler(request)).thenThrow(new RuntimeException("boom")); + + assertThat(resolver.resolve(request)).isEqualTo(RateLimitTier.READ); + assertThat(resolver.resolveGlobal(request)).isFalse(); + } + + static class TestHandler { + + @RateLimit(value = RateLimitTier.CRITICAL, global = true) + public void annotated() {} + + public void plain() {} + } +} diff --git a/modules/quiz-history/impl/gradle.lockfile b/modules/quiz-history/impl/gradle.lockfile index 36f30075..67661ef5 100644 --- a/modules/quiz-history/impl/gradle.lockfile +++ b/modules/quiz-history/impl/gradle.lockfile @@ -14,8 +14,10 @@ com.fasterxml.jackson:jackson-bom:2.21.4=compileClasspath,runtimeClasspath,testC com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=runtimeClasspath,testRuntimeClasspath @@ -44,7 +46,10 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClassp jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -52,11 +57,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=runtimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -65,11 +74,25 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=compileClasspath,runtimeClasspa org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -92,11 +115,14 @@ org.springframework.boot:spring-boot-starter-data-jpa:4.1.0=compileClasspath,run org.springframework.boot:spring-boot-starter-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -113,11 +139,13 @@ org.springframework:spring-core:7.0.8=compileClasspath,runtimeClasspath,testComp org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/modules/quiz-set/impl/gradle.lockfile b/modules/quiz-set/impl/gradle.lockfile index 36f30075..80129558 100644 --- a/modules/quiz-set/impl/gradle.lockfile +++ b/modules/quiz-set/impl/gradle.lockfile @@ -14,8 +14,11 @@ com.fasterxml.jackson:jackson-bom:2.21.4=compileClasspath,runtimeClasspath,testC com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.cloud:libraries-bom:26.84.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.h2database:h2:2.4.240=testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.mysql:mysql-connector-j:9.7.0=runtimeClasspath,testRuntimeClasspath com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-annotations:2.4.0=runtimeClasspath,testRuntimeClasspath @@ -44,7 +47,10 @@ jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClassp jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.10=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -52,11 +58,15 @@ org.apache.logging.log4j:log4j-to-slf4j:2.25.4=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=runtimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hashids:hashids:1.0.3=runtimeClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.1.1=runtimeClasspath,testRuntimeClasspath @@ -65,11 +75,25 @@ org.hibernate.orm:hibernate-platform:7.4.1.Final=compileClasspath,runtimeClasspa org.hibernate.validator:hibernate-validator:9.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.23.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.46=annotationProcessor,compileClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-common:3.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -78,25 +102,33 @@ org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3=compileClasspath,runtime org.springframework.ai:spring-ai-bom:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-commons:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-dependencies:4.1.0=annotationProcessor,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-hibernate:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jpa:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.1.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.1.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-transaction:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-validation:4.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -113,11 +145,13 @@ org.springframework:spring-core:7.0.8=compileClasspath,runtimeClasspath,testComp org.springframework:spring-expression:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.8=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:swagger-ui:5.32.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.webjars:webjars-locator-lite:1.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.11.0=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath From 095d17ee2eb5cbe1c728eb8c9f06530e8b8ee79b Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:31:18 +0900 Subject: [PATCH 07/10] =?UTF-8?q?ICC-387=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20oci=20=EB=AA=A8=EB=93=88=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OciObjectStorageServiceImpl: uploadImage/uploadPdf 중복 골격(PutObjectRequest 조립→upload→CDN URL)을 private 헬퍼로 집약, 소스 타입별 UploadRequest 조립만 Function 위임 (117→107줄) - 회귀 테스트 15개 선작성 (URL 계약·objectName 규칙·opcMeta 인코딩·BufferedInputStream 래핑 + FileValidateService 검증 분기) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/OciObjectStorageServiceImpl.java | 96 ++++++------ .../service/FileValidateServiceImplTest.java | 112 ++++++++++++++ .../OciObjectStorageServiceImplTest.java | 145 ++++++++++++++---- 3 files changed, 281 insertions(+), 72 deletions(-) create mode 100644 modules/oci/impl/src/test/java/com/icc/qasker/oci/service/FileValidateServiceImplTest.java diff --git a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java index cd53bcf3..fe2da7bb 100644 --- a/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java +++ b/modules/oci/impl/src/main/java/com/icc/qasker/oci/service/OciObjectStorageServiceImpl.java @@ -12,7 +12,9 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; @@ -57,31 +59,20 @@ public String uploadImage( String extension = originalFileName.substring(originalFileName.lastIndexOf(".")); String objectName = "images/" + UUID.randomUUID() + extension; - String encodedFileName = UriUtils.encode(originalFileName, StandardCharsets.UTF_8); - - PutObjectRequest putObjectRequest = - PutObjectRequest.builder() - .namespaceName(ociProperties.namespace()) - .bucketName(ociProperties.imageBucketName()) - .objectName(objectName) - .contentType(contentType) - .opcMeta(java.util.Map.of("original-filename", encodedFileName)) - .build(); - // stream does not support mark/reset 워닝 방지를 위해 BufferedInputStream으로 래핑 InputStream bufferedInputStream = inputStream.markSupported() ? inputStream : new BufferedInputStream(inputStream); - UploadRequest uploadRequest = - UploadRequest.builder(bufferedInputStream, contentLength) - .allowOverwrite(true) - .build(putObjectRequest); - - uploadManager.upload(uploadRequest); - - String finalUrl = cdnProperties.imageBaseUrl() + "/" + objectName; - log.info("이미지 OCI 업로드 완료: {} -> {}", originalFileName, finalUrl); - return finalUrl; + return upload( + ociProperties.imageBucketName(), + objectName, + contentType, + originalFileName, + cdnProperties.imageBaseUrl(), + request -> + UploadRequest.builder(bufferedInputStream, contentLength) + .allowOverwrite(true) + .build(request)); }); } @@ -89,29 +80,46 @@ public String uploadImage( public String uploadPdf(Path pdfFile, String originalFileName) { return pdfUploadTimer.record( () -> { - String uuid = UUID.randomUUID().toString(); - String objectName = uuid + ".pdf"; - - String encodedFileName = UriUtils.encode(originalFileName, StandardCharsets.UTF_8); - - PutObjectRequest putObjectRequest = - PutObjectRequest.builder() - .namespaceName(ociProperties.namespace()) - .bucketName(ociProperties.pdfBucketName()) - .objectName(objectName) - .contentType("application/pdf") - .opcMeta(java.util.Map.of("original-filename", encodedFileName)) - .build(); - - // File 객체를 직접 전달하면 OCI SDK가 스트림 mark/reset 워닝 없이 멀티파트 업로드 처리 - UploadRequest uploadRequest = - UploadRequest.builder(pdfFile.toFile()).allowOverwrite(true).build(putObjectRequest); - - uploadManager.upload(uploadRequest); - - String finalUrl = cdnProperties.fileBaseUrl() + "/" + objectName; - log.info("PDF OCI 업로드 완료: {} -> {}", originalFileName, finalUrl); - return finalUrl; + String objectName = UUID.randomUUID() + ".pdf"; + + return upload( + ociProperties.pdfBucketName(), + objectName, + "application/pdf", + originalFileName, + cdnProperties.fileBaseUrl(), + // File 객체를 직접 전달하면 OCI SDK가 스트림 mark/reset 워닝 없이 멀티파트 업로드 처리 + request -> + UploadRequest.builder(pdfFile.toFile()).allowOverwrite(true).build(request)); }); } + + /** + * PutObjectRequest 조립 → 업로드 → CDN URL 생성의 공통 골격. 소스 타입(스트림/파일)별 UploadRequest 조립만 {@code + * uploadRequestFactory}로 위임한다. + */ + private String upload( + String bucketName, + String objectName, + String contentType, + String originalFileName, + String cdnBaseUrl, + Function uploadRequestFactory) { + String encodedFileName = UriUtils.encode(originalFileName, StandardCharsets.UTF_8); + + PutObjectRequest putObjectRequest = + PutObjectRequest.builder() + .namespaceName(ociProperties.namespace()) + .bucketName(bucketName) + .objectName(objectName) + .contentType(contentType) + .opcMeta(Map.of("original-filename", encodedFileName)) + .build(); + + uploadManager.upload(uploadRequestFactory.apply(putObjectRequest)); + + String finalUrl = cdnBaseUrl + "/" + objectName; + log.info("OCI 업로드 완료: {} -> {}", originalFileName, finalUrl); + return finalUrl; + } } diff --git a/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/FileValidateServiceImplTest.java b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/FileValidateServiceImplTest.java new file mode 100644 index 00000000..c4835d2e --- /dev/null +++ b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/FileValidateServiceImplTest.java @@ -0,0 +1,112 @@ +package com.icc.qasker.oci.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.oci.properties.CdnProperties; +import com.icc.qasker.oci.properties.FileValidationProperties; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FileValidateServiceImplTest { + + private static final String IMAGE_BASE_URL = "https://img.test.com"; + private static final String FILE_BASE_URL = "https://files.test.com"; + private static final long MAX_FILE_SIZE = 100; + private static final int MAX_FILE_NAME_LENGTH = 10; + + private FileValidateServiceImpl service; + + @BeforeEach + void setUp() { + CdnProperties cdnProperties = new CdnProperties(IMAGE_BASE_URL, FILE_BASE_URL); + FileValidationProperties validationProperties = + new FileValidationProperties( + MAX_FILE_SIZE, MAX_FILE_NAME_LENGTH, "image/png,application/pdf"); + service = new FileValidateServiceImpl(cdnProperties, validationProperties); + } + + // ── checkCdnUrlWithThrowing ───────────────────────────────────── + + @Test + @DisplayName("imageBaseUrl로 시작하는 URL은 통과한다") + void checkCdnUrl_imageBaseUrl_passes() { + assertThatCode(() -> service.checkCdnUrlWithThrowing(IMAGE_BASE_URL + "/images/a.png")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("fileBaseUrl로 시작하는 URL은 통과한다") + void checkCdnUrl_fileBaseUrl_passes() { + assertThatCode(() -> service.checkCdnUrlWithThrowing(FILE_BASE_URL + "/a.pdf")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("허용된 prefix가 아니면 INVALID_URL_REQUEST를 던진다") + void checkCdnUrl_invalidPrefix_throws() { + assertException( + () -> service.checkCdnUrlWithThrowing("https://evil.com/a.png"), + ExceptionMessage.INVALID_URL_REQUEST); + } + + // ── validateFileWithThrowing ──────────────────────────────────── + + @Test + @DisplayName("정상 파일은 통과한다") + void validateFile_valid_passes() { + assertThatCode(() -> service.validateFileWithThrowing("a.png", 50, "image/png")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("파일 크기 초과 시 OUT_OF_FILE_SIZE를 던진다") + void validateFile_oversized_throws() { + assertException( + () -> service.validateFileWithThrowing("a.png", MAX_FILE_SIZE + 1, "image/png"), + ExceptionMessage.OUT_OF_FILE_SIZE); + } + + @Test + @DisplayName("파일명이 null이면 FILE_NAME_NOT_EXIST를 던진다") + void validateFile_nullName_throws() { + assertException( + () -> service.validateFileWithThrowing(null, 50, "image/png"), + ExceptionMessage.FILE_NAME_NOT_EXIST); + } + + @Test + @DisplayName("파일명이 최대 길이를 초과하면 FILE_NAME_TOO_LONG을 던진다") + void validateFile_tooLongName_throws() { + String longName = "a".repeat(MAX_FILE_NAME_LENGTH + 1); + assertException( + () -> service.validateFileWithThrowing(longName, 50, "image/png"), + ExceptionMessage.FILE_NAME_TOO_LONG); + } + + @Test + @DisplayName("contentType이 null이면 EXTENSION_INVALID를 던진다") + void validateFile_nullContentType_throws() { + assertException( + () -> service.validateFileWithThrowing("a.png", 50, null), + ExceptionMessage.EXTENSION_INVALID); + } + + @Test + @DisplayName("허용되지 않은 확장자면 EXTENSION_INVALID를 던진다") + void validateFile_disallowedContentType_throws() { + assertException( + () -> service.validateFileWithThrowing("a.gif", 50, "image/gif"), + ExceptionMessage.EXTENSION_INVALID); + } + + private void assertException(ThrowingCallable callable, ExceptionMessage message) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .hasMessage(message.getMessage()); + } +} diff --git a/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java index e6cfdc69..ce99bab8 100644 --- a/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java +++ b/modules/oci/impl/src/test/java/com/icc/qasker/oci/service/OciObjectStorageServiceImplTest.java @@ -8,27 +8,38 @@ import com.icc.qasker.oci.properties.CdnProperties; import com.icc.qasker.oci.properties.OciObjectStorageProperties; +import com.oracle.bmc.objectstorage.requests.PutObjectRequest; import com.oracle.bmc.objectstorage.transfer.UploadManager; import com.oracle.bmc.objectstorage.transfer.UploadManager.UploadRequest; import com.oracle.bmc.objectstorage.transfer.UploadManager.UploadResponse; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.util.UriUtils; class OciObjectStorageServiceImplTest { + private static final String IMAGE_BASE_URL = "https://img.test.com"; + private static final String FILE_BASE_URL = "https://files.test.com"; + private OciObjectStorageServiceImpl service; private UploadManager uploadManager; @BeforeEach void setUp() { uploadManager = mock(UploadManager.class); + when(uploadManager.upload(any(UploadRequest.class))).thenReturn(mock(UploadResponse.class)); OciObjectStorageProperties ociProperties = new OciObjectStorageProperties( @@ -39,59 +50,137 @@ void setUp() { "~/.oci/config", "DEFAULT"); - CdnProperties cdnProperties = - new CdnProperties("https://files.test.com", "https://files.test.com"); + CdnProperties cdnProperties = new CdnProperties(IMAGE_BASE_URL, FILE_BASE_URL); service = new OciObjectStorageServiceImpl( cdnProperties, ociProperties, uploadManager, new SimpleMeterRegistry()); } + // ── 업로드 URL 계약 + objectName 규칙 ───────────────────────────── + @Test - @DisplayName("이미지 업로드 시 OCI에 저장하고 CDN URL을 반환한다") - void uploadImage_inputStream_returnsCdnUrl() { - // Given: 테스트용 이미지 데이터를 생성한다 + @DisplayName("이미지 업로드 시 imageBaseUrl 기반 images/ URL을 반환한다") + void uploadImage_returnsImageCdnUrlWithObjectNameRule() { byte[] content = "dummy image content".getBytes(); InputStream inputStream = new ByteArrayInputStream(content); - String contentType = "image/png"; - String originalFileName = "test.png"; - - // UploadManager.upload 호출 시 성공 응답을 반환하도록 모킹한다 - when(uploadManager.upload(any(UploadRequest.class))).thenReturn(mock(UploadResponse.class)); - // When: 이미지 업로드를 실행한다 - String cdnUrl = service.uploadImage(inputStream, content.length, contentType, originalFileName); + String cdnUrl = service.uploadImage(inputStream, content.length, "image/png", "test.png"); - // Then: CDN 도메인으로 시작하고 .png로 끝나야 한다 - assertThat(cdnUrl).startsWith("https://files.test.com/images/"); + assertThat(cdnUrl).startsWith(IMAGE_BASE_URL + "/images/"); assertThat(cdnUrl).endsWith(".png"); - // Then: UploadManager.upload()이 호출되었는지 검증한다 - verify(uploadManager).upload(any(UploadRequest.class)); + PutObjectRequest captured = captureRequest(); + assertThat(captured.getBucketName()).isEqualTo("test-image-bucket"); + assertThat(captured.getContentType()).isEqualTo("image/png"); + // objectName == URL 접미부(images/.png)와 일치 + assertThat(captured.getObjectName()) + .isEqualTo(cdnUrl.substring((IMAGE_BASE_URL + "/").length())); + assertThat(captured.getObjectName()).matches("images/[0-9a-fA-F-]{36}\\.png"); } @Test - @DisplayName("PDF 업로드 시 OCI에 저장하고 CDN URL을 반환한다") - void uploadPdf_pdfFile_returnsCdnUrl() throws IOException { - // Given: 임시 PDF 파일을 생성한다 + @DisplayName("PDF 업로드 시 fileBaseUrl 기반 .pdf URL을 반환한다") + void uploadPdf_returnsFileCdnUrlWithObjectNameRule() throws IOException { Path pdfFile = Files.createTempFile("test", ".pdf"); Files.writeString(pdfFile, "dummy pdf content"); - - // UploadManager.upload 호출 시 성공 응답을 반환하도록 모킹한다 - when(uploadManager.upload(any(UploadRequest.class))).thenReturn(mock(UploadResponse.class)); - try { - // When: PDF 파일 업로드를 실행한다 String cdnUrl = service.uploadPdf(pdfFile, "document.pdf"); - // Then: CDN 도메인으로 시작하고 .pdf로 끝나야 한다 - assertThat(cdnUrl).startsWith("https://files.test.com/"); + assertThat(cdnUrl).startsWith(FILE_BASE_URL + "/"); assertThat(cdnUrl).endsWith(".pdf"); - // Then: UploadManager.upload()이 호출되었는지 검증한다 - verify(uploadManager).upload(any(UploadRequest.class)); + PutObjectRequest captured = captureRequest(); + assertThat(captured.getBucketName()).isEqualTo("test-pdf-bucket"); + assertThat(captured.getContentType()).isEqualTo("application/pdf"); + assertThat(captured.getObjectName()) + .isEqualTo(cdnUrl.substring((FILE_BASE_URL + "/").length())); + assertThat(captured.getObjectName()).matches("[0-9a-fA-F-]{36}\\.pdf"); } finally { Files.deleteIfExists(pdfFile); } } + + // ── opcMeta 인코딩 ────────────────────────────────────────────── + + @Test + @DisplayName("이미지 업로드 시 한글 파일명이 UriUtils.encode로 인코딩되어 opcMeta에 실린다") + void uploadImage_encodesKoreanFileNameIntoOpcMeta() { + String originalFileName = "한글 파일&이름.png"; + byte[] content = "x".getBytes(); + + service.uploadImage( + new ByteArrayInputStream(content), content.length, "image/png", originalFileName); + + PutObjectRequest captured = captureRequest(); + String expected = UriUtils.encode(originalFileName, StandardCharsets.UTF_8); + assertThat(captured.getOpcMeta()).containsEntry("original-filename", expected); + // 인코딩되었으므로 원문 공백/특수문자가 그대로 남아있지 않다 + assertThat(expected).doesNotContain(" "); + } + + @Test + @DisplayName("PDF 업로드 시 한글 파일명이 UriUtils.encode로 인코딩되어 opcMeta에 실린다") + void uploadPdf_encodesKoreanFileNameIntoOpcMeta() throws IOException { + String originalFileName = "보고서 최종본.pdf"; + Path pdfFile = Files.createTempFile("test", ".pdf"); + Files.writeString(pdfFile, "dummy"); + try { + service.uploadPdf(pdfFile, originalFileName); + + PutObjectRequest captured = captureRequest(); + String expected = UriUtils.encode(originalFileName, StandardCharsets.UTF_8); + assertThat(captured.getOpcMeta()).containsEntry("original-filename", expected); + } finally { + Files.deleteIfExists(pdfFile); + } + } + + // ── markSupported 분기 ────────────────────────────────────────── + + @Test + @DisplayName("markSupported=false 스트림은 BufferedInputStream으로 래핑된다") + void uploadImage_wrapsNonMarkSupportedStream() { + InputStream nonMark = + new FilterInputStream(new ByteArrayInputStream("x".getBytes())) { + @Override + public boolean markSupported() { + return false; + } + }; + + service.uploadImage(nonMark, 1, "image/png", "a.png"); + + PutObjectRequest captured = captureRequest(); + assertThat(captured.getPutObjectBody()).isInstanceOf(BufferedInputStream.class); + } + + @Test + @DisplayName("markSupported=true 스트림은 래핑 없이 그대로 전달된다") + void uploadImage_keepsMarkSupportedStream() { + ByteArrayInputStream markSupported = new ByteArrayInputStream("x".getBytes()); + + service.uploadImage(markSupported, 1, "image/png", "a.png"); + + PutObjectRequest captured = captureRequest(); + assertThat(captured.getPutObjectBody()).isSameAs(markSupported); + } + + // ── 헬퍼 ──────────────────────────────────────────────────────── + + private PutObjectRequest captureRequest() { + ArgumentCaptor captor = ArgumentCaptor.forClass(UploadRequest.class); + verify(uploadManager).upload(captor.capture()); + return extractPutObjectRequest(captor.getValue()); + } + + private static PutObjectRequest extractPutObjectRequest(UploadRequest uploadRequest) { + try { + Field field = UploadRequest.class.getDeclaredField("putObjectRequest"); + field.setAccessible(true); + return (PutObjectRequest) field.get(uploadRequest); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("UploadRequest 내부 PutObjectRequest 추출 실패", e); + } + } } From 8054e08f4f989d88418f156424d9fd3ca71e2e64 Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:31:36 +0900 Subject: [PATCH 08/10] =?UTF-8?q?ICC-388=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20quiz-ai=20=EB=AA=A8=EB=93=88=20=EC=98=A4=EC=BC=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=ED=84=B0=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 청크 오케스트레이터 3종(MULTIPLE/BLANK/OX) → AbstractChunkedQuizOrchestrator 템플릿메서드 통합, 서브클래스는 훅 3개만 override (844→123줄) - 스트리밍 추출기 제네릭화: StreamingJsonArrayExtractor로 통합, 중복 추출기 2종 삭제 - 레거시 캐시 경로 제거(GeminiChatService/GeminiCacheService/GeminiCacheConfig/ChunkInfo/스모크 ~355줄) - usage 로깅 4곳 → recordChunkUsage() 흡수, orphan 메트릭·QuizPromptStrategy 2-arg 死메서드 제거(ISP) - 회귀 테스트 24개 선작성 (오케스트레이터 계약 18 + 추출기 4 + 메트릭 2) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/icc/qasker/ai/dto/ChunkInfo.java | 5 - .../qasker/ai/config/GeminiCacheConfig.java | 17 -- .../AbstractChunkedQuizOrchestrator.java | 285 ++++++++++++++++++ .../service/blank/BlankQuizOrchestrator.java | 260 +--------------- .../service/essay/EssayQuizOrchestrator.java | 28 +- .../multiple/MultipleQuizOrchestrator.java | 266 +--------------- .../ai/service/ox/OXQuizOrchestrator.java | 251 +-------------- .../service/support/GeminiCacheService.java | 134 -------- .../ai/service/support/GeminiChatService.java | 126 -------- .../support/GeminiMetricsRecorder.java | 81 ++--- .../StreamingEssayQuestionExtractor.java | 100 ------ ....java => StreamingJsonArrayExtractor.java} | 45 +-- .../ai/strategy/QuizPromptStrategy.java | 10 +- .../com/icc/qasker/ai/strategy/QuizType.java | 20 -- .../ChunkedQuizOrchestratorContractTest.java | 236 +++++++++++++++ .../support/GeminiChatServiceSmokeTest.java | 73 ----- .../support/GeminiMetricsRecorderTest.java | 50 +++ .../StreamingQuestionExtractorTest.java | 80 +++++ 18 files changed, 740 insertions(+), 1327 deletions(-) delete mode 100644 modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/ChunkInfo.java delete mode 100644 modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiCacheConfig.java create mode 100644 modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java delete mode 100644 modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiCacheService.java delete mode 100644 modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiChatService.java delete mode 100644 modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java rename modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/{StreamingQuestionExtractor.java => StreamingJsonArrayExtractor.java} (55%) create mode 100644 modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/ChunkedQuizOrchestratorContractTest.java delete mode 100644 modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiChatServiceSmokeTest.java create mode 100644 modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorderTest.java create mode 100644 modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractorTest.java diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/ChunkInfo.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/ChunkInfo.java deleted file mode 100644 index ba57b754..00000000 --- a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/ChunkInfo.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.icc.qasker.ai.dto; - -import java.util.List; - -public record ChunkInfo(List referencedPages, int quizCount) {} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiCacheConfig.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiCacheConfig.java deleted file mode 100644 index 1c2e99f7..00000000 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiCacheConfig.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.icc.qasker.ai.config; - -import com.google.genai.Client; -import org.springframework.ai.google.genai.cache.GoogleGenAiCachedContentService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class GeminiCacheConfig { - - @Bean - @ConditionalOnMissingBean - public GoogleGenAiCachedContentService googleGenAiCachedContentService(Client genAiClient) { - return new GoogleGenAiCachedContentService(genAiClient); - } -} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java new file mode 100644 index 00000000..d213238f --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java @@ -0,0 +1,285 @@ +package com.icc.qasker.ai.service; + +import com.icc.qasker.ai.GeminiFileService; +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; +import com.icc.qasker.ai.dto.GenerationRequestToAI; +import com.icc.qasker.ai.exception.GeminiInfraException; +import com.icc.qasker.ai.mapper.GeminiQuestionMapper; +import com.icc.qasker.ai.properties.QAskerAiProperties; +import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; +import com.icc.qasker.ai.service.support.PreviousGenerationContext; +import com.icc.qasker.ai.service.support.StreamingJsonArrayExtractor; +import com.icc.qasker.ai.strategy.QuizType; +import com.icc.qasker.ai.structure.GeminiQuestion; +import com.icc.qasker.ai.structure.GeminiResponseSchema; +import com.icc.qasker.global.error.CustomException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.DoubleAdder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.util.MimeTypeUtils; +import reactor.core.publisher.Flux; +import tools.jackson.databind.ObjectMapper; + +/** + * 청크형(MULTIPLE/BLANK/OX) 퀴즈 오케스트레이터의 공통 골격. N문항 요청을 chunk-size 단위로 분할 호출하고, 청크 K(K≥2)에는 직전 누적 문항 + * 요약을 시스템 프롬프트에 주입한다. + * + *

한 청크가 실패해도 직전 청크까지의 산출물은 보존된다(PRD S3.1). + * + *

타입별 차이는 {@link #getSupportedType()}, {@link #maxSelectionCount()}, {@link #dedupInstruction()} + * 세 훅으로만 노출된다. + */ +@Slf4j +public abstract class AbstractChunkedQuizOrchestrator implements QuizTypeOrchestrator { + + private final GeminiFileService geminiFileService; + private final ChatModel chatModel; + private final ObjectMapper objectMapper; + private final GeminiMetricsRecorder metricsRecorder; + private final QAskerAiProperties aiProperties; + + protected AbstractChunkedQuizOrchestrator( + GeminiFileService geminiFileService, + ChatModel chatModel, + ObjectMapper objectMapper, + GeminiMetricsRecorder metricsRecorder, + QAskerAiProperties aiProperties) { + this.geminiFileService = geminiFileService; + this.chatModel = chatModel; + this.objectMapper = objectMapper; + this.metricsRecorder = metricsRecorder; + this.aiProperties = aiProperties; + } + + /** 문항 최대 선택지 수. 초과 문항은 drop 된다 (MULTIPLE/BLANK=4, OX=2). */ + protected abstract int maxSelectionCount(); + + /** 청크 K(K≥2) 시스템 프롬프트 꼬리에 붙는 타입별 중복 회피 지침 문구. */ + protected abstract String dedupInstruction(); + + @Override + public int generateQuiz(GenerationRequestToAI request) { + long startNanos = System.nanoTime(); + String tag = getSupportedType(); + + DoubleAdder totalCost = new DoubleAdder(); + AtomicLong firstNanos = new AtomicLong(0); + AtomicLong lastNanos = new AtomicLong(0); + AtomicInteger delivered = new AtomicInteger(0); + int quizCount = request.quizCount(); + + QuizType quizType = QuizType.valueOf(request.strategyValue()); + String baseSystemPrompt = quizType.getSystemGuideLine(request.language()); + + // PDF 업로드는 한 번만 (캐시) + String cacheKey = + geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); + FileMetadata metadata; + try { + metadata = + geminiFileService + .awaitCachedFileMetadata(cacheKey) + .orElseGet( + () -> geminiFileService.uploadPdf(request.fileUrl(), request.referencePages())); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + throw new GeminiInfraException("PDF 업로드 실패", e); + } + + List chunkPlan = aiProperties.getChunk().planChunks(quizCount); + log.info( + "{} 청크 분할: 요청={}문항, chunk-size={}, 청크={}개", + tag, + quizCount, + aiProperties.getChunk().getChunkSize(), + chunkPlan.size()); + + List accumulated = new ArrayList<>(); + int chunksDone = 0; + + try { + for (int chunkIndex = 0; chunkIndex < chunkPlan.size(); chunkIndex++) { + int chunkSize = chunkPlan.get(chunkIndex); + if (delivered.get() >= quizCount) break; + + String systemPrompt = buildSystemPrompt(baseSystemPrompt, accumulated, chunkIndex); + String userPrompt = + quizType.generateRequestPrompt( + request.referencePages(), chunkSize, request.customInstruction()); + + runChunk( + chunkIndex, + systemPrompt, + userPrompt, + metadata, + request, + quizCount, + chunkSize, + startNanos, + delivered, + firstNanos, + lastNanos, + totalCost, + accumulated); + chunksDone++; + } + } catch (CustomException e) { + // 정책: 청크 도중 비즈니스 예외가 발생해도 직전 청크 결과는 보존 + if (delivered.get() == 0) throw e; + log.warn( + "{} 청크 도중 비즈니스 오류. {}/{} 청크, {}문항 보존.", + tag, + chunksDone, + chunkPlan.size(), + delivered.get(), + e); + } catch (Exception e) { + if (delivered.get() == 0) throw new GeminiInfraException("Gemini 인프라 장애", e); + log.warn( + "{} 청크 도중 인프라 오류. {}/{} 청크, {}문항 보존.", + tag, + chunksDone, + chunkPlan.size(), + delivered.get(), + e); + metricsRecorder.recordStreamingTimeout(tag); + } + + log.info( + "{} 청크 루프 완료: 전달={}문항(목표 {}), 청크 완료={}/{}, 총 소요={}ms", + tag, + delivered.get(), + quizCount, + chunksDone, + chunkPlan.size(), + (System.nanoTime() - startNanos) / 1_000_000); + + Long first = firstNanos.get() == 0 ? null : firstNanos.get(); + Long last = lastNanos.get() == 0 ? null : lastNanos.get(); + metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); + return chunksDone == 0 ? 1 : chunksDone; + } + + /** 한 청크의 Gemini 호출 + 스트리밍 파싱을 실행한다. */ + private void runChunk( + int chunkIndex, + String systemPrompt, + String userPrompt, + FileMetadata metadata, + GenerationRequestToAI request, + int quizCount, + int chunkSize, + long startNanos, + AtomicInteger delivered, + AtomicLong firstNanos, + AtomicLong lastNanos, + DoubleAdder totalCost, + List accumulated) { + + String tag = getSupportedType(); + var pdfMedia = + new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); + var userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build(); + var systemMessage = new SystemMessage(systemPrompt); + + String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction()); + var options = + GoogleGenAiChatOptions.builder() + .responseMimeType("application/json") + .responseSchema(responseSchema) + .build(); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); + log.info( + "{} 청크 #{} 시작: 청크 목표={}문항, 누적={}/{}", + tag, + chunkIndex, + chunkSize, + delivered.get(), + quizCount); + + StreamingJsonArrayExtractor extractor = + new StreamingJsonArrayExtractor<>( + objectMapper, + GeminiQuestion.class, + question -> { + if (delivered.get() >= quizCount) return; + if (question.selections() != null + && question.selections().size() > maxSelectionCount()) return; + + int count = delivered.incrementAndGet(); + var dtoSet = GeminiQuestionMapper.toDto(List.of(question), metadata.sourcePages()); + if (dtoSet.quiz() != null) { + accumulated.addAll(dtoSet.quiz()); + } + request.questionsConsumer().accept(dtoSet); + + long now = System.nanoTime(); + firstNanos.compareAndSet(0, now); + lastNanos.updateAndGet(prev -> Math.max(prev, now)); + + if (count == 1) { + long ttfqMs = (now - startNanos) / 1_000_000; + log.info("TTFQ (Time To First Question): {}ms", ttfqMs); + } + }, + tag); + + Flux stream = chatModel.stream(prompt); + stream + .doOnNext( + response -> { + if (response.getResult() != null + && response.getResult().getOutput() != null + && response.getResult().getOutput().getText() != null) { + extractor.feed(response.getResult().getOutput().getText()); + } + + if (response.getMetadata() != null + && response.getMetadata().getUsage() != null + && response.getMetadata().getUsage().getCompletionTokens() > 0) { + double cost = + metricsRecorder.recordChunkUsage( + tag + " chunk #" + chunkIndex, + startNanos, + response.getMetadata().getUsage()); + totalCost.add(cost); + } + }) + .blockLast(java.time.Duration.ofMinutes(6)); + } + + /** 청크 K(K≥2)의 시스템 프롬프트에 직전 누적 컨텍스트와 중복 회피 지침을 부가한다. */ + private String buildSystemPrompt( + String baseSystemPrompt, List accumulated, int chunkIndex) { + if (chunkIndex == 0 || accumulated.isEmpty()) return baseSystemPrompt; + + PreviousGenerationContext ctx = PreviousGenerationContext.from(accumulated); + if (ctx.isEmpty()) return baseSystemPrompt; + + String summaryJson = serializeContext(ctx); + return baseSystemPrompt + "\n\n# 직전 청크 누적 문항 요약 (중복 회피용)\n" + summaryJson + dedupInstruction(); + } + + private String serializeContext(PreviousGenerationContext ctx) { + try { + return objectMapper.writeValueAsString(ctx); + } catch (Exception e) { + log.warn("직전 컨텍스트 직렬화 실패. 컨텍스트 주입 생략.", e); + return "[]"; + } + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/blank/BlankQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/blank/BlankQuizOrchestrator.java index ec207c36..d87d156a 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/blank/BlankQuizOrchestrator.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/blank/BlankQuizOrchestrator.java @@ -1,55 +1,16 @@ package com.icc.qasker.ai.service.blank; import com.icc.qasker.ai.GeminiFileService; -import com.icc.qasker.ai.dto.AIProblem; -import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; -import com.icc.qasker.ai.dto.GenerationRequestToAI; -import com.icc.qasker.ai.exception.GeminiInfraException; -import com.icc.qasker.ai.mapper.GeminiQuestionMapper; import com.icc.qasker.ai.properties.QAskerAiProperties; -import com.icc.qasker.ai.service.QuizTypeOrchestrator; +import com.icc.qasker.ai.service.AbstractChunkedQuizOrchestrator; import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; -import com.icc.qasker.ai.service.support.PreviousGenerationContext; -import com.icc.qasker.ai.service.support.StreamingQuestionExtractor; -import com.icc.qasker.ai.strategy.QuizType; -import com.icc.qasker.ai.structure.GeminiResponseSchema; -import com.icc.qasker.global.error.CustomException; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.DoubleAdder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.content.Media; -import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; import org.springframework.stereotype.Component; -import org.springframework.util.MimeTypeUtils; -import reactor.core.publisher.Flux; import tools.jackson.databind.ObjectMapper; -/** - * 빈칸채우기(BLANK) 퀴즈 오케스트레이터. N문항 요청을 chunk-size 단위로 분할 호출하고, 청크 K(K≥2)에는 직전 누적 문항 요약을 시스템 프롬프트에 주입한다. - * - *

한 청크가 실패해도 직전 청크까지의 산출물은 보존된다(PRD S3.1). - */ -@Slf4j +/** 빈칸채우기(BLANK) 퀴즈 오케스트레이터. */ @Component -public class BlankQuizOrchestrator implements QuizTypeOrchestrator { - - private static final int MAX_SELECTION_COUNT = 4; - - private final GeminiFileService geminiFileService; - private final ChatModel chatModel; - private final ObjectMapper objectMapper; - private final GeminiMetricsRecorder metricsRecorder; - private final QAskerAiProperties aiProperties; +public class BlankQuizOrchestrator extends AbstractChunkedQuizOrchestrator { public BlankQuizOrchestrator( GeminiFileService geminiFileService, @@ -57,11 +18,7 @@ public BlankQuizOrchestrator( ObjectMapper objectMapper, GeminiMetricsRecorder metricsRecorder, QAskerAiProperties aiProperties) { - this.geminiFileService = geminiFileService; - this.chatModel = chatModel; - this.objectMapper = objectMapper; - this.metricsRecorder = metricsRecorder; - this.aiProperties = aiProperties; + super(geminiFileService, chatModel, objectMapper, metricsRecorder, aiProperties); } @Override @@ -70,213 +27,14 @@ public String getSupportedType() { } @Override - public int generateQuiz(GenerationRequestToAI request) { - long startNanos = System.nanoTime(); - - DoubleAdder totalCost = new DoubleAdder(); - AtomicLong firstNanos = new AtomicLong(0); - AtomicLong lastNanos = new AtomicLong(0); - AtomicInteger delivered = new AtomicInteger(0); - int quizCount = request.quizCount(); - - QuizType quizType = QuizType.valueOf(request.strategyValue()); - String baseSystemPrompt = quizType.getSystemGuideLine(request.language()); - - String cacheKey = - geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); - FileMetadata metadata; - try { - metadata = - geminiFileService - .awaitCachedFileMetadata(cacheKey) - .orElseGet( - () -> geminiFileService.uploadPdf(request.fileUrl(), request.referencePages())); - } catch (CustomException e) { - throw e; - } catch (Exception e) { - throw new GeminiInfraException("PDF 업로드 실패", e); - } - - List chunkPlan = aiProperties.getChunk().planChunks(quizCount); - log.info( - "BLANK 청크 분할: 요청={}문항, chunk-size={}, 청크={}개", - quizCount, - aiProperties.getChunk().getChunkSize(), - chunkPlan.size()); - - List accumulated = new ArrayList<>(); - int chunksDone = 0; - - try { - for (int chunkIndex = 0; chunkIndex < chunkPlan.size(); chunkIndex++) { - int chunkSize = chunkPlan.get(chunkIndex); - if (delivered.get() >= quizCount) break; - - String systemPrompt = buildSystemPrompt(baseSystemPrompt, accumulated, chunkIndex); - String userPrompt = - quizType.generateRequestPrompt( - request.referencePages(), chunkSize, request.customInstruction()); - - runChunk( - chunkIndex, - systemPrompt, - userPrompt, - metadata, - request, - quizCount, - chunkSize, - startNanos, - delivered, - firstNanos, - lastNanos, - totalCost, - accumulated); - chunksDone++; - } - } catch (CustomException e) { - if (delivered.get() == 0) throw e; - log.warn( - "BLANK 청크 도중 비즈니스 오류. {}/{} 청크, {}문항 보존.", - chunksDone, - chunkPlan.size(), - delivered.get(), - e); - } catch (Exception e) { - if (delivered.get() == 0) throw new GeminiInfraException("Gemini 인프라 장애", e); - log.warn( - "BLANK 청크 도중 인프라 오류. {}/{} 청크, {}문항 보존.", - chunksDone, - chunkPlan.size(), - delivered.get(), - e); - metricsRecorder.recordStreamingTimeout("BLANK"); - } - - log.info( - "BLANK 청크 루프 완료: 전달={}문항(목표 {}), 청크 완료={}/{}, 총 소요={}ms", - delivered.get(), - quizCount, - chunksDone, - chunkPlan.size(), - (System.nanoTime() - startNanos) / 1_000_000); - - Long first = firstNanos.get() == 0 ? null : firstNanos.get(); - Long last = lastNanos.get() == 0 ? null : lastNanos.get(); - metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); - return chunksDone == 0 ? 1 : chunksDone; - } - - private void runChunk( - int chunkIndex, - String systemPrompt, - String userPrompt, - FileMetadata metadata, - GenerationRequestToAI request, - int quizCount, - int chunkSize, - long startNanos, - AtomicInteger delivered, - AtomicLong firstNanos, - AtomicLong lastNanos, - DoubleAdder totalCost, - List accumulated) { - - var pdfMedia = - new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); - var userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build(); - var systemMessage = new SystemMessage(systemPrompt); - - String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction()); - var options = - GoogleGenAiChatOptions.builder() - .responseMimeType("application/json") - .responseSchema(responseSchema) - .build(); - - Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); - log.info( - "BLANK 청크 #{} 시작: 청크 목표={}문항, 누적={}/{}", chunkIndex, chunkSize, delivered.get(), quizCount); - - StreamingQuestionExtractor extractor = - new StreamingQuestionExtractor( - objectMapper, - question -> { - if (delivered.get() >= quizCount) return; - if (question.selections() != null - && question.selections().size() > MAX_SELECTION_COUNT) return; - - int count = delivered.incrementAndGet(); - var dtoSet = GeminiQuestionMapper.toDto(List.of(question), metadata.sourcePages()); - if (dtoSet.quiz() != null) { - accumulated.addAll(dtoSet.quiz()); - } - request.questionsConsumer().accept(dtoSet); - - long now = System.nanoTime(); - firstNanos.compareAndSet(0, now); - lastNanos.updateAndGet(prev -> Math.max(prev, now)); - - if (count == 1) { - long ttfqMs = (now - startNanos) / 1_000_000; - log.info("TTFQ (Time To First Question): {}ms", ttfqMs); - } - }); - - Flux stream = chatModel.stream(prompt); - stream - .doOnNext( - response -> { - if (response.getResult() != null - && response.getResult().getOutput() != null - && response.getResult().getOutput().getText() != null) { - extractor.feed(response.getResult().getOutput().getText()); - } - - if (response.getMetadata() != null - && response.getMetadata().getUsage() != null - && response.getMetadata().getUsage().getCompletionTokens() > 0) { - var usage = response.getMetadata().getUsage(); - long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; - double cost = metricsRecorder.recordChunkResult(elapsedMs, usage); - totalCost.add(cost); - long thinkingTokens = - usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null - ? g.getThoughtsTokenCount() - : 0; - log.info( - "Gemini Usage - BLANK chunk #{}, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", - chunkIndex, - usage.getPromptTokens(), - thinkingTokens, - usage.getCompletionTokens(), - String.format("%.6f", cost)); - } - }) - .blockLast(java.time.Duration.ofMinutes(6)); + protected int maxSelectionCount() { + return 4; } - private String buildSystemPrompt( - String baseSystemPrompt, List accumulated, int chunkIndex) { - if (chunkIndex == 0 || accumulated.isEmpty()) return baseSystemPrompt; - - PreviousGenerationContext ctx = PreviousGenerationContext.from(accumulated); - if (ctx.isEmpty()) return baseSystemPrompt; - - String summaryJson = serializeContext(ctx); - return baseSystemPrompt - + "\n\n# 직전 청크 누적 문항 요약 (중복 회피용)\n" - + summaryJson - + "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 빈칸 핵심 어휘·맥락·정답 분포(answerIndex)가 겹치지 않게 이번 청크 문항을 작성한다." + @Override + protected String dedupInstruction() { + return "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 빈칸 핵심 어휘·맥락·정답 분포(answerIndex)가 겹치지 않게 이번 청크 문항을 작성한다." + " stemSummary와 동일·유사한 맥락은 다른 단원·다른 페이지에서 가져와 재구성하고," + " 정답 위치(answerIndex)가 직전 청크와 같은 쪽으로 쏠리지 않게 분산한다."; } - - private String serializeContext(PreviousGenerationContext ctx) { - try { - return objectMapper.writeValueAsString(ctx); - } catch (Exception e) { - log.warn("직전 컨텍스트 직렬화 실패. 컨텍스트 주입 생략.", e); - return "[]"; - } - } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java index f8bdba63..78a156d4 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java @@ -7,8 +7,9 @@ import com.icc.qasker.ai.mapper.GeminiEssayQuestionMapper; import com.icc.qasker.ai.service.QuizTypeOrchestrator; import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; -import com.icc.qasker.ai.service.support.StreamingEssayQuestionExtractor; +import com.icc.qasker.ai.service.support.StreamingJsonArrayExtractor; import com.icc.qasker.ai.strategy.QuizType; +import com.icc.qasker.ai.structure.GeminiEssayQuestion; import com.icc.qasker.ai.structure.GeminiEssayResponseSchema; import com.icc.qasker.global.error.CustomException; import java.net.URI; @@ -24,7 +25,6 @@ import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; import org.springframework.stereotype.Component; import org.springframework.util.MimeTypeUtils; import reactor.core.publisher.Flux; @@ -99,9 +99,10 @@ public int generateQuiz(GenerationRequestToAI request) { log.info("ESSAY 스트리밍 생성 시작: 목표={}문항", quizCount); // 스트리밍 파서: 문항 객체가 완성될 때마다 즉시 SSE 전달 - StreamingEssayQuestionExtractor extractor = - new StreamingEssayQuestionExtractor( + StreamingJsonArrayExtractor extractor = + new StreamingJsonArrayExtractor<>( objectMapper, + GeminiEssayQuestion.class, question -> { if (delivered.get() >= quizCount) return; @@ -119,7 +120,8 @@ public int generateQuiz(GenerationRequestToAI request) { long ttfqMs = (now - startNanos) / 1_000_000; log.info("TTFQ (Time To First Question): {}ms", ttfqMs); } - }); + }, + "ESSAY"); // 스트리밍 실행 Flux stream = chatModel.stream(prompt); @@ -135,20 +137,10 @@ public int generateQuiz(GenerationRequestToAI request) { if (response.getMetadata() != null && response.getMetadata().getUsage() != null && response.getMetadata().getUsage().getCompletionTokens() > 0) { - var usage = response.getMetadata().getUsage(); - long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; - double cost = metricsRecorder.recordChunkResult(elapsedMs, usage); + double cost = + metricsRecorder.recordChunkUsage( + "streaming", startNanos, response.getMetadata().getUsage()); totalCost.add(cost); - long thinkingTokens = - usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null - ? g.getThoughtsTokenCount() - : 0; - log.info( - "Gemini Usage - streaming, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", - usage.getPromptTokens(), - thinkingTokens, - usage.getCompletionTokens(), - String.format("%.6f", cost)); } }) .blockLast(java.time.Duration.ofMinutes(6)); diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/multiple/MultipleQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/multiple/MultipleQuizOrchestrator.java index 291cbb20..158ee32a 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/multiple/MultipleQuizOrchestrator.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/multiple/MultipleQuizOrchestrator.java @@ -1,56 +1,16 @@ package com.icc.qasker.ai.service.multiple; import com.icc.qasker.ai.GeminiFileService; -import com.icc.qasker.ai.dto.AIProblem; -import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; -import com.icc.qasker.ai.dto.GenerationRequestToAI; -import com.icc.qasker.ai.exception.GeminiInfraException; -import com.icc.qasker.ai.mapper.GeminiQuestionMapper; import com.icc.qasker.ai.properties.QAskerAiProperties; -import com.icc.qasker.ai.service.QuizTypeOrchestrator; +import com.icc.qasker.ai.service.AbstractChunkedQuizOrchestrator; import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; -import com.icc.qasker.ai.service.support.PreviousGenerationContext; -import com.icc.qasker.ai.service.support.StreamingQuestionExtractor; -import com.icc.qasker.ai.strategy.QuizType; -import com.icc.qasker.ai.structure.GeminiResponseSchema; -import com.icc.qasker.global.error.CustomException; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.DoubleAdder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.content.Media; -import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; import org.springframework.stereotype.Component; -import org.springframework.util.MimeTypeUtils; -import reactor.core.publisher.Flux; import tools.jackson.databind.ObjectMapper; -/** - * 객관식(MULTIPLE) 퀴즈 오케스트레이터. N문항 요청을 chunk-size 단위로 분할 호출하고, 청크 K(K≥2)에는 직전 누적 문항 요약을 시스템 프롬프트에 - * 주입한다. - * - *

한 청크가 실패해도 직전 청크까지의 산출물은 보존된다(PRD S3.1). - */ -@Slf4j +/** 객관식(MULTIPLE) 퀴즈 오케스트레이터. */ @Component -public class MultipleQuizOrchestrator implements QuizTypeOrchestrator { - - private static final int MAX_SELECTION_COUNT = 4; - - private final GeminiFileService geminiFileService; - private final ChatModel chatModel; - private final ObjectMapper objectMapper; - private final GeminiMetricsRecorder metricsRecorder; - private final QAskerAiProperties aiProperties; +public class MultipleQuizOrchestrator extends AbstractChunkedQuizOrchestrator { public MultipleQuizOrchestrator( GeminiFileService geminiFileService, @@ -58,11 +18,7 @@ public MultipleQuizOrchestrator( ObjectMapper objectMapper, GeminiMetricsRecorder metricsRecorder, QAskerAiProperties aiProperties) { - this.geminiFileService = geminiFileService; - this.chatModel = chatModel; - this.objectMapper = objectMapper; - this.metricsRecorder = metricsRecorder; - this.aiProperties = aiProperties; + super(geminiFileService, chatModel, objectMapper, metricsRecorder, aiProperties); } @Override @@ -71,219 +27,15 @@ public String getSupportedType() { } @Override - public int generateQuiz(GenerationRequestToAI request) { - long startNanos = System.nanoTime(); - - DoubleAdder totalCost = new DoubleAdder(); - AtomicLong firstNanos = new AtomicLong(0); - AtomicLong lastNanos = new AtomicLong(0); - AtomicInteger delivered = new AtomicInteger(0); - int quizCount = request.quizCount(); - - QuizType quizType = QuizType.valueOf(request.strategyValue()); - String baseSystemPrompt = quizType.getSystemGuideLine(request.language()); - - // PDF 업로드는 한 번만 (캐시) - String cacheKey = - geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); - FileMetadata metadata; - try { - metadata = - geminiFileService - .awaitCachedFileMetadata(cacheKey) - .orElseGet( - () -> geminiFileService.uploadPdf(request.fileUrl(), request.referencePages())); - } catch (CustomException e) { - throw e; - } catch (Exception e) { - throw new GeminiInfraException("PDF 업로드 실패", e); - } - - List chunkPlan = aiProperties.getChunk().planChunks(quizCount); - log.info( - "MULTIPLE 청크 분할: 요청={}문항, chunk-size={}, 청크={}개", - quizCount, - aiProperties.getChunk().getChunkSize(), - chunkPlan.size()); - - List accumulated = new ArrayList<>(); - int chunksDone = 0; - - try { - for (int chunkIndex = 0; chunkIndex < chunkPlan.size(); chunkIndex++) { - int chunkSize = chunkPlan.get(chunkIndex); - if (delivered.get() >= quizCount) break; - - String systemPrompt = buildSystemPrompt(baseSystemPrompt, accumulated, chunkIndex); - String userPrompt = - quizType.generateRequestPrompt( - request.referencePages(), chunkSize, request.customInstruction()); - - runChunk( - chunkIndex, - systemPrompt, - userPrompt, - metadata, - request, - quizCount, - chunkSize, - startNanos, - delivered, - firstNanos, - lastNanos, - totalCost, - accumulated); - chunksDone++; - } - } catch (CustomException e) { - // 정책: 청크 도중 비즈니스 예외가 발생해도 직전 청크 결과는 보존 - if (delivered.get() == 0) throw e; - log.warn( - "MULTIPLE 청크 도중 비즈니스 오류. {}/{} 청크, {}문항 보존.", - chunksDone, - chunkPlan.size(), - delivered.get(), - e); - } catch (Exception e) { - if (delivered.get() == 0) throw new GeminiInfraException("Gemini 인프라 장애", e); - log.warn( - "MULTIPLE 청크 도중 인프라 오류. {}/{} 청크, {}문항 보존.", - chunksDone, - chunkPlan.size(), - delivered.get(), - e); - metricsRecorder.recordStreamingTimeout("MULTIPLE"); - } - - log.info( - "MULTIPLE 청크 루프 완료: 전달={}문항(목표 {}), 청크 완료={}/{}, 총 소요={}ms", - delivered.get(), - quizCount, - chunksDone, - chunkPlan.size(), - (System.nanoTime() - startNanos) / 1_000_000); - - Long first = firstNanos.get() == 0 ? null : firstNanos.get(); - Long last = lastNanos.get() == 0 ? null : lastNanos.get(); - metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); - return chunksDone == 0 ? 1 : chunksDone; - } - - /** 한 청크의 Gemini 호출 + 스트리밍 파싱을 실행한다. */ - private void runChunk( - int chunkIndex, - String systemPrompt, - String userPrompt, - FileMetadata metadata, - GenerationRequestToAI request, - int quizCount, - int chunkSize, - long startNanos, - AtomicInteger delivered, - AtomicLong firstNanos, - AtomicLong lastNanos, - DoubleAdder totalCost, - List accumulated) { - - var pdfMedia = - new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); - var userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build(); - var systemMessage = new SystemMessage(systemPrompt); - - String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction()); - var options = - GoogleGenAiChatOptions.builder() - .responseMimeType("application/json") - .responseSchema(responseSchema) - .build(); - - Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); - log.info( - "MULTIPLE 청크 #{} 시작: 청크 목표={}문항, 누적={}/{}", - chunkIndex, - chunkSize, - delivered.get(), - quizCount); - - StreamingQuestionExtractor extractor = - new StreamingQuestionExtractor( - objectMapper, - question -> { - if (delivered.get() >= quizCount) return; - if (question.selections() != null - && question.selections().size() > MAX_SELECTION_COUNT) return; - - int count = delivered.incrementAndGet(); - var dtoSet = GeminiQuestionMapper.toDto(List.of(question), metadata.sourcePages()); - if (dtoSet.quiz() != null) { - accumulated.addAll(dtoSet.quiz()); - } - request.questionsConsumer().accept(dtoSet); - - long now = System.nanoTime(); - firstNanos.compareAndSet(0, now); - lastNanos.updateAndGet(prev -> Math.max(prev, now)); - - if (count == 1) { - long ttfqMs = (now - startNanos) / 1_000_000; - log.info("TTFQ (Time To First Question): {}ms", ttfqMs); - } - }); - - Flux stream = chatModel.stream(prompt); - stream - .doOnNext( - response -> { - if (response.getResult().getOutput().getText() != null) { - extractor.feed(response.getResult().getOutput().getText()); - } - - if (response.getMetadata().getUsage() != null - && response.getMetadata().getUsage().getCompletionTokens() > 0) { - var usage = response.getMetadata().getUsage(); - long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; - double cost = metricsRecorder.recordChunkResult(elapsedMs, usage); - totalCost.add(cost); - long thinkingTokens = - usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null - ? g.getThoughtsTokenCount() - : 0; - log.info( - "Gemini Usage - chunk #{}, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", - chunkIndex, - usage.getPromptTokens(), - thinkingTokens, - usage.getCompletionTokens(), - String.format("%.6f", cost)); - } - }) - .blockLast(java.time.Duration.ofMinutes(6)); + protected int maxSelectionCount() { + return 4; } - /** 청크 K(K≥2)의 시스템 프롬프트에 직전 누적 컨텍스트와 중복 회피 지침을 부가한다. */ - private String buildSystemPrompt( - String baseSystemPrompt, List accumulated, int chunkIndex) { - if (chunkIndex == 0 || accumulated.isEmpty()) return baseSystemPrompt; - - PreviousGenerationContext ctx = PreviousGenerationContext.from(accumulated); - if (ctx.isEmpty()) return baseSystemPrompt; - - String summaryJson = serializeContext(ctx); - return baseSystemPrompt - + "\n\n# 직전 청크 누적 문항 요약 (중복 회피용)\n" - + summaryJson - + "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 주제·표현·정답 분포(answerIndex)·Bloom's 하위 과정이" + @Override + protected String dedupInstruction() { + return "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 주제·표현·정답 분포(answerIndex)·Bloom's 하위 과정이" + " 겹치지 않게 이번 청크 문항을 작성한다." + " stemSummary와 동일·유사한 주제는 다른 각도(다른 패턴 라벨, 다른 강의노트 페이지)로 재구성하고," + " 정답 위치(answerIndex)가 직전 청크와 같은 쪽으로 쏠리지 않게 분산한다."; } - - private String serializeContext(PreviousGenerationContext ctx) { - try { - return objectMapper.writeValueAsString(ctx); - } catch (Exception e) { - log.warn("직전 컨텍스트 직렬화 실패. 컨텍스트 주입 생략.", e); - return "[]"; - } - } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/ox/OXQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/ox/OXQuizOrchestrator.java index 029c2a28..b3f6e6a9 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/ox/OXQuizOrchestrator.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/ox/OXQuizOrchestrator.java @@ -1,55 +1,16 @@ package com.icc.qasker.ai.service.ox; import com.icc.qasker.ai.GeminiFileService; -import com.icc.qasker.ai.dto.AIProblem; -import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; -import com.icc.qasker.ai.dto.GenerationRequestToAI; -import com.icc.qasker.ai.exception.GeminiInfraException; -import com.icc.qasker.ai.mapper.GeminiQuestionMapper; import com.icc.qasker.ai.properties.QAskerAiProperties; -import com.icc.qasker.ai.service.QuizTypeOrchestrator; +import com.icc.qasker.ai.service.AbstractChunkedQuizOrchestrator; import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; -import com.icc.qasker.ai.service.support.PreviousGenerationContext; -import com.icc.qasker.ai.service.support.StreamingQuestionExtractor; -import com.icc.qasker.ai.strategy.QuizType; -import com.icc.qasker.ai.structure.GeminiResponseSchema; -import com.icc.qasker.global.error.CustomException; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.DoubleAdder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.content.Media; -import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; import org.springframework.stereotype.Component; -import org.springframework.util.MimeTypeUtils; -import reactor.core.publisher.Flux; import tools.jackson.databind.ObjectMapper; -/** - * OX 퀴즈 오케스트레이터. N문항 요청을 chunk-size 단위로 분할 호출하고, 청크 K(K≥2)에는 직전 누적 문항 요약을 시스템 프롬프트에 주입한다. - * - *

한 청크가 실패해도 직전 청크까지의 산출물은 보존된다(PRD S3.1). - */ -@Slf4j +/** OX 퀴즈 오케스트레이터. */ @Component -public class OXQuizOrchestrator implements QuizTypeOrchestrator { - - private static final int MAX_SELECTION_COUNT = 2; - - private final GeminiFileService geminiFileService; - private final ChatModel chatModel; - private final ObjectMapper objectMapper; - private final GeminiMetricsRecorder metricsRecorder; - private final QAskerAiProperties aiProperties; +public class OXQuizOrchestrator extends AbstractChunkedQuizOrchestrator { public OXQuizOrchestrator( GeminiFileService geminiFileService, @@ -57,11 +18,7 @@ public OXQuizOrchestrator( ObjectMapper objectMapper, GeminiMetricsRecorder metricsRecorder, QAskerAiProperties aiProperties) { - this.geminiFileService = geminiFileService; - this.chatModel = chatModel; - this.objectMapper = objectMapper; - this.metricsRecorder = metricsRecorder; - this.aiProperties = aiProperties; + super(geminiFileService, chatModel, objectMapper, metricsRecorder, aiProperties); } @Override @@ -70,204 +27,14 @@ public String getSupportedType() { } @Override - public int generateQuiz(GenerationRequestToAI request) { - long startNanos = System.nanoTime(); - - DoubleAdder totalCost = new DoubleAdder(); - AtomicLong firstNanos = new AtomicLong(0); - AtomicLong lastNanos = new AtomicLong(0); - AtomicInteger delivered = new AtomicInteger(0); - int quizCount = request.quizCount(); - - QuizType quizType = QuizType.valueOf(request.strategyValue()); - String baseSystemPrompt = quizType.getSystemGuideLine(request.language()); - - String cacheKey = - geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); - FileMetadata metadata; - try { - metadata = - geminiFileService - .awaitCachedFileMetadata(cacheKey) - .orElseGet( - () -> geminiFileService.uploadPdf(request.fileUrl(), request.referencePages())); - } catch (CustomException e) { - throw e; - } catch (Exception e) { - throw new GeminiInfraException("PDF 업로드 실패", e); - } - - List chunkPlan = aiProperties.getChunk().planChunks(quizCount); - log.info( - "OX 청크 분할: 요청={}문항, chunk-size={}, 청크={}개", - quizCount, - aiProperties.getChunk().getChunkSize(), - chunkPlan.size()); - - List accumulated = new ArrayList<>(); - int chunksDone = 0; - - try { - for (int chunkIndex = 0; chunkIndex < chunkPlan.size(); chunkIndex++) { - int chunkSize = chunkPlan.get(chunkIndex); - if (delivered.get() >= quizCount) break; - - String systemPrompt = buildSystemPrompt(baseSystemPrompt, accumulated, chunkIndex); - String userPrompt = - quizType.generateRequestPrompt( - request.referencePages(), chunkSize, request.customInstruction()); - - runChunk( - chunkIndex, - systemPrompt, - userPrompt, - metadata, - request, - quizCount, - chunkSize, - startNanos, - delivered, - firstNanos, - lastNanos, - totalCost, - accumulated); - chunksDone++; - } - } catch (CustomException e) { - if (delivered.get() == 0) throw e; - log.warn( - "OX 청크 도중 비즈니스 오류. {}/{} 청크, {}문항 보존.", chunksDone, chunkPlan.size(), delivered.get(), e); - } catch (Exception e) { - if (delivered.get() == 0) throw new GeminiInfraException("Gemini 인프라 장애", e); - log.warn( - "OX 청크 도중 인프라 오류. {}/{} 청크, {}문항 보존.", chunksDone, chunkPlan.size(), delivered.get(), e); - metricsRecorder.recordStreamingTimeout("OX"); - } - - log.info( - "OX 청크 루프 완료: 전달={}문항(목표 {}), 청크 완료={}/{}, 총 소요={}ms", - delivered.get(), - quizCount, - chunksDone, - chunkPlan.size(), - (System.nanoTime() - startNanos) / 1_000_000); - - Long first = firstNanos.get() == 0 ? null : firstNanos.get(); - Long last = lastNanos.get() == 0 ? null : lastNanos.get(); - metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); - return chunksDone == 0 ? 1 : chunksDone; - } - - private void runChunk( - int chunkIndex, - String systemPrompt, - String userPrompt, - FileMetadata metadata, - GenerationRequestToAI request, - int quizCount, - int chunkSize, - long startNanos, - AtomicInteger delivered, - AtomicLong firstNanos, - AtomicLong lastNanos, - DoubleAdder totalCost, - List accumulated) { - - var pdfMedia = - new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); - var userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build(); - var systemMessage = new SystemMessage(systemPrompt); - - String responseSchema = GeminiResponseSchema.forInstruction(request.customInstruction()); - var options = - GoogleGenAiChatOptions.builder() - .responseMimeType("application/json") - .responseSchema(responseSchema) - .build(); - - Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); - log.info( - "OX 청크 #{} 시작: 청크 목표={}문항, 누적={}/{}", chunkIndex, chunkSize, delivered.get(), quizCount); - - StreamingQuestionExtractor extractor = - new StreamingQuestionExtractor( - objectMapper, - question -> { - if (delivered.get() >= quizCount) return; - if (question.selections() != null - && question.selections().size() > MAX_SELECTION_COUNT) return; - - int count = delivered.incrementAndGet(); - var dtoSet = GeminiQuestionMapper.toDto(List.of(question), metadata.sourcePages()); - if (dtoSet.quiz() != null) { - accumulated.addAll(dtoSet.quiz()); - } - request.questionsConsumer().accept(dtoSet); - - long now = System.nanoTime(); - firstNanos.compareAndSet(0, now); - lastNanos.updateAndGet(prev -> Math.max(prev, now)); - - if (count == 1) { - long ttfqMs = (now - startNanos) / 1_000_000; - log.info("TTFQ (Time To First Question): {}ms", ttfqMs); - } - }); - - Flux stream = chatModel.stream(prompt); - stream - .doOnNext( - response -> { - if (response.getResult() != null - && response.getResult().getOutput() != null - && response.getResult().getOutput().getText() != null) { - extractor.feed(response.getResult().getOutput().getText()); - } - - if (response.getMetadata().getUsage() != null - && response.getMetadata().getUsage().getCompletionTokens() > 0) { - var usage = response.getMetadata().getUsage(); - long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; - double cost = metricsRecorder.recordChunkResult(elapsedMs, usage); - totalCost.add(cost); - long thinkingTokens = - usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null - ? g.getThoughtsTokenCount() - : 0; - log.info( - "Gemini Usage - OX chunk #{}, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", - chunkIndex, - usage.getPromptTokens(), - thinkingTokens, - usage.getCompletionTokens(), - String.format("%.6f", cost)); - } - }) - .blockLast(java.time.Duration.ofMinutes(6)); + protected int maxSelectionCount() { + return 2; } - private String buildSystemPrompt( - String baseSystemPrompt, List accumulated, int chunkIndex) { - if (chunkIndex == 0 || accumulated.isEmpty()) return baseSystemPrompt; - - PreviousGenerationContext ctx = PreviousGenerationContext.from(accumulated); - if (ctx.isEmpty()) return baseSystemPrompt; - - String summaryJson = serializeContext(ctx); - return baseSystemPrompt - + "\n\n# 직전 청크 누적 문항 요약 (중복 회피용)\n" - + summaryJson - + "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 주제·표현·정답(O/X 분포)이 겹치지 않게 이번 청크 문항을 작성한다." + @Override + protected String dedupInstruction() { + return "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 주제·표현·정답(O/X 분포)이 겹치지 않게 이번 청크 문항을 작성한다." + " stemSummary와 동일·유사한 진술은 다른 각도(다른 강의노트 페이지, 다른 개념 차원)로 재구성하고," + " 정답(answerIndex)이 직전 청크와 한쪽으로 쏠리지 않게 분산한다."; } - - private String serializeContext(PreviousGenerationContext ctx) { - try { - return objectMapper.writeValueAsString(ctx); - } catch (Exception e) { - log.warn("직전 컨텍스트 직렬화 실패. 컨텍스트 주입 생략.", e); - return "[]"; - } - } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiCacheService.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiCacheService.java deleted file mode 100644 index b25566cd..00000000 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiCacheService.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.icc.qasker.ai.service.support; - -import com.google.genai.types.Content; -import com.google.genai.types.FileData; -import com.google.genai.types.Part; -import com.icc.qasker.ai.strategy.QuizType; -import com.icc.qasker.global.error.CustomException; -import com.icc.qasker.global.error.ExceptionMessage; -import java.time.Duration; -import java.util.List; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.google.genai.cache.CachedContentRequest; -import org.springframework.ai.google.genai.cache.GoogleGenAiCachedContent; -import org.springframework.ai.google.genai.cache.GoogleGenAiCachedContentService; -import org.springframework.ai.model.google.genai.autoconfigure.chat.GoogleGenAiChatProperties; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -public class GeminiCacheService { - - public record CacheInfo(String name, long tokenCount) {} - - private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); - - private final GoogleGenAiCachedContentService cachedContentService; - private final String model; - - public GeminiCacheService( - GoogleGenAiChatProperties properties, GoogleGenAiCachedContentService cachedContentService) { - this.model = properties.getOptions().getModel(); - this.cachedContentService = cachedContentService; - } - - public CacheInfo createCache(String fileUri, String strategyValue, String language) { - try { - Content pdfContent = - Content.builder() - .role("user") - .parts( - Part.builder() - .fileData( - FileData.builder().fileUri(fileUri).mimeType("application/pdf").build()) - .build()) - .build(); - - QuizType strategy = QuizType.valueOf(strategyValue); - String systemPrompt = strategy.getSystemGuideLine(language); - Content systemInstruction = - Content.builder().parts(List.of(Part.builder().text(systemPrompt).build())).build(); - - CachedContentRequest request = - CachedContentRequest.builder() - .model(model) - .systemInstruction(systemInstruction) - .contents(List.of(pdfContent)) - .ttl(DEFAULT_TTL) - .build(); - - GoogleGenAiCachedContent cache = cachedContentService.create(request); - String cacheName = cache.getName(); - long tokenCount = - cache.getUsageMetadata() != null && cache.getUsageMetadata().totalTokenCount().isPresent() - ? cache.getUsageMetadata().totalTokenCount().get() - : 0; - - log.info( - "캐시 생성 완료: name={}, model={}, ttl={}, expireTime={}, tokenCount={}", - cacheName, - cache.getModel(), - cache.getTtl(), - cache.getExpireTime(), - tokenCount); - - return new CacheInfo(cacheName, tokenCount); - } catch (Exception e) { - throw new CustomException( - ExceptionMessage.AI_SERVER_RESPONSE_ERROR, "캐시 생성 실패: fileUri=" + fileUri, e); - } - } - - /** - * PDF 파일만 캐시에 등록한다 (시스템 프롬프트 제외). 3-chunk 병렬 생성에서 각 청크가 서로 다른 시스템 프롬프트를 사용하되 동일 PDF를 공유할 때 활용한다. - */ - public CacheInfo createPdfOnlyCache(String fileUri) { - try { - Content pdfContent = - Content.builder() - .role("user") - .parts( - Part.builder() - .fileData( - FileData.builder().fileUri(fileUri).mimeType("application/pdf").build()) - .build()) - .build(); - - CachedContentRequest request = - CachedContentRequest.builder() - .model(model) - .contents(List.of(pdfContent)) - .ttl(Duration.ofMinutes(10)) - .build(); - - GoogleGenAiCachedContent cache = cachedContentService.create(request); - String cacheName = cache.getName(); - long tokenCount = - cache.getUsageMetadata() != null && cache.getUsageMetadata().totalTokenCount().isPresent() - ? cache.getUsageMetadata().totalTokenCount().get() - : 0; - - log.info("PDF 전용 캐시 생성 완료: name={}, tokenCount={}", cacheName, tokenCount); - return new CacheInfo(cacheName, tokenCount); - } catch (Exception e) { - throw new CustomException( - ExceptionMessage.AI_SERVER_RESPONSE_ERROR, "PDF 캐시 생성 실패: fileUri=" + fileUri, e); - } - } - - public void deleteCache(String cacheName) { - if (cacheName == null) { - return; - } - try { - boolean deleted = cachedContentService.delete(cacheName); - if (deleted) { - log.info("캐시 삭제 완료: name={}", cacheName); - } else { - log.warn("[Gemini 캐시 삭제 실패] 캐시 삭제 실패 name={}", cacheName); - } - } catch (Exception e) { - log.warn("[Gemini 캐시 삭제 오류] 캐시 삭제 중 예외 발생 name={}", cacheName, e); - } - } -} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiChatService.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiChatService.java deleted file mode 100644 index 60db64c9..00000000 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiChatService.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.icc.qasker.ai.service.support; - -import com.icc.qasker.ai.dto.ChunkInfo; -import com.icc.qasker.ai.strategy.QuizType; -import com.icc.qasker.ai.structure.GeminiResponse; -import com.icc.qasker.ai.structure.GeminiResponseSchema; -import java.util.List; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel; -import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; -import org.springframework.stereotype.Component; -import tools.jackson.databind.ObjectMapper; - -/** - * Gemini ChatModel 호출, JSON 응답 파싱, 메트릭 기록을 담당한다. QuizOrchestrationServiceImpl에서 Chat 관심사를 분리하여 - * 오케스트레이터가 흐름 제어에만 집중할 수 있게 한다. - */ -@Slf4j -@Component -public class GeminiChatService { - - /** 청크 처리 결과: 파싱된 응답 + 추정 비용 */ - public record ParsedResult(GeminiResponse response, double cost) {} - - private final ChatModel chatModel; - private final ObjectMapper objectMapper; - private final GeminiMetricsRecorder metricsRecorder; - - public GeminiChatService( - ChatModel chatModel, ObjectMapper objectMapper, GeminiMetricsRecorder metricsRecorder) { - this.chatModel = chatModel; - this.objectMapper = objectMapper; - this.metricsRecorder = metricsRecorder; - } - - /** - * 캐시된 컨텍스트를 사용하여 Gemini API를 호출하고, 파싱된 결과를 반환한다. - * - * @param chunk 청크 정보 (참조 페이지, 문제 수) - * @param cacheName Gemini Cached Content 이름 - * @param strategyValue 퀴즈 타입 (MULTIPLE, OX, BLANK) - * @param planExtra 문항 계획 결과 (nullable, MULTIPLE만 사용) - * @return 파싱 결과 (응답 + 비용), 응답이 비어있으면 null - */ - public ParsedResult callAndParse( - ChunkInfo chunk, String cacheName, String strategyValue, String language, String planExtra) - throws Exception { - return callAndParse(chunk, cacheName, strategyValue, language, planExtra, null); - } - - /** - * thinking-level을 오버라이드하여 Gemini API를 호출한다. Gemini 3 모델은 thinkingBudget 대신 thinkingLevel을 사용한다. - * - * @param thinkingLevel thinking 수준 (null이면 yml 기본값 사용) - */ - public ParsedResult callAndParse( - ChunkInfo chunk, - String cacheName, - String strategyValue, - String language, - String planExtra, - GoogleGenAiThinkingLevel thinkingLevel) - throws Exception { - long startMs = System.currentTimeMillis(); - List pages = chunk.referencedPages(); - QuizType quizType = QuizType.valueOf(strategyValue); - - String userPrompt = quizType.generateRequestPrompt(pages, chunk.quizCount(), planExtra); - - String responseSchema = GeminiResponseSchema.forInstruction(planExtra); - var optionsBuilder = - GoogleGenAiChatOptions.builder() - .useCachedContent(true) - .cachedContentName(cacheName) - .responseMimeType("application/json") - .responseSchema(responseSchema); - if (thinkingLevel != null) { - optionsBuilder.thinkingLevel(thinkingLevel); - } - var options = optionsBuilder.build(); - log.debug( - "Gemini 옵션 - model={}, thinkingLevel={}, thinkingBudget={}, temperature={}", - options.getModel(), - options.getThinkingLevel(), - options.getThinkingBudget(), - options.getTemperature()); - Prompt prompt = new Prompt(userPrompt, options); - - ChatResponse chatResponse = chatModel.call(prompt); - long elapsedMs = System.currentTimeMillis() - startMs; - - // Prometheus 메트릭 기록 + 비용 추정 - double chunkCost = 0.0; - if (chatResponse.getMetadata().getUsage() != null) { - var usage = chatResponse.getMetadata().getUsage(); - chunkCost = metricsRecorder.recordChunkResult(elapsedMs, usage); - long thinkingTokens = - usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null - ? g.getThoughtsTokenCount() - : 0; - log.info( - "Gemini Usage - pages={}, {}ms, 토큰: 입력={}(캐시: {}), 추론={}, 출력={}, 추정 비용: ${}", - pages, - elapsedMs, - usage.getPromptTokens(), - usage instanceof GoogleGenAiUsage g2 - ? g2.getCachedContentTokenCount() - : Integer.valueOf(0), - thinkingTokens, - usage.getCompletionTokens(), - String.format("%.6f", chunkCost)); - } - - String json = chatResponse.getResult().getOutput().getText(); - if (json == null || json.isBlank()) { - log.error("[AI 응답 오류] Gemini 응답이 비어있음 pages={}", pages); - return new ParsedResult(null, chunkCost); - } - - return new ParsedResult(objectMapper.readValue(json.trim(), GeminiResponse.class), chunkCost); - } -} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java index c0240a7b..96793e14 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java @@ -5,6 +5,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; import org.springframework.beans.factory.annotation.Value; @@ -14,6 +15,7 @@ * Gemini API 호출에 대한 Prometheus 메트릭을 통합 기록한다. 비즈니스 로직(QuizOrchestrationServiceImpl)에서 메트릭 관심사를 분리하기 * 위한 Delegation 패턴. 모든 메트릭은 eager 등록 — Prometheus가 첫 스크랩에서 0을 관측하여 increase()가 첫 요청부터 정확히 동작한다. */ +@Slf4j @Component public class GeminiMetricsRecorder { @@ -27,14 +29,6 @@ public class GeminiMetricsRecorder { private final Counter tokensCached; private final Counter tokensThinking; private final Counter tokensOutput; - private final Counter selectionEqualization; - private final Counter selectionChecked; - private final Counter eqTokensInput; - private final Counter eqTokensOutput; - private final Counter eqCost; - private final Counter planTokensInput; - private final Counter planTokensOutput; - private final Counter planCost; private final Timer gradingDuration; private final Counter gradingTokensInput; private final Counter gradingTokensOutput; @@ -54,36 +48,6 @@ public GeminiMetricsRecorder( this.priceInputPer1M = priceInputPer1M; this.priceCacheReadPer1M = priceCacheReadPer1M; this.priceOutputPer1M = priceOutputPer1M; - this.selectionEqualization = - Counter.builder("gemini.selection.equalization") - .description("선택지 길이 균등화 실행 횟수") - .register(registry); - this.selectionChecked = - Counter.builder("gemini.selection.checked") - .description("선택지 균등화 검사 대상 문항 수") - .register(registry); - this.eqTokensInput = - Counter.builder("gemini.equalization.tokens.input") - .description("균등화 API 입력 토큰") - .register(registry); - this.eqTokensOutput = - Counter.builder("gemini.equalization.tokens.output") - .description("균등화 API 출력 토큰") - .register(registry); - this.eqCost = - Counter.builder("gemini.equalization.cost") - .description("균등화 API 추정 비용 (USD)") - .register(registry); - this.planTokensInput = - Counter.builder("gemini.plan.tokens.input") - .description("문항 계획 API 입력 토큰") - .register(registry); - this.planTokensOutput = - Counter.builder("gemini.plan.tokens.output") - .description("문항 계획 API 출력 토큰") - .register(registry); - this.planCost = - Counter.builder("gemini.plan.cost").description("문항 계획 API 추정 비용 (USD)").register(registry); this.gradingDuration = Timer.builder("gemini.grading.duration") .description("ESSAY 채점 API 응답 시간") @@ -188,24 +152,29 @@ public double recordChunkResult(long elapsedMs, Usage usage) { return totalCost; } - /** 선택지 균등화 검사 대상 문항 수를 기록한다. */ - public void incrementSelectionChecked(int count) { - selectionChecked.increment(count); - } - - /** 문항 계획 실행 + 토큰/비용을 기록한다. */ - public void recordPlan(long inputTokens, long outputTokens, double cost) { - planTokensInput.increment(inputTokens); - planTokensOutput.increment(outputTokens); - planCost.increment(cost); - } - - /** 선택지 길이 균등화 실행 + 토큰/비용을 기록한다. */ - public void recordEqualization(long inputTokens, long outputTokens, double cost) { - selectionEqualization.increment(); - eqTokensInput.increment(inputTokens); - eqTokensOutput.increment(outputTokens); - eqCost.increment(cost); + /** + * 청크 usage를 기록하고 토큰/비용을 로깅한 뒤 추정 비용을 반환한다. 오케스트레이터의 doOnNext usage 로깅 중복을 흡수한다. + * + * @param label 로그 식별 라벨 (예: "MULTIPLE chunk #0", "streaming") + * @param startNanos 요청 시작 시각 (System.nanoTime) + * @param usage ChatResponse에서 추출한 Usage 메타데이터 + * @return 추정 비용 (USD) + */ + public double recordChunkUsage(String label, long startNanos, Usage usage) { + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + double cost = recordChunkResult(elapsedMs, usage); + long thinkingTokens = + usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null + ? g.getThoughtsTokenCount() + : 0; + log.info( + "Gemini Usage - {}, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", + label, + usage.getPromptTokens(), + thinkingTokens, + usage.getCompletionTokens(), + String.format("%.6f", cost)); + return cost; } /** ESSAY 채점 완료 메트릭을 기록한다. */ diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java deleted file mode 100644 index e7195872..00000000 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.icc.qasker.ai.service.support; - -import com.icc.qasker.ai.structure.GeminiEssayQuestion; -import java.util.function.Consumer; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import tools.jackson.databind.ObjectMapper; - -/** - * 스트리밍 JSON 응답에서 GeminiEssayQuestion 객체가 완성될 때마다 콜백을 호출한다. - * - *

Gemini 응답 형식: {"questions": [{Q1}, {Q2}, ...]} - * - *

questions 배열 내부의 각 객체({...})가 완성되면 즉시 파싱하여 consumer에 전달한다. - */ -@Slf4j -public class StreamingEssayQuestionExtractor { - - private final ObjectMapper objectMapper; - private final Consumer questionConsumer; - - private final StringBuilder buffer = new StringBuilder(); - private boolean inArray = false; - private int braceDepth = 0; - private int objectStart = -1; - private boolean inString = false; - private boolean escaped = false; - - @Getter private int questionCount = 0; - - public StreamingEssayQuestionExtractor( - ObjectMapper objectMapper, Consumer questionConsumer) { - this.objectMapper = objectMapper; - this.questionConsumer = questionConsumer; - } - - public void feed(String chunk) { - if (chunk == null) return; - - for (int i = 0; i < chunk.length(); i++) { - char c = chunk.charAt(i); - buffer.append(c); - - if (escaped) { - escaped = false; - continue; - } - if (c == '\\' && inString) { - escaped = true; - continue; - } - if (c == '"') { - inString = !inString; - continue; - } - if (inString) continue; - - if (c == '[' && !inArray) { - inArray = true; - continue; - } - - if (!inArray) continue; - - if (c == '{') { - if (braceDepth == 0) { - objectStart = buffer.length() - 1; - } - braceDepth++; - } else if (c == '}') { - braceDepth--; - if (braceDepth == 0 && objectStart >= 0) { - String objectJson = buffer.substring(objectStart, buffer.length()); - emitQuestion(objectJson); - objectStart = -1; - } - } - } - } - - private void emitQuestion(String json) { - GeminiEssayQuestion question; - try { - question = objectMapper.readValue(json, GeminiEssayQuestion.class); - } catch (Exception e) { - log.warn( - "[ESSAY JSON 파싱 실패] 문항 JSON 역직렬화 실패 jsonLength={} jsonPreview={}", - json.length(), - json.length() > 300 ? json.substring(0, 300) : json, - e); - return; - } - try { - questionCount++; - questionConsumer.accept(question); - } catch (Exception e) { - log.warn("[ESSAY 문항 처리 실패] 문항 소비자 호출 중 오류 발생", e); - } - } -} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractor.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingJsonArrayExtractor.java similarity index 55% rename from modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractor.java rename to modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingJsonArrayExtractor.java index 02ff4d3f..adf3b74b 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractor.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingJsonArrayExtractor.java @@ -1,24 +1,26 @@ package com.icc.qasker.ai.service.support; -import com.icc.qasker.ai.structure.GeminiQuestion; import java.util.function.Consumer; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import tools.jackson.databind.ObjectMapper; /** - * 스트리밍 JSON 응답에서 GeminiQuestion 객체가 완성될 때마다 콜백을 호출한다. + * 스트리밍 JSON 응답에서 배열 원소 객체가 완성될 때마다 콜백을 호출하는 제네릭 추출기. * *

Gemini 응답 형식: {"questions": [{Q1}, {Q2}, ...]} * - *

questions 배열 내부의 각 객체({...})가 완성되면 즉시 파싱하여 consumer에 전달한다. 문자열 내부의 중괄호를 무시하기 위해 JSON 문자열 - * 이스케이프를 추적한다. + *

배열 내부의 각 객체({...})가 완성되면 즉시 파싱하여 consumer에 전달한다. 문자열 내부의 중괄호를 무시하기 위해 JSON 문자열 이스케이프를 추적한다. + * + * @param 배열 원소 타입 (예: GeminiQuestion, GeminiEssayQuestion) */ @Slf4j -public class StreamingQuestionExtractor { +public class StreamingJsonArrayExtractor { private final ObjectMapper objectMapper; - private final Consumer questionConsumer; + private final Class elementType; + private final Consumer elementConsumer; + private final String logTag; private final StringBuilder buffer = new StringBuilder(); private boolean inArray = false; @@ -27,16 +29,18 @@ public class StreamingQuestionExtractor { private boolean inString = false; private boolean escaped = false; - /** -- GETTER -- 추출된 문항 수를 반환한다. */ + /** -- GETTER -- 추출된 원소 수를 반환한다. */ @Getter private int questionCount = 0; - public StreamingQuestionExtractor( - ObjectMapper objectMapper, Consumer questionConsumer) { + public StreamingJsonArrayExtractor( + ObjectMapper objectMapper, Class elementType, Consumer elementConsumer, String logTag) { this.objectMapper = objectMapper; - this.questionConsumer = questionConsumer; + this.elementType = elementType; + this.elementConsumer = elementConsumer; + this.logTag = logTag; } - /** 스트리밍 텍스트 청크를 받아 처리한다. 문항 객체가 완성되면 consumer가 호출된다. */ + /** 스트리밍 텍스트 청크를 받아 처리한다. 원소 객체가 완성되면 consumer가 호출된다. */ public void feed(String chunk) { if (chunk == null) return; @@ -59,7 +63,7 @@ public void feed(String chunk) { } if (inString) continue; - // questions 배열 시작 감지 + // 배열 시작 감지 if (c == '[' && !inArray) { inArray = true; continue; @@ -67,7 +71,7 @@ public void feed(String chunk) { if (!inArray) continue; - // 문항 객체 추적 + // 원소 객체 추적 if (c == '{') { if (braceDepth == 0) { objectStart = buffer.length() - 1; @@ -77,20 +81,21 @@ public void feed(String chunk) { braceDepth--; if (braceDepth == 0 && objectStart >= 0) { String objectJson = buffer.substring(objectStart, buffer.length()); - emitQuestion(objectJson); + emitElement(objectJson); objectStart = -1; } } } } - private void emitQuestion(String json) { - GeminiQuestion question; + private void emitElement(String json) { + T element; try { - question = objectMapper.readValue(json, GeminiQuestion.class); + element = objectMapper.readValue(json, elementType); } catch (Exception e) { log.warn( - "[JSON 파싱 실패] 문항 JSON 역직렬화 실패 jsonLength={} jsonPreview={}", + "[{} JSON 파싱 실패] 문항 JSON 역직렬화 실패 jsonLength={} jsonPreview={}", + logTag, json.length(), json.length() > 300 ? json.substring(0, 300) : json, e); @@ -98,9 +103,9 @@ private void emitQuestion(String json) { } try { questionCount++; - questionConsumer.accept(question); + elementConsumer.accept(element); } catch (Exception e) { - log.warn("[문항 처리 실패] 문항 소비자 호출 중 오류 발생", e); + log.warn("[{} 문항 처리 실패] 문항 소비자 호출 중 오류 발생", logTag, e); } } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizPromptStrategy.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizPromptStrategy.java index 887406ab..7da6466f 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizPromptStrategy.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizPromptStrategy.java @@ -6,12 +6,6 @@ public interface QuizPromptStrategy { String getSystemGuideLine(String language); - /** 청크별 유저 프롬프트를 생성한다. 퀴즈 타입에 따라 난이도·정답 배분 등이 달라진다. */ - String generateRequestPrompt(List referencePages, int quizCount); - - /** 문항 계획 결과를 포함한 유저 프롬프트를 생성한다. 기본 구현은 planExtra를 무시한다. */ - default String generateRequestPrompt( - List referencePages, int quizCount, String planExtra) { - return generateRequestPrompt(referencePages, quizCount); - } + /** 청크별 유저 프롬프트를 생성한다. 퀴즈 타입에 따라 난이도·정답 배분 등이 달라진다. planExtra(사용자 지시/계획)를 반영한다. */ + String generateRequestPrompt(List referencePages, int quizCount, String planExtra); } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java index cbce16a0..cd1beb78 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java @@ -17,11 +17,6 @@ @RequiredArgsConstructor public enum QuizType implements QuizPromptStrategy { MULTIPLE(MultipleGuideLine.content) { - @Override - public String generateRequestPrompt(List referencePages, int quizCount) { - return MultipleRequestPrompt.generate(referencePages, quizCount); - } - @Override public String generateRequestPrompt( List referencePages, int quizCount, String planExtra) { @@ -29,11 +24,6 @@ public String generateRequestPrompt( } }, BLANK(BlankGuideLine.content) { - @Override - public String generateRequestPrompt(List referencePages, int quizCount) { - return BlankRequestPrompt.generate(referencePages, quizCount); - } - @Override public String generateRequestPrompt( List referencePages, int quizCount, String planExtra) { @@ -41,11 +31,6 @@ public String generateRequestPrompt( } }, OX(OXGuideLine.content) { - @Override - public String generateRequestPrompt(List referencePages, int quizCount) { - return OXRequestPrompt.generate(referencePages, quizCount); - } - @Override public String generateRequestPrompt( List referencePages, int quizCount, String planExtra) { @@ -54,11 +39,6 @@ public String generateRequestPrompt( } }, ESSAY(EssayGuideLine.content) { - @Override - public String generateRequestPrompt(List referencePages, int quizCount) { - return EssayRequestPrompt.generate(referencePages, quizCount); - } - @Override public String generateRequestPrompt( List referencePages, int quizCount, String planExtra) { diff --git a/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/ChunkedQuizOrchestratorContractTest.java b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/ChunkedQuizOrchestratorContractTest.java new file mode 100644 index 00000000..e2231869 --- /dev/null +++ b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/ChunkedQuizOrchestratorContractTest.java @@ -0,0 +1,236 @@ +package com.icc.qasker.ai.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.ai.GeminiFileService; +import com.icc.qasker.ai.dto.AIProblemSet; +import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; +import com.icc.qasker.ai.dto.GenerationRequestToAI; +import com.icc.qasker.ai.exception.GeminiInfraException; +import com.icc.qasker.ai.properties.QAskerAiProperties; +import com.icc.qasker.ai.service.blank.BlankQuizOrchestrator; +import com.icc.qasker.ai.service.multiple.MultipleQuizOrchestrator; +import com.icc.qasker.ai.service.ox.OXQuizOrchestrator; +import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; +import com.icc.qasker.ai.strategy.QuizType; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; +import tools.jackson.databind.ObjectMapper; + +/** + * 청크형 오케스트레이터(MULTIPLE/BLANK/OX) 공통 동작 회귀 테스트 (문서 검증 테스트 1~3). + * + *

제안 1(템플릿 메서드 베이스) 리팩터링 전후로 동작이 보존됨을 보장한다. + */ +class ChunkedQuizOrchestratorContractTest { + + private GeminiFileService fileService; + private ChatModel chatModel; + private ObjectMapper objectMapper; + private GeminiMetricsRecorder metricsRecorder; + private QAskerAiProperties aiProperties; + + @BeforeEach + void setUp() { + fileService = mock(GeminiFileService.class); + chatModel = mock(ChatModel.class); + objectMapper = new ObjectMapper(); + metricsRecorder = mock(GeminiMetricsRecorder.class); + aiProperties = new QAskerAiProperties(); + + FileMetadata meta = + new FileMetadata(null, null, null, null, null, null, null, "gs://b/x.pdf", null); + when(fileService.generateCacheKey(any(), any())).thenReturn("k"); + when(fileService.awaitCachedFileMetadata("k")).thenReturn(Optional.of(meta)); + when(metricsRecorder.recordChunkResult(anyLong(), any())).thenReturn(0.0); + } + + private QuizTypeOrchestrator orchestrator(String type) { + return switch (type) { + case "MULTIPLE" -> + new MultipleQuizOrchestrator( + fileService, chatModel, objectMapper, metricsRecorder, aiProperties); + case "BLANK" -> + new BlankQuizOrchestrator( + fileService, chatModel, objectMapper, metricsRecorder, aiProperties); + case "OX" -> + new OXQuizOrchestrator( + fileService, chatModel, objectMapper, metricsRecorder, aiProperties); + default -> throw new IllegalArgumentException(type); + }; + } + + private int maxSelection(String type) { + return "OX".equals(type) ? 2 : 4; + } + + private GenerationRequestToAI request( + String type, int quizCount, Consumer consumer) { + return GenerationRequestToAI.builder() + .fileUrl("http://f/x.pdf") + .strategyValue(type) + .language("KO") + .quizCount(quizCount) + .referencePages(List.of(1, 2)) + .questionsConsumer(consumer) + .build(); + } + + /** selCount개 선택지를 가진 문항 JSON. 첫 선택지가 정답. */ + private String question(int idx, int selCount) { + String selections = + IntStream.range(0, selCount) + .mapToObj( + s -> + "{\"content\":\"s" + + s + + "\",\"correct\":" + + (s == 0) + + ",\"explanation\":\"e\"}") + .collect(Collectors.joining(",")); + return "{\"content\":\"Q" + + idx + + "\",\"bloomsLevel\":\"Remember\",\"referencedPages\":[1],\"selections\":[" + + selections + + "]}"; + } + + private String questionsJson(List selCounts) { + String body = + IntStream.range(0, selCounts.size()) + .mapToObj(i -> question(i, selCounts.get(i))) + .collect(Collectors.joining(",")); + return "{\"questions\":[" + body + "]}"; + } + + private ChatResponse resp(String text) { + return new ChatResponse( + List.of(new Generation(new AssistantMessage(text))), + ChatResponseMetadata.builder().build()); + } + + private Flux fluxOf(String json) { + return Flux.just(resp(json)); + } + + private List deliveredContents(List sets) { + return sets.stream().map(s -> s.quiz().get(0).content()).toList(); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void deliversExactlyQuizCount_inOrder(String type) { + List collected = new ArrayList<>(); + when(chatModel.stream(any(Prompt.class))).thenReturn(fluxOf(questionsJson(List.of(1, 1, 1)))); + + orchestrator(type).generateQuiz(request(type, 3, collected::add)); + + assertThat(deliveredContents(collected)).containsExactly("Q0", "Q1", "Q2"); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void truncatesWhenChunkOverProduces(String type) { + List collected = new ArrayList<>(); + when(chatModel.stream(any(Prompt.class))) + .thenReturn(fluxOf(questionsJson(List.of(1, 1, 1, 1)))); + + // quizCount=2 이지만 4문항 방출 → 2개로 잘림 + orchestrator(type).generateQuiz(request(type, 2, collected::add)); + + assertThat(deliveredContents(collected)).containsExactly("Q0", "Q1"); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void dropsQuestionsExceedingMaxSelectionCount(String type) { + List collected = new ArrayList<>(); + int oversize = maxSelection(type) + 1; + // idx0은 초과 선택지 → drop. idx1, idx2만 전달. + when(chatModel.stream(any(Prompt.class))) + .thenReturn(fluxOf(questionsJson(List.of(oversize, 1, 1)))); + + orchestrator(type).generateQuiz(request(type, 3, collected::add)); + + assertThat(deliveredContents(collected)).containsExactly("Q1", "Q2"); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void preservesEarlierChunk_whenLaterChunkFails(String type) { + aiProperties.getChunk().setChunkSize(1); // quizCount=2 → 청크 2개 + List collected = new ArrayList<>(); + when(chatModel.stream(any(Prompt.class))) + .thenReturn(fluxOf(questionsJson(List.of(1))), Flux.error(new RuntimeException("boom"))); + + int chunksDone = orchestrator(type).generateQuiz(request(type, 2, collected::add)); + + assertThat(deliveredContents(collected)).containsExactly("Q0"); + assertThat(chunksDone).isEqualTo(1); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void rethrows_whenNothingDelivered(String type) { + List collected = new ArrayList<>(); + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.error(new RuntimeException("boom"))); + + assertThatThrownBy(() -> orchestrator(type).generateQuiz(request(type, 1, collected::add))) + .isInstanceOf(GeminiInfraException.class); + assertThat(collected).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"MULTIPLE", "BLANK", "OX"}) + void injectsPreviousContextOnSecondChunkOnly(String type) { + aiProperties.getChunk().setChunkSize(1); // quizCount=2 → 청크 2개 + List collected = new ArrayList<>(); + when(chatModel.stream(any(Prompt.class))) + .thenReturn(fluxOf(questionsJson(List.of(1))), fluxOf(questionsJson(List.of(1)))); + + orchestrator(type).generateQuiz(request(type, 2, collected::add)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel, times(2)).stream(captor.capture()); + List prompts = captor.getAllValues(); + + String base = QuizType.valueOf(type).getSystemGuideLine("KO"); + String firstSystem = prompts.get(0).getInstructions().get(0).getText(); + String secondSystem = prompts.get(1).getInstructions().get(0).getText(); + + assertThat(firstSystem).isEqualTo(base); + assertThat(secondSystem).contains("직전 청크 누적 문항 요약"); + assertThat(secondSystem).contains(typeSpecificDedupMarker(type)); + } + + private String typeSpecificDedupMarker(String type) { + return switch (type) { + case "MULTIPLE" -> "다른 패턴 라벨"; + case "BLANK" -> "빈칸 핵심 어휘"; + case "OX" -> "O/X 분포"; + default -> throw new IllegalArgumentException(type); + }; + } +} diff --git a/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiChatServiceSmokeTest.java b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiChatServiceSmokeTest.java deleted file mode 100644 index e5b74d92..00000000 --- a/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiChatServiceSmokeTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.icc.qasker.ai.service.support; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.icc.qasker.ai.dto.ChunkInfo; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.metadata.ChatResponseMetadata; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.prompt.Prompt; -import tools.jackson.databind.ObjectMapper; - -/** - * 버전업 세이프티 넷 (RT-005): Spring AI 1.1.x → 2.0.0 승격이 GeminiChatService.callAndParse()의 - * chatModel.call() → chatResponse.getResult().getOutput().getText() → JSON 파싱 사슬을 유지하는지 확인한다. - */ -class GeminiChatServiceSmokeTest { - - private ChatModel chatModel; - private ObjectMapper objectMapper; - private GeminiMetricsRecorder metricsRecorder; - private GeminiChatService service; - - @BeforeEach - void setUp() { - chatModel = mock(ChatModel.class); - objectMapper = new ObjectMapper(); - metricsRecorder = mock(GeminiMetricsRecorder.class); - service = new GeminiChatService(chatModel, objectMapper, metricsRecorder); - } - - @Test - void callAndParse_returnsParsedResponse_whenChatModelReturnsValidJson() throws Exception { - String validJson = "{\"questions\":[]}"; - ChatResponse mockResponse = - new ChatResponse( - List.of(new Generation(new AssistantMessage(validJson))), - ChatResponseMetadata.builder().build()); - when(chatModel.call(any(Prompt.class))).thenReturn(mockResponse); - - ChunkInfo chunk = new ChunkInfo(List.of(1, 2), 5); - GeminiChatService.ParsedResult result = - service.callAndParse(chunk, "cache-x", "MULTIPLE", "ko", null); - - assertThat(result).isNotNull(); - assertThat(result.response()).isNotNull(); - assertThat(result.response().questions()).isEmpty(); - verify(chatModel).call(any(Prompt.class)); - } - - @Test - void callAndParse_returnsNullResponse_whenChatModelReturnsBlankText() throws Exception { - ChatResponse mockResponse = - new ChatResponse( - List.of(new Generation(new AssistantMessage(""))), - ChatResponseMetadata.builder().build()); - when(chatModel.call(any(Prompt.class))).thenReturn(mockResponse); - - ChunkInfo chunk = new ChunkInfo(List.of(1), 3); - GeminiChatService.ParsedResult result = - service.callAndParse(chunk, "cache-x", "OX", "ko", null); - - assertThat(result.response()).isNull(); - } -} diff --git a/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorderTest.java b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorderTest.java new file mode 100644 index 00000000..9a53e860 --- /dev/null +++ b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorderTest.java @@ -0,0 +1,50 @@ +package com.icc.qasker.ai.service.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.icc.qasker.ai.properties.QAskerAiProperties; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; + +/** + * 비용 계산 골든 회귀 테스트 (문서 검증 테스트 5). 제안 4·5 리팩터링 후에도 cached/thinking 토큰 분리 단가가 보존됨을 보장한다. + * + *

단가: input=0.5/1M, cache-read=0.05/1M, output=3.0/1M. + */ +class GeminiMetricsRecorderTest { + + private GeminiMetricsRecorder recorder() { + return new GeminiMetricsRecorder( + new SimpleMeterRegistry(), new QAskerAiProperties(), 0.5, 0.05, 3.0); + } + + @Test + void recordChunkResult_plainUsage_appliesInputAndOutputPricing() { + Usage usage = mock(Usage.class); + when(usage.getPromptTokens()).thenReturn(1_000_000); + when(usage.getCompletionTokens()).thenReturn(1_000_000); + + double cost = recorder().recordChunkResult(100L, usage); + + // input 1M * 0.5 + output 1M * 3.0 = 3.5 + assertThat(cost).isEqualTo(3.5); + } + + @Test + void recordChunkResult_googleUsage_separatesCachedAndThinkingPricing() { + GoogleGenAiUsage usage = mock(GoogleGenAiUsage.class); + when(usage.getPromptTokens()).thenReturn(2_000_000); + when(usage.getCompletionTokens()).thenReturn(1_000_000); + when(usage.getCachedContentTokenCount()).thenReturn(1_000_000); + when(usage.getThoughtsTokenCount()).thenReturn(500_000); + + double cost = recorder().recordChunkResult(100L, usage); + + // nonCached input 1M*0.5=0.5, cache 1M*0.05=0.05, output 1M*3.0=3.0, thinking 0.5M*3.0=1.5 + assertThat(cost).isEqualTo(5.05); + } +} diff --git a/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractorTest.java b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractorTest.java new file mode 100644 index 00000000..961dca2a --- /dev/null +++ b/modules/quiz-ai/impl/src/test/java/com/icc/qasker/ai/service/support/StreamingQuestionExtractorTest.java @@ -0,0 +1,80 @@ +package com.icc.qasker.ai.service.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.icc.qasker.ai.structure.GeminiQuestion; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * 스트리밍 JSON 배열 추출기의 상태머신 회귀 테스트 (문서 검증 테스트 4). + * + *

제안 2(제네릭 통합) 전후로 파싱 동작이 동일함을 보장한다. + */ +class StreamingQuestionExtractorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private List collect(String... chunks) { + List out = new ArrayList<>(); + StreamingJsonArrayExtractor extractor = + new StreamingJsonArrayExtractor<>(objectMapper, GeminiQuestion.class, out::add, "MULTIPLE"); + for (String c : chunks) { + extractor.feed(c); + } + return out; + } + + @Test + void emitsQuestionsInOrder() { + String json = + "{\"questions\":[{\"content\":\"Q1\",\"bloomsLevel\":\"Remember\",\"referencedPages\":[1]," + + "\"selections\":[{\"content\":\"a\",\"correct\":true,\"explanation\":\"e\"}]}," + + "{\"content\":\"Q2\",\"bloomsLevel\":\"Apply\",\"referencedPages\":[2]," + + "\"selections\":[{\"content\":\"b\",\"correct\":false,\"explanation\":\"e\"}]}]}"; + + List result = collect(json); + + assertThat(result).extracting(GeminiQuestion::content).containsExactly("Q1", "Q2"); + } + + @Test + void ignoresBracesAndEscapesInsideStrings() { + // content 문자열 내부에 중괄호와 이스케이프된 따옴표/백슬래시가 포함 + String json = + "{\"questions\":[{\"content\":\"he said \\\"hi\\\" {x} \\\\ end\"," + + "\"bloomsLevel\":\"Remember\",\"referencedPages\":[1]," + + "\"selections\":[{\"content\":\"a\",\"correct\":true,\"explanation\":\"e\"}]}]}"; + + List result = collect(json); + + assertThat(result).hasSize(1); + assertThat(result.get(0).content()).isEqualTo("he said \"hi\" {x} \\ end"); + } + + @Test + void handlesChunkBoundarySplittingObjectMidway() { + String full = + "{\"questions\":[{\"content\":\"Q1\",\"bloomsLevel\":\"Remember\",\"referencedPages\":[1]," + + "\"selections\":[{\"content\":\"a\",\"correct\":true,\"explanation\":\"e\"}]}]}"; + int mid = full.length() / 2; + + List result = collect(full.substring(0, mid), full.substring(mid)); + + assertThat(result).extracting(GeminiQuestion::content).containsExactly("Q1"); + } + + @Test + void ignoresTextOutsideArray() { + // 배열 밖 텍스트/공백은 무시되고 배열 내부 객체만 방출 + String json = + "prefix noise {\"questions\": [ {\"content\":\"Q1\",\"bloomsLevel\":\"Remember\"," + + "\"referencedPages\":[1],\"selections\":[]} ] } trailing"; + + List result = collect(json); + + assertThat(result).extracting(GeminiQuestion::content).containsExactly("Q1"); + } +} From 01bc7bde76b9e5a9af13a63fe3bd688b3b3178db Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:31:48 +0900 Subject: [PATCH 09/10] =?UTF-8?q?ICC-389=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20quiz-history=20=EB=AA=A8=EB=93=88=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 매핑 로직(객관식/서술형/채점결과)을 서비스 인라인 람다 → QuizHistoryMapper로 이관 (QueryService 261→165줄) - loadOwnedHistory/loadProblemSet 헬퍼 + AnswerSnapshotView 값 객체 추출로 스냅샷→Map 변환 중복 제거 - 회귀 테스트 8개 선작성 (정오답 판정·스냅샷 매핑·삭제 제외·소유자 검증·서술형 채점) Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/quiz-history/impl/build.gradle | 7 + .../entity/AnswerSnapshotView.java | 48 ++++ .../quizhistory/mapper/QuizHistoryMapper.java | 78 ++++++ .../service/QuizHistoryQueryServiceImpl.java | 142 ++-------- .../service/EssayGradeServiceTest.java | 82 ++++++ .../QuizHistoryQueryServiceImplTest.java | 264 ++++++++++++++++++ 6 files changed, 502 insertions(+), 119 deletions(-) create mode 100644 modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshotView.java create mode 100644 modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/EssayGradeServiceTest.java create mode 100644 modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImplTest.java diff --git a/modules/quiz-history/impl/build.gradle b/modules/quiz-history/impl/build.gradle index 0dca8300..deaf74f2 100644 --- a/modules/quiz-history/impl/build.gradle +++ b/modules/quiz-history/impl/build.gradle @@ -29,4 +29,11 @@ dependencies { // ──────────────────────────────── // SpringDoc OpenAPI: API 문서화 어노테이션(@Operation, @Schema) + Swagger UI 제공 implementation libs.springdoc.openapi.starter.webmvc.ui + + // ──────────────────────────────── + // 테스트 + // ──────────────────────────────── + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation platform(libs.junit.bom) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshotView.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshotView.java new file mode 100644 index 00000000..d1d907c2 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshotView.java @@ -0,0 +1,48 @@ +package com.icc.qasker.quizhistory.entity; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** AnswerSnapshot 목록을 문항 번호 기준 조회용으로 감싼 값 객체. 상세/서술형 상세 매핑에서 반복되던 스냅샷 → Map 변환을 한 곳으로 모은다. */ +public final class AnswerSnapshotView { + + private final Map userAnswers; + private final Map inReviews; + private final Map textAnswers; + + private AnswerSnapshotView( + Map userAnswers, + Map inReviews, + Map textAnswers) { + this.userAnswers = userAnswers; + this.inReviews = inReviews; + this.textAnswers = textAnswers; + } + + public static AnswerSnapshotView from(List answers) { + Map userAnswers = + answers.stream() + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::userAnswer)); + Map inReviews = + answers.stream() + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::inReview)); + Map textAnswers = + answers.stream() + .filter(a -> a.textAnswer() != null) + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::textAnswer)); + return new AnswerSnapshotView(userAnswers, inReviews, textAnswers); + } + + public int userAnswer(int number) { + return userAnswers.getOrDefault(number, 0); + } + + public boolean inReview(int number) { + return inReviews.getOrDefault(number, false); + } + + public String textAnswer(int number) { + return textAnswers.get(number); + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java index 0162528a..adf57005 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java @@ -1,9 +1,21 @@ package com.icc.qasker.quizhistory.mapper; import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.ElementScoreDetail; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssayProblemWithGrade; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssaySelection; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.GradeResult; import com.icc.qasker.quizhistory.dto.feresponse.HistorySummaryResponse; +import com.icc.qasker.quizhistory.dto.feresponse.ProblemWithAnswer; +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; +import com.icc.qasker.quizhistory.entity.EssayGradeLog; import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizset.dto.feresponse.Selection; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.util.List; +import java.util.stream.IntStream; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -32,4 +44,70 @@ public HistorySummaryResponse toSummary(QuizHistory history, ProblemSetSummary p history.getScore(), history.getCreatedAt()); } + + /** ProblemDetail + 답안 스냅샷 → ProblemWithAnswer(객관식 상세) 변환. 정답 인덱스와 사용자 답을 비교해 정오답을 판정한다. */ + public ProblemWithAnswer toProblemWithAnswer(ProblemDetail problem, AnswerSnapshotView answers) { + List rawSelections = problem.selections(); + int correctIndex = findCorrectIndex(rawSelections); + int userAnswer = answers.userAnswer(problem.number()); + boolean correct = userAnswer == correctIndex; + List selections = + IntStream.range(0, rawSelections.size()) + .mapToObj( + i -> + new Selection( + i + 1, rawSelections.get(i).content(), rawSelections.get(i).correct())) + .toList(); + return new ProblemWithAnswer( + problem.number(), + problem.title(), + userAnswer, + correct, + answers.inReview(problem.number()), + selections, + answers.textAnswer(problem.number())); + } + + /** ProblemDetail + 답안 스냅샷 + 최신 채점 로그 → EssayProblemWithGrade(서술형 상세) 변환. */ + public EssayProblemWithGrade toEssayProblemWithGrade( + ProblemDetail problem, AnswerSnapshotView answers, EssayGradeLog gradeLog) { + List selections = + IntStream.range(0, problem.selections().size()) + .mapToObj(i -> new EssaySelection(i + 1, problem.selections().get(i).content())) + .toList(); + return new EssayProblemWithGrade( + problem.number(), + problem.title(), + answers.textAnswer(problem.number()), + answers.inReview(problem.number()), + selections, + toGradeResult(gradeLog)); + } + + private GradeResult toGradeResult(EssayGradeLog gradeLog) { + if (gradeLog == null) { + return null; + } + List elementScores = + gradeLog.getElementScores().stream() + .map( + e -> + new ElementScoreDetail( + e.element(), e.maxPoints(), e.earnedPoints(), e.level(), e.feedback())) + .toList(); + return new GradeResult( + gradeLog.getTotalScore(), + gradeLog.getMaxScore(), + gradeLog.getOverallFeedback(), + elementScores); + } + + private int findCorrectIndex(List selections) { + for (int i = 0; i < selections.size(); i++) { + if (selections.get(i).correct()) { + return i + 1; + } + } + return -1; + } } diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java index 513b17a0..ec3a6eb7 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java @@ -5,32 +5,26 @@ import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.quizhistory.QuizHistoryQueryService; import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse; -import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.ElementScoreDetail; import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssayProblemWithGrade; -import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssaySelection; -import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.GradeResult; import com.icc.qasker.quizhistory.dto.feresponse.HistoryCheckResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryPageResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistorySummaryResponse; import com.icc.qasker.quizhistory.dto.feresponse.ProblemWithAnswer; -import com.icc.qasker.quizhistory.entity.AnswerSnapshot; +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; import com.icc.qasker.quizhistory.entity.EssayGradeLog; import com.icc.qasker.quizhistory.entity.QuizHistory; import com.icc.qasker.quizhistory.mapper.QuizHistoryMapper; import com.icc.qasker.quizhistory.repository.EssayGradeLogRepository; import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; import com.icc.qasker.quizset.ProblemSetReadService; -import com.icc.qasker.quizset.dto.feresponse.Selection; import com.icc.qasker.quizset.dto.readonly.ProblemDetail; import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; -import com.icc.qasker.quizset.dto.readonly.SelectionDetail; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -94,58 +88,14 @@ public HistoryPageResponse getHistoryList(String userId, int page, int size) { @Override public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { - long id = hashUtil.decode(historyId); - QuizHistory history = - quizHistoryRepository - .findById(id) - .filter(h -> h.getUserId().equals(userId)) - .orElseThrow(() -> new CustomException(ExceptionMessage.QUIZ_HISTORY_NOT_FOUND)); - + QuizHistory history = loadOwnedHistory(userId, historyId); Long problemSetId = history.getProblemSetId(); - ProblemSetSummary problemSet = - problemSetReadService - .findProblemSetById(problemSetId) - .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); - + ProblemSetSummary problemSet = loadProblemSet(problemSetId); List problems = problemSetReadService.findProblemsByProblemSetId(problemSetId); - Map answerMap = - history.getAnswers().stream() - .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::userAnswer)); - - Map inReviewMap = - history.getAnswers().stream() - .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::inReview)); - - Map textAnswerMap = - history.getAnswers().stream() - .filter(a -> a.textAnswer() != null) - .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::textAnswer)); - + AnswerSnapshotView answers = AnswerSnapshotView.from(history.getAnswers()); List problemWithAnswers = - problems.stream() - .map( - p -> { - boolean inReview = inReviewMap.getOrDefault(p.number(), false); - int userAnswer = answerMap.getOrDefault(p.number(), 0); - List rawSelections = p.selections(); - int correctIndex = findCorrectIndex(rawSelections); - boolean correct = userAnswer == correctIndex; - List selections = - IntStream.range(0, rawSelections.size()) - .mapToObj( - i -> - new Selection( - i + 1, - rawSelections.get(i).content(), - rawSelections.get(i).correct())) - .toList(); - - String textAnswer = textAnswerMap.get(p.number()); - return new ProblemWithAnswer( - p.number(), p.title(), userAnswer, correct, inReview, selections, textAnswer); - }) - .toList(); + problems.stream().map(p -> quizHistoryMapper.toProblemWithAnswer(p, answers)).toList(); return new HistoryDetailResponse( hashUtil.encode(history.getId()), @@ -160,29 +110,12 @@ public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { @Override public EssayHistoryDetailResponse getEssayHistoryDetail(String userId, String historyId) { - long id = hashUtil.decode(historyId); - QuizHistory history = - quizHistoryRepository - .findById(id) - .filter(h -> h.getUserId().equals(userId)) - .orElseThrow(() -> new CustomException(ExceptionMessage.QUIZ_HISTORY_NOT_FOUND)); - + QuizHistory history = loadOwnedHistory(userId, historyId); Long problemSetId = history.getProblemSetId(); - ProblemSetSummary problemSet = - problemSetReadService - .findProblemSetById(problemSetId) - .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); - + ProblemSetSummary problemSet = loadProblemSet(problemSetId); List problems = problemSetReadService.findProblemsByProblemSetId(problemSetId); - // 답안 스냅샷 매핑 - Map textAnswerMap = - history.getAnswers().stream() - .filter(a -> a.textAnswer() != null) - .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::textAnswer)); - Map inReviewMap = - history.getAnswers().stream() - .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::inReview)); + AnswerSnapshotView answers = AnswerSnapshotView.from(history.getAnswers()); // 문제별 최신 채점 결과 조회 Map gradeLogMap = @@ -192,25 +125,9 @@ public EssayHistoryDetailResponse getEssayHistoryDetail(String userId, String hi List essayProblems = problems.stream() .map( - p -> { - // selections (id, content만 노출) - List selections = - IntStream.range(0, p.selections().size()) - .mapToObj(i -> new EssaySelection(i + 1, p.selections().get(i).content())) - .toList(); - - // 채점 결과 변환 - EssayGradeLog gradeLog = gradeLogMap.get(p.number()); - GradeResult gradeResult = toGradeResult(gradeLog); - - return new EssayProblemWithGrade( - p.number(), - p.title(), - textAnswerMap.get(p.number()), - inReviewMap.getOrDefault(p.number(), false), - selections, - gradeResult); - }) + p -> + quizHistoryMapper.toEssayProblemWithGrade( + p, answers, gradeLogMap.get(p.number()))) .toList(); return new EssayHistoryDetailResponse( @@ -223,24 +140,6 @@ public EssayHistoryDetailResponse getEssayHistoryDetail(String userId, String hi essayProblems); } - private GradeResult toGradeResult(EssayGradeLog gradeLog) { - if (gradeLog == null) { - return null; - } - List elementScores = - gradeLog.getElementScores().stream() - .map( - e -> - new ElementScoreDetail( - e.element(), e.maxPoints(), e.earnedPoints(), e.level(), e.feedback())) - .toList(); - return new GradeResult( - gradeLog.getTotalScore(), - gradeLog.getMaxScore(), - gradeLog.getOverallFeedback(), - elementScores); - } - @Override public HistoryCheckResponse checkHistory(String userId, String problemSetId) { long id = hashUtil.decode(problemSetId); @@ -250,12 +149,17 @@ public HistoryCheckResponse checkHistory(String userId, String problemSetId) { .orElse(new HistoryCheckResponse(false, null, null)); } - private int findCorrectIndex(List selections) { - for (int i = 0; i < selections.size(); i++) { - if (selections.get(i).correct()) { - return i + 1; - } - } - return -1; + private QuizHistory loadOwnedHistory(String userId, String historyId) { + long id = hashUtil.decode(historyId); + return quizHistoryRepository + .findById(id) + .filter(h -> h.getUserId().equals(userId)) + .orElseThrow(() -> new CustomException(ExceptionMessage.QUIZ_HISTORY_NOT_FOUND)); + } + + private ProblemSetSummary loadProblemSet(Long problemSetId) { + return problemSetReadService + .findProblemSetById(problemSetId) + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); } } diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/EssayGradeServiceTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/EssayGradeServiceTest.java new file mode 100644 index 00000000..7ea603d7 --- /dev/null +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/EssayGradeServiceTest.java @@ -0,0 +1,82 @@ +package com.icc.qasker.quizhistory.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.ai.EssayGradingService; +import com.icc.qasker.ai.dto.EssayGradingResult; +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.quizhistory.entity.EssayGradeLog; +import com.icc.qasker.quizhistory.repository.EssayGradeLogRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** EssayGradeService 회귀 세이프티 넷. 데이터 정합성 실패 예외와 정상 채점 시 비동기 로그 저장 트리거를 고정한다. */ +@ExtendWith(MockitoExtension.class) +class EssayGradeServiceTest { + + @Mock private ProblemSetReadService problemSetReadService; + @Mock private EssayGradingService essayGradingService; + @Mock private EssayGradeLogRepository essayGradeLogRepository; + @Mock private HashUtil hashUtil; + + @InjectMocks private EssayGradeService service; + + private ProblemDetail problem(List selections, String explanation) { + return new ProblemDetail(1, "essay-title", selections, explanation); + } + + @Test + @DisplayName("grade: 모범답안(정답 selection)이 없으면 예외") + void grade_missingModelAnswer_throws() { + when(hashUtil.decode("ps")).thenReturn(1L); + when(problemSetReadService.findProblemsByProblemSetId(1L)) + .thenReturn(List.of(problem(List.of(new SelectionDetail("only", false)), "rubric"))); + + assertThatThrownBy(() -> service.grade("user1", "ps", 1, "answer", 1)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("grade: 루브릭(explanationContent)이 비어 있으면 예외") + void grade_blankRubric_throws() { + when(hashUtil.decode("ps")).thenReturn(1L); + when(problemSetReadService.findProblemsByProblemSetId(1L)) + .thenReturn(List.of(problem(List.of(new SelectionDetail("model", true)), " "))); + + assertThatThrownBy(() -> service.grade("user1", "ps", 1, "answer", 1)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("grade: 정상 경로에서 결과 반환 + 비동기 채점 로그 저장 트리거") + void grade_success_returnsResultAndSavesLog() { + when(hashUtil.decode("ps")).thenReturn(1L); + when(problemSetReadService.findProblemsByProblemSetId(1L)) + .thenReturn(List.of(problem(List.of(new SelectionDetail("model", true)), "rubric"))); + EssayGradingResult result = new EssayGradingResult(List.of(), 7, 10, "feedback", "{}"); + when(essayGradingService.grade( + eq("essay-title"), eq("model"), eq("rubric"), eq("student"), anyInt())) + .thenReturn(result); + + EssayGradingResult returned = service.grade("user1", "ps", 1, "student", 2); + + assertThat(returned).isSameAs(result); + verify(essayGradeLogRepository, timeout(2000)).save(any(EssayGradeLog.class)); + } +} diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImplTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImplTest.java new file mode 100644 index 00000000..113fd9bc --- /dev/null +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImplTest.java @@ -0,0 +1,264 @@ +package com.icc.qasker.quizhistory.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.icc.qasker.ai.dto.EssayGradingResult; +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssayProblemWithGrade; +import com.icc.qasker.quizhistory.dto.feresponse.HistoryDetailResponse; +import com.icc.qasker.quizhistory.dto.feresponse.HistoryPageResponse; +import com.icc.qasker.quizhistory.dto.feresponse.ProblemWithAnswer; +import com.icc.qasker.quizhistory.entity.AnswerSnapshot; +import com.icc.qasker.quizhistory.entity.EssayGradeLog; +import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.mapper.QuizHistoryMapper; +import com.icc.qasker.quizhistory.repository.EssayGradeLogRepository; +import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +/** + * QuizHistoryQueryServiceImpl 회귀 세이프티 넷. 상세/서술형 상세 조회의 매핑·정오답 판정·소유권 검증 동작을 고정한다. 매핑 로직이 서비스에서 + * QuizHistoryMapper로 이관되더라도 공개 API 출력이 동일해야 한다. + */ +@ExtendWith(MockitoExtension.class) +class QuizHistoryQueryServiceImplTest { + + @Mock private QuizHistoryRepository quizHistoryRepository; + @Mock private EssayGradeLogRepository essayGradeLogRepository; + @Mock private ProblemSetReadService problemSetReadService; + @Mock private HashUtil hashUtil; + + private QuizHistoryQueryServiceImpl service; + + @BeforeEach + void setUp() throws Exception { + // QuizHistoryMapper는 private 생성자를 가지므로 리플렉션으로 실 인스턴스를 만들어 실제 매핑 로직을 검증한다. + Constructor ctor = + QuizHistoryMapper.class.getDeclaredConstructor(HashUtil.class); + ctor.setAccessible(true); + QuizHistoryMapper mapper = ctor.newInstance(hashUtil); + + service = + new QuizHistoryQueryServiceImpl( + quizHistoryRepository, + essayGradeLogRepository, + problemSetReadService, + hashUtil, + mapper); + + lenient().when(hashUtil.encode(anyLong())).thenReturn("ENC"); + } + + private ProblemSetSummary summary(long id, QuizType type, int count) { + return new ProblemSetSummary(id, type, count, "title-" + id, null); + } + + @Test + @DisplayName("getHistoryDetail: 정답/오답 판정 + inReview/textAnswer 매핑 + 스냅샷 없는 문항 기본값") + void getHistoryDetail_mapsAnswersAndCorrectness() { + String userId = "user1"; + QuizHistory history = + QuizHistory.builder() + .id(10L) + .userId(userId) + .problemSetId(100L) + .title("t") + .score(50) + .totalTime("60") + .answers(List.of(new AnswerSnapshot(1, 2, true, "myText"))) + .build(); + when(hashUtil.decode("h10")).thenReturn(10L); + when(quizHistoryRepository.findById(10L)).thenReturn(Optional.of(history)); + when(problemSetReadService.findProblemSetById(100L)) + .thenReturn(Optional.of(summary(100L, QuizType.MULTIPLE, 2))); + + // P1: 정답 인덱스 2 (b), 사용자 답 2 → 정답. P2: 정답 인덱스 1, 스냅샷 없음 → 기본값 0 → 오답 + ProblemDetail p1 = + new ProblemDetail( + 1, + "problem-1", + List.of( + new SelectionDetail("a", false), + new SelectionDetail("b", true), + new SelectionDetail("c", false)), + "expl-1"); + ProblemDetail p2 = + new ProblemDetail( + 2, + "problem-2", + List.of(new SelectionDetail("x", true), new SelectionDetail("y", false)), + "expl-2"); + when(problemSetReadService.findProblemsByProblemSetId(100L)).thenReturn(List.of(p1, p2)); + + HistoryDetailResponse response = service.getHistoryDetail(userId, "h10"); + + assertThat(response.problems()).hasSize(2); + ProblemWithAnswer first = response.problems().get(0); + assertThat(first.number()).isEqualTo(1); + assertThat(first.userAnswer()).isEqualTo(2); + assertThat(first.correct()).isTrue(); + assertThat(first.inReview()).isTrue(); + assertThat(first.textAnswer()).isEqualTo("myText"); + assertThat(first.selections()).hasSize(3); + assertThat(first.selections().get(0).id()).isEqualTo(1); + assertThat(first.selections().get(1).id()).isEqualTo(2); + assertThat(first.selections().get(1).correct()).isTrue(); + + ProblemWithAnswer second = response.problems().get(1); + assertThat(second.userAnswer()).isEqualTo(0); + assertThat(second.correct()).isFalse(); + assertThat(second.inReview()).isFalse(); + assertThat(second.textAnswer()).isNull(); + } + + @Test + @DisplayName("getHistoryDetail: 정답 없는 selections는 correctIndex -1 → 어떤 답도 오답") + void getHistoryDetail_noCorrectSelection_alwaysIncorrect() { + String userId = "user1"; + QuizHistory history = + QuizHistory.builder() + .id(11L) + .userId(userId) + .problemSetId(101L) + .score(0) + .answers(List.of(new AnswerSnapshot(1, 1, false, null))) + .build(); + when(hashUtil.decode("h11")).thenReturn(11L); + when(quizHistoryRepository.findById(11L)).thenReturn(Optional.of(history)); + when(problemSetReadService.findProblemSetById(101L)) + .thenReturn(Optional.of(summary(101L, QuizType.MULTIPLE, 1))); + ProblemDetail p1 = + new ProblemDetail( + 1, + "problem-1", + List.of(new SelectionDetail("a", false), new SelectionDetail("b", false)), + "expl"); + when(problemSetReadService.findProblemsByProblemSetId(101L)).thenReturn(List.of(p1)); + + HistoryDetailResponse response = service.getHistoryDetail(userId, "h11"); + + assertThat(response.problems().get(0).correct()).isFalse(); + } + + @Test + @DisplayName("getEssayHistoryDetail: 최신 채점 로그 매핑 + 로그 없는 문항 gradeResult null") + void getEssayHistoryDetail_mapsGradeLogs() { + String userId = "user1"; + QuizHistory history = + QuizHistory.builder() + .id(20L) + .userId(userId) + .problemSetId(200L) + .totalTime("120") + .answers( + List.of( + new AnswerSnapshot(1, 0, true, "answer-1"), + new AnswerSnapshot(2, 0, false, "answer-2"))) + .build(); + when(hashUtil.decode("h20")).thenReturn(20L); + when(quizHistoryRepository.findById(20L)).thenReturn(Optional.of(history)); + when(problemSetReadService.findProblemSetById(200L)) + .thenReturn(Optional.of(summary(200L, QuizType.ESSAY, 2))); + ProblemDetail p1 = + new ProblemDetail(1, "essay-1", List.of(new SelectionDetail("model-1", true)), "rubric-1"); + ProblemDetail p2 = + new ProblemDetail(2, "essay-2", List.of(new SelectionDetail("model-2", true)), "rubric-2"); + when(problemSetReadService.findProblemsByProblemSetId(200L)).thenReturn(List.of(p1, p2)); + + EssayGradeLog log = + EssayGradeLog.builder() + .userId(userId) + .problemSetId(200L) + .problemNumber(1) + .question("essay-1") + .studentAnswer("answer-1") + .attemptCount(1) + .totalScore(8) + .maxScore(10) + .overallFeedback("good") + .elementScores( + List.of(new EssayGradingResult.ElementScore("정확성", 5, 4, "상", "well done"))) + .build(); + when(essayGradeLogRepository.findLatestByUserIdAndProblemSetId(userId, 200L)) + .thenReturn(List.of(log)); + + EssayHistoryDetailResponse response = service.getEssayHistoryDetail(userId, "h20"); + + assertThat(response.problems()).hasSize(2); + EssayProblemWithGrade first = response.problems().get(0); + assertThat(first.textAnswer()).isEqualTo("answer-1"); + assertThat(first.inReview()).isTrue(); + assertThat(first.selections()).hasSize(1); + assertThat(first.selections().get(0).id()).isEqualTo(1); + assertThat(first.gradeResult()).isNotNull(); + assertThat(first.gradeResult().totalScore()).isEqualTo(8); + assertThat(first.gradeResult().maxScore()).isEqualTo(10); + assertThat(first.gradeResult().overallFeedback()).isEqualTo("good"); + assertThat(first.gradeResult().elementScores()).hasSize(1); + assertThat(first.gradeResult().elementScores().get(0).element()).isEqualTo("정확성"); + assertThat(first.gradeResult().elementScores().get(0).earnedPoints()).isEqualTo(4); + + EssayProblemWithGrade second = response.problems().get(1); + assertThat(second.gradeResult()).isNull(); + assertThat(second.inReview()).isFalse(); + } + + @Test + @DisplayName("getHistoryList: 문제세트가 삭제되어 조회되지 않는 히스토리는 결과에서 제외") + void getHistoryList_excludesHistoryWithMissingProblemSet() { + String userId = "user1"; + QuizHistory h1 = + QuizHistory.builder().id(1L).userId(userId).problemSetId(100L).title("kept").build(); + QuizHistory h2 = + QuizHistory.builder().id(2L).userId(userId).problemSetId(200L).title("dropped").build(); + Pageable pageable = Pageable.ofSize(10).withPage(0); + Page page = new PageImpl<>(List.of(h1, h2), pageable, 2); + when(quizHistoryRepository.findAllByUserIdOrderByCreatedAtDesc(eq(userId), eq(pageable))) + .thenReturn(page); + // 100번 문제세트만 살아있고 200번은 삭제됨 + when(problemSetReadService.findProblemSetsByIds(List.of(100L, 200L))) + .thenReturn(List.of(summary(100L, QuizType.MULTIPLE, 3))); + + HistoryPageResponse response = service.getHistoryList(userId, 0, 10); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("kept"); + } + + @Test + @DisplayName("getHistoryDetail: 타 사용자의 히스토리는 QUIZ_HISTORY_NOT_FOUND") + void getHistoryDetail_otherUser_throwsNotFound() { + QuizHistory history = QuizHistory.builder().id(30L).userId("owner").problemSetId(300L).build(); + when(hashUtil.decode("h30")).thenReturn(30L); + when(quizHistoryRepository.findById(30L)).thenReturn(Optional.of(history)); + + assertThatThrownBy(() -> service.getHistoryDetail("intruder", "h30")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getMessage()) + .isEqualTo(ExceptionMessage.QUIZ_HISTORY_NOT_FOUND.getMessage()); + } +} From fa61f529543bc9be4360a3f2293f7697037562d3 Mon Sep 17 00:00:00 2001 From: Oh YoungJe <139232765+GulSauce@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:32:00 +0900 Subject: [PATCH 10/10] =?UTF-8?q?ICC-390=20=E2=99=BB=EF=B8=8F=20refactor:?= =?UTF-8?q?=20quiz-make=20=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processGenerationAsync의 84줄 God 람다 → GenerationBatchConsumer 클래스로 추출 (CommandService 258→166줄) - 선택지 배치(MULTIPLE/BLANK/REAL_BLANK 셔플 + OX 정규화) → SelectionArranger 유틸로 전략화(OCP) - SSE 전송 4중 보일러플레이트(get→null체크→send→catch) → safeSend() 헬퍼 통합 - 회귀 테스트 16개 선작성 (배치 순번/SSE eventId/셔플 보존/OX 정규화/finalize 3분기/IOException 처리) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../quizmake/mapper/SelectionArranger.java | 61 ++++++ .../generation/GenerationBatchConsumer.java | 135 +++++++++++++ .../GenerationCommandServiceImpl.java | 126 ++----------- .../SseNotificationServiceImpl.java | 59 +++--- .../GenerationCommandServiceImplTest.java | 178 ++++++++++++++++++ .../SseNotificationServiceImplTest.java | 88 +++++++++ 6 files changed, 507 insertions(+), 140 deletions(-) create mode 100644 modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/SelectionArranger.java create mode 100644 modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java create mode 100644 modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImplTest.java diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/SelectionArranger.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/SelectionArranger.java new file mode 100644 index 00000000..7ebe680e --- /dev/null +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/SelectionArranger.java @@ -0,0 +1,61 @@ +package com.icc.qasker.quizmake.mapper; + +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.CollectionUtils; + +/** + * 퀴즈 타입별 선택지 배치 규칙을 캡슐화한다. + * + *

    + *
  • MULTIPLE / BLANK / REAL_BLANK: 선택지 전체 셔플 + *
  • OX: X 계열이 1번이면 순서를 바꿔 O가 항상 1번이 되도록 정규화 + *
  • 그 외 타입: 원본 순서 유지 + *
+ */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class SelectionArranger { + + public static void arrange(QuizType quizType, List quizzes) { + if (quizType == QuizType.MULTIPLE + || quizType == QuizType.BLANK + || quizType == QuizType.REAL_BLANK) { + shuffleAll(quizzes); + } else if (quizType == QuizType.OX) { + normalizeOxOrder(quizzes); + } + } + + private static void shuffleAll(List quizzes) { + for (var quiz : quizzes) { + if (!CollectionUtils.isEmpty(quiz.getSelections())) { + List shuffled = new ArrayList<>(quiz.getSelections()); + Collections.shuffle(shuffled); + quiz.setSelections(shuffled); + } + } + } + + private static void normalizeOxOrder(List quizzes) { + // OX 선택지 정규화: X 계열이 1번이면 순서 변경 → O가 항상 1번 + for (var quiz : quizzes) { + var sels = quiz.getSelections(); + if (sels != null + && sels.size() == 2 + && sels.get(0).getContent() != null + && sels.get(0).getContent().matches("(?i)^x$")) { + + List swapped = new ArrayList<>(2); + swapped.add(sels.get(1)); + swapped.add(sels.get(0)); + quiz.setSelections(swapped); + } + } + } +} diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java new file mode 100644 index 00000000..3680274b --- /dev/null +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java @@ -0,0 +1,135 @@ +package com.icc.qasker.quizmake.service.generation; + +import static com.icc.qasker.quizset.GenerationStatus.GENERATING; + +import com.icc.qasker.ai.dto.AIProblemSet; +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.quizmake.SseNotificationService; +import com.icc.qasker.quizmake.dto.ferequest.GenerationRequest; +import com.icc.qasker.quizmake.mapper.AIProblemSetMapper; +import com.icc.qasker.quizmake.mapper.ExplanationMarkdownBuilder; +import com.icc.qasker.quizmake.mapper.SelectionArranger; +import com.icc.qasker.quizset.QuizCommandService; +import com.icc.qasker.quizset.QuizQueryService; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; +import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; +import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse.QuizForFe; +import com.icc.qasker.quizset.view.QuizView; +import com.icc.qasker.quizset.view.QuizViewToQuizForFeMapper; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.CollectionUtils; + +/** + * AI가 스트리밍으로 전달하는 배치(문제 묶음) 1건을 처리하는 소비자. + * + *

변환 → 선택지 배치 → 마크다운 조립 → 순차 번호 부여 → 영속화 → SSE 전송 → 타이밍/카운터 누적을 담당한다. 여러 배치가 동시에 도착해도 순서와 번호가 + * 어긋나지 않도록 {@link ReentrantLock}으로 직렬화한다. + */ +@Slf4j +class GenerationBatchConsumer implements Consumer { + + private final String sessionId; + private final Long problemSetId; + private final GenerationRequest request; + private final QuizCommandService quizCommandService; + private final QuizQueryService quizQueryService; + private final SseNotificationService notificationService; + private final HashUtil hashUtil; + + private final AtomicInteger generatedCount = new AtomicInteger(0); + private final AtomicInteger numberCounter = new AtomicInteger(1); + private final AtomicLong firstConsumerNanos = new AtomicLong(0); + private final AtomicLong lastConsumerNanos = new AtomicLong(0); + private final ReentrantLock consumerLock = new ReentrantLock(); + + GenerationBatchConsumer( + String sessionId, + Long problemSetId, + GenerationRequest request, + QuizCommandService quizCommandService, + QuizQueryService quizQueryService, + SseNotificationService notificationService, + HashUtil hashUtil) { + this.sessionId = sessionId; + this.problemSetId = problemSetId; + this.request = request; + this.quizCommandService = quizCommandService; + this.quizQueryService = quizQueryService; + this.notificationService = notificationService; + this.hashUtil = hashUtil; + } + + @Override + public void accept(AIProblemSet aiProblemSet) { + consumerLock.lock(); + try { + // 1. 전송용 DTO로 변환 + ProblemSetGeneratedEvent problemSet = AIProblemSetMapper.toEvent(aiProblemSet); + if (CollectionUtils.isEmpty(problemSet.getQuiz())) { + log.warn("[AI 생성 스킵] 빈 배치 수신 sessionId={}", sessionId); + return; + } + + // 2. 선택지 배치 (셔플 / OX 정규화) + SelectionArranger.arrange(request.quizType(), problemSet.getQuiz()); + + // 3. 마크다운 포맷팅 + for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { + quiz.setExplanation(ExplanationMarkdownBuilder.build(quiz, request.language())); + } + + // 4. 순서 보장 번호 할당 + for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { + quiz.setNumber(numberCounter.getAndIncrement()); + } + + // 5. 데이터베이스에 영속화 + List savedNumbers = quizCommandService.saveBatch(problemSet.getQuiz(), problemSetId); + + // 6. SSE 전송 + List quizViews = quizQueryService.getQuizViews(problemSetId, savedNumbers); + if (quizViews.isEmpty()) { + return; + } + + List quizForFeList = + quizViews.stream().map(QuizViewToQuizForFeMapper::toQuizForFe).toList(); + + notificationService.sendCreatedMessageWithId( + sessionId, + String.valueOf(quizViews.getLast().getNumber()), + new ProblemSetResponse( + sessionId, + hashUtil.encode(problemSetId), + request.title(), + GENERATING, + request.quizType(), + request.quizCount(), + quizForFeList)); + + generatedCount.addAndGet(quizViews.size()); + firstConsumerNanos.compareAndSet(0, System.nanoTime()); + lastConsumerNanos.set(System.nanoTime()); + } finally { + consumerLock.unlock(); + } + } + + int generatedCount() { + return generatedCount.get(); + } + + long firstConsumerNanos() { + return firstConsumerNanos.get(); + } + + long lastConsumerNanos() { + return lastConsumerNanos.get(); + } +} diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java index 82b549ea..8631e9b3 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java @@ -2,7 +2,6 @@ import static com.icc.qasker.quizset.GenerationStatus.COMPLETED; import static com.icc.qasker.quizset.GenerationStatus.FAILED; -import static com.icc.qasker.quizset.GenerationStatus.GENERATING; import com.icc.qasker.ai.dto.GenerationRequestToAI; import com.icc.qasker.global.component.HashUtil; @@ -12,28 +11,12 @@ import com.icc.qasker.quizmake.SseNotificationService; import com.icc.qasker.quizmake.adapter.AIServerAdapter; import com.icc.qasker.quizmake.dto.ferequest.GenerationRequest; -import com.icc.qasker.quizmake.mapper.AIProblemSetMapper; -import com.icc.qasker.quizmake.mapper.ExplanationMarkdownBuilder; import com.icc.qasker.quizset.QuizCommandService; import com.icc.qasker.quizset.QuizQueryService; -import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent; -import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; -import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; -import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; -import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse.QuizForFe; -import com.icc.qasker.quizset.view.QuizView; -import com.icc.qasker.quizset.view.QuizViewToQuizForFeMapper; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.ReentrantLock; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; @Slf4j @Service @@ -86,11 +69,15 @@ private void processGenerationAsync( String sessionId, Long problemSetId, GenerationRequest request) { long startNanos = System.nanoTime(); - AtomicInteger atomicGeneratedCount = new AtomicInteger(0); - AtomicInteger numberCounter = new AtomicInteger(1); - AtomicLong firstConsumerNanos = new AtomicLong(0); - AtomicLong lastConsumerNanos = new AtomicLong(0); - ReentrantLock consumerLock = new ReentrantLock(); + GenerationBatchConsumer batchConsumer = + new GenerationBatchConsumer( + sessionId, + problemSetId, + request, + quizCommandService, + quizQueryService, + notificationService, + hashUtil); GenerationRequestToAI requestToAI = GenerationRequestToAI.builder() @@ -100,90 +87,7 @@ private void processGenerationAsync( .quizCount(request.quizCount()) .referencePages(request.pageNumbers()) .customInstruction(request.customInstruction()) - .questionsConsumer( - aiProblemSet -> { - consumerLock.lock(); - try { - // 1. 전송용 DTO로 변환 - ProblemSetGeneratedEvent problemSet = AIProblemSetMapper.toEvent(aiProblemSet); - if (CollectionUtils.isEmpty(problemSet.getQuiz())) { - log.warn("[AI 생성 스킵] 빈 배치 수신 sessionId={}", sessionId); - return; - } - - // 2. 선택지 셔플 - QuizType quizType = request.quizType(); - if (quizType == QuizType.MULTIPLE - || quizType == QuizType.BLANK - || quizType == QuizType.REAL_BLANK) { - for (var quiz : problemSet.getQuiz()) { - if (!CollectionUtils.isEmpty(quiz.getSelections())) { - List shuffled = new ArrayList<>(quiz.getSelections()); - Collections.shuffle(shuffled); - quiz.setSelections(shuffled); - } - } - } else if (quizType == QuizType.OX) { - // OX 선택지 정규화: X 계열이 1번이면 순서 변경 → O가 항상 1번 - for (var quiz : problemSet.getQuiz()) { - var sels = quiz.getSelections(); - if (sels != null - && sels.size() == 2 - && sels.get(0).getContent() != null - && sels.get(0).getContent().matches("(?i)^x$")) { - - List swapped = new ArrayList<>(2); - swapped.add(sels.get(1)); - swapped.add(sels.get(0)); - quiz.setSelections(swapped); - } - } - } - - // 3. 마크다운 포맷팅 - for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { - quiz.setExplanation( - ExplanationMarkdownBuilder.build(quiz, request.language())); - } - - // 4. 순서 보장 번호 할당 - for (QuizGeneratedFromAI quiz : problemSet.getQuiz()) { - quiz.setNumber(numberCounter.getAndIncrement()); - } - - // 5. 데이터베이스에 영속화 - List savedNumbers = - quizCommandService.saveBatch(problemSet.getQuiz(), problemSetId); - - // 6. SSE 전송 - List quizViews = - quizQueryService.getQuizViews(problemSetId, savedNumbers); - if (quizViews.isEmpty()) { - return; - } - - List quizForFeList = - quizViews.stream().map(QuizViewToQuizForFeMapper::toQuizForFe).toList(); - - notificationService.sendCreatedMessageWithId( - sessionId, - String.valueOf(quizViews.getLast().getNumber()), - new ProblemSetResponse( - sessionId, - hashUtil.encode(problemSetId), - request.title(), - GENERATING, - request.quizType(), - request.quizCount(), - quizForFeList)); - - atomicGeneratedCount.addAndGet(quizViews.size()); - firstConsumerNanos.compareAndSet(0, System.nanoTime()); - lastConsumerNanos.set(System.nanoTime()); - } finally { - consumerLock.unlock(); - } - }) + .questionsConsumer(batchConsumer) .build(); int maxChunkCount; @@ -199,12 +103,16 @@ private void processGenerationAsync( return; } - int generatedCount = atomicGeneratedCount.get(); + int generatedCount = batchConsumer.generatedCount(); int quizCount = request.quizCount(); long ttfqMs = - firstConsumerNanos.get() > 0 ? (firstConsumerNanos.get() - startNanos) / 1_000_000 : -1; + batchConsumer.firstConsumerNanos() > 0 + ? (batchConsumer.firstConsumerNanos() - startNanos) / 1_000_000 + : -1; long ttlqMs = - lastConsumerNanos.get() > 0 ? (lastConsumerNanos.get() - startNanos) / 1_000_000 : -1; + batchConsumer.lastConsumerNanos() > 0 + ? (batchConsumer.lastConsumerNanos() - startNanos) / 1_000_000 + : -1; // 요청/생성/실패 문제 수 메트릭 기록 (finalize 결과와 무관하게 항상 실행) resultRecorder.recordQuizCounts(request.quizType(), quizCount, generatedCount, maxChunkCount); diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java index fd5a7087..bd498632 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java @@ -69,15 +69,10 @@ public SseEmitter createSseEmitter(String sessionId) { @Override public void sendConnected(String sessionId) { - SseEmitter emitter = emitterMap.get(sessionId); - if (emitter != null) { - try { - emitter.send(SseEmitter.event().name("connected").data("hello")); - } catch (IOException e) { - log.warn("[SSE 연결 실패] 클라이언트 연결 중 에러 발생", e); - emitter.completeWithError(e); - } - } + safeSend( + sessionId, + SseEmitter.event().name("connected").data("hello"), + "[SSE 연결 실패] 클라이언트 연결 중 에러 발생"); } private @NonNull SseEmitter initSseEmitter(String sessionId) { @@ -104,38 +99,40 @@ public void sendConnected(String sessionId) { @Override public void sendCreatedMessageWithId(String sessionId, String eventId, Object data) { - SseEmitter emitter = emitterMap.get(sessionId); - if (emitter != null) { - try { - emitter.send(SseEmitter.event().id(eventId).name("created").data(data)); - } catch (IOException e) { - log.warn("[SSE 전송 실패] 클라이언트에게 데이터 전송 중 에러 발생", e); - emitter.completeWithError(e); - } - } + safeSend( + sessionId, + SseEmitter.event().id(eventId).name("created").data(data), + "[SSE 전송 실패] 클라이언트에게 데이터 전송 중 에러 발생"); } @Override public void sendFinishWithError(String sessionId, String message) { - SseEmitter emitter = emitterMap.get(sessionId); - if (emitter != null) { - try { - emitter.send(SseEmitter.event().name("error-finish").data(message)); - } catch (IOException e) { - emitter.completeWithError(e); - } - } + safeSend(sessionId, SseEmitter.event().name("error-finish").data(message), null); } @Override public void sendComplete(String sessionId) { + safeSend(sessionId, SseEmitter.event().name("complete").data("complete"), null); + } + + /** + * 세션에 연결된 emitter로 이벤트를 전송한다. emitter가 없으면 no-op, {@link IOException} 발생 시 해당 emitter를 에러로 마감하며 + * 예외를 밖으로 전파하지 않는다. + * + * @param warnMessage 전송 실패 시 남길 경고 로그 (null이면 로그 생략) + */ + private void safeSend(String sessionId, SseEmitter.SseEventBuilder event, String warnMessage) { SseEmitter emitter = emitterMap.get(sessionId); - if (emitter != null) { - try { - emitter.send(SseEmitter.event().name("complete").data("complete")); - } catch (IOException e) { - emitter.completeWithError(e); + if (emitter == null) { + return; + } + try { + emitter.send(event); + } catch (IOException e) { + if (warnMessage != null) { + log.warn(warnMessage, e); } + emitter.completeWithError(e); } } } diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java index a57a9e48..993137cf 100644 --- a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java @@ -3,21 +3,29 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.dto.AIProblemSet; +import com.icc.qasker.ai.dto.AISelection; import com.icc.qasker.ai.dto.GenerationRequestToAI; import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.quizmake.SseNotificationService; import com.icc.qasker.quizmake.adapter.AIServerAdapter; import com.icc.qasker.quizmake.dto.ferequest.GenerationRequest; import com.icc.qasker.quizmake.dto.ferequest.enums.Language; +import com.icc.qasker.quizset.GenerationStatus; import com.icc.qasker.quizset.QuizCommandService; import com.icc.qasker.quizset.QuizQueryService; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.view.QuizView; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -108,6 +116,176 @@ void multiple_request_calls_ai_with_multiple_strategy() { assertThat(captor.getValue().strategyValue()).isEqualTo("MULTIPLE"); } + @Test + @DisplayName("배치 소비: 문제에 1..n 순차 번호를 부여하고 saveBatch로 저장한다") + void batch_consumer_assigns_sequential_numbers_and_saves() { + GenerationRequest request = request(QuizType.MULTIPLE); + AIProblemSet batch = batchOf(multipleProblem(), multipleProblem()); + stubConsumer(batch, List.of(1, 2), List.of(quizView(1), quizView(2))); + + service.triggerGeneration("user-1", request); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(quizCommandService, timeout(2000)).saveBatch(captor.capture(), eq(1L)); + assertThat(captor.getValue()).extracting(QuizGeneratedFromAI::getNumber).containsExactly(1, 2); + } + + @Test + @DisplayName("배치 소비: SSE eventId는 저장된 마지막 문제 번호로 전송된다") + void batch_consumer_sends_last_number_as_event_id() { + GenerationRequest request = request(QuizType.MULTIPLE); + AIProblemSet batch = batchOf(multipleProblem(), multipleProblem()); + stubConsumer(batch, List.of(1, 2), List.of(quizView(1), quizView(2))); + + service.triggerGeneration("user-1", request); + + verify(notificationService, timeout(2000)).sendCreatedMessageWithId(any(), eq("2"), any()); + } + + @Test + @DisplayName("배치 소비: MULTIPLE 선택지는 순서만 바뀌고 집합은 보존된다") + void multiple_selections_are_shuffled_preserving_set() { + GenerationRequest request = request(QuizType.MULTIPLE); + AIProblemSet batch = batchOf(multipleProblem()); + stubConsumer(batch, List.of(1), List.of(quizView(1))); + + service.triggerGeneration("user-1", request); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(quizCommandService, timeout(2000)).saveBatch(captor.capture(), eq(1L)); + List contents = + captor.getValue().get(0).getSelections().stream() + .map(QuizGeneratedFromAI.SelectionsOfAI::getContent) + .toList(); + assertThat(contents).containsExactlyInAnyOrder("A", "B", "C", "D"); + } + + @Test + @DisplayName("배치 소비: OX 선택지는 X가 1번이면 O가 항상 1번이 되도록 정규화된다") + void ox_selections_are_normalized_so_o_is_first() { + GenerationRequest request = + new GenerationRequest( + null, + UUID.randomUUID().toString(), + "https://example.com/file.pdf", + "title", + 5, + QuizType.OX, + List.of(1, 2), + Language.KO); + AIProblemSet batch = + batchOf( + new AIProblem( + "질문", + "이해", + List.of(new AISelection("X", "틀림 해설", false), new AISelection("O", "맞음 해설", true)), + List.of(1), + null)); + stubConsumer(batch, List.of(1), List.of(quizView(1))); + + service.triggerGeneration("user-1", request); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(quizCommandService, timeout(2000)).saveBatch(captor.capture(), eq(1L)); + assertThat(captor.getValue().get(0).getSelections().get(0).getContent()).isEqualTo("O"); + } + + @Test + @DisplayName("finalize: 생성 수가 0이면 FAILED 상태로 마감하고 에러를 기록한다") + void finalize_marks_failed_when_nothing_generated() { + GenerationRequest request = request(QuizType.MULTIPLE); + when(aiServerAdapter.streamRequest(any())).thenReturn(3); + + service.triggerGeneration("user-1", request); + + verify(quizCommandService, timeout(2000)).updateStatus(eq(1L), eq(GenerationStatus.FAILED)); + verify(notificationService, timeout(2000)).sendFinishWithError(any(), any()); + verify(resultRecorder, timeout(2000)).recordError(eq(1L), eq(QuizType.MULTIPLE), any()); + } + + @Test + @DisplayName("finalize: 생성 수가 요청 수와 같으면 COMPLETED + 성공 기록") + void finalize_marks_completed_on_full_success() { + GenerationRequest request = request(QuizType.MULTIPLE); // quizCount=5 + AIProblemSet batch = batchOf(multipleProblem()); + stubConsumer( + batch, + List.of(1, 2, 3, 4, 5), + List.of(quizView(1), quizView(2), quizView(3), quizView(4), quizView(5))); + + service.triggerGeneration("user-1", request); + + verify(quizCommandService, timeout(2000)).updateStatus(eq(1L), eq(GenerationStatus.COMPLETED)); + verify(notificationService, timeout(2000)).sendComplete(any()); + verify(resultRecorder, timeout(2000)) + .recordSuccess(eq(1L), eq(QuizType.MULTIPLE), eq(5L), anyLong(), anyLong()); + } + + @Test + @DisplayName("finalize: 일부만 생성되면 COMPLETED + 부분 성공 기록") + void finalize_marks_partial_success() { + GenerationRequest request = request(QuizType.MULTIPLE); // quizCount=5 + AIProblemSet batch = batchOf(multipleProblem()); + stubConsumer(batch, List.of(1, 2, 3), List.of(quizView(1), quizView(2), quizView(3))); + + service.triggerGeneration("user-1", request); + + verify(quizCommandService, timeout(2000)).updateStatus(eq(1L), eq(GenerationStatus.COMPLETED)); + verify(resultRecorder, timeout(2000)) + .recordPartialSuccess(eq(1L), eq(QuizType.MULTIPLE), eq(3L), eq(5L), anyLong(), anyLong()); + } + + @Test + @DisplayName("finalize: 스트림 예외 발생 시 FAILED 상태로 마감한다") + void finalize_marks_failed_when_stream_throws() { + GenerationRequest request = request(QuizType.MULTIPLE); + when(aiServerAdapter.streamRequest(any())).thenThrow(new RuntimeException("boom")); + + service.triggerGeneration("user-1", request); + + verify(quizCommandService, timeout(2000)).updateStatus(eq(1L), eq(GenerationStatus.FAILED)); + verify(resultRecorder, timeout(2000)).recordError(eq(1L), eq(QuizType.MULTIPLE), any()); + } + + // ── helpers ──────────────────────────────────────────────── + + /** streamRequest가 호출되면 넘겨받은 consumer에 batch를 전달하도록 스텁한다. */ + private void stubConsumer(AIProblemSet batch, List savedNumbers, List views) { + when(quizCommandService.saveBatch(anyList(), eq(1L))).thenReturn(savedNumbers); + when(quizQueryService.getQuizViews(anyLong(), anyList())).thenReturn(views); + when(aiServerAdapter.streamRequest(any())) + .thenAnswer( + invocation -> { + GenerationRequestToAI req = invocation.getArgument(0); + req.questionsConsumer().accept(batch); + return 3; + }); + } + + private AIProblemSet batchOf(AIProblem... problems) { + return new AIProblemSet(List.of(problems)); + } + + private AIProblem multipleProblem() { + return new AIProblem( + "질문", + "이해", + List.of( + new AISelection("A", "해설 A", true), + new AISelection("B", "해설 B", false), + new AISelection("C", "해설 C", false), + new AISelection("D", "해설 D", false)), + List.of(1), + null); + } + + private QuizView quizView(int number) { + return new QuizView(number, "title-" + number, 0, false, List.of(), null); + } + private GenerationRequest request(QuizType quizType) { return new GenerationRequest( null, diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImplTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImplTest.java new file mode 100644 index 00000000..cb217755 --- /dev/null +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImplTest.java @@ -0,0 +1,88 @@ +package com.icc.qasker.quizmake.service.generation; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.icc.qasker.quizmake.properties.QAskerSseProperties; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +class SseNotificationServiceImplTest { + + private SseNotificationServiceImpl service; + + @BeforeEach + void setUp() { + service = + new SseNotificationServiceImpl( + new QAskerSseProperties(), + CircuitBreakerRegistry.ofDefaults(), + new SimpleMeterRegistry()); + } + + @Test + @DisplayName("전송 중 IOException이 발생하면 completeWithError를 호출하고 예외를 전파하지 않는다") + void send_failure_completes_with_error_without_propagating() throws Exception { + SseEmitter emitter = mock(SseEmitter.class); + IOException failure = new IOException("broken pipe"); + doThrow(failure).when(emitter).send(any(SseEmitter.SseEventBuilder.class)); + putEmitter("session-1", emitter); + + assertThatCode(() -> service.sendComplete("session-1")).doesNotThrowAnyException(); + + verify(emitter).completeWithError(failure); + } + + @Test + @DisplayName("등록된 emitter가 없으면 전송은 no-op이며 예외를 던지지 않는다") + void send_is_noop_when_emitter_absent() { + assertThatCode(() -> service.sendComplete("unknown-session")).doesNotThrowAnyException(); + } + + @Test + @DisplayName("sendCreatedMessageWithId도 IOException 시 completeWithError로 마감한다") + void send_created_message_completes_with_error_on_failure() throws Exception { + SseEmitter emitter = mock(SseEmitter.class); + IOException failure = new IOException("broken pipe"); + doThrow(failure).when(emitter).send(any(SseEmitter.SseEventBuilder.class)); + putEmitter("session-2", emitter); + + assertThatCode(() -> service.sendCreatedMessageWithId("session-2", "3", "data")) + .doesNotThrowAnyException(); + + verify(emitter).completeWithError(failure); + } + + @Test + @DisplayName("정상 전송 시 completeWithError를 호출하지 않는다") + void successful_send_does_not_complete_with_error() { + SseEmitter emitter = mock(SseEmitter.class); + putEmitter("session-3", emitter); + + service.sendComplete("session-3"); + + verify(emitter, never()).completeWithError(any()); + } + + @SuppressWarnings("unchecked") + private void putEmitter(String sessionId, SseEmitter emitter) { + try { + Field field = SseNotificationServiceImpl.class.getDeclaredField("emitterMap"); + field.setAccessible(true); + ((Map) field.get(service)).put(sessionId, emitter); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } +}