From 7ea694773ab5b9757ce9250978e2d72d32341a28 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 27 Mar 2026 17:14:08 +0900 Subject: [PATCH 001/136] =?UTF-8?q?[refactor]=20-=20scheme=EC=9D=84=20?= =?UTF-8?q?=EB=8D=94=20=EC=84=B8=EB=B6=84=ED=99=94=20=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20DTO?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/domain/SchemeType.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/domain/SchemeType.java b/src/main/java/com/veriq/veriqbe3/domain/SchemeType.java index 026b9ee..710e9d0 100644 --- a/src/main/java/com/veriq/veriqbe3/domain/SchemeType.java +++ b/src/main/java/com/veriq/veriqbe3/domain/SchemeType.java @@ -1,8 +1,14 @@ package com.veriq.veriqbe3.domain; public enum SchemeType { - WEB, // 일반 웹 (http, https) - SHORT_URL, // 단축 URL + WEB, + SHORT_URL, + OTP, + CRYPTO, + SMS, + WIFI, + CONTACT, + DEEP_LINK, TEL, // 전화번호 (tel:) EMAIL, // 이메일 (mailto:) APP_STORE, // 앱 설치 페이지 (market:, itms-apps:) From 2f6b9b9146083381873aca6aac01f9897c0acef7 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 27 Mar 2026 17:18:21 +0900 Subject: [PATCH 002/136] =?UTF-8?q?[refactor]=20-=20schemeType=EC=9D=84=20?= =?UTF-8?q?=EC=84=B8=EB=B6=84=ED=99=94=ED=95=A8=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=EA=B0=81=20scheme=EC=9D=98=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=ED=95=9C=20=ED=83=90=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20pattern,=20matcher=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/service/SchemeClassifier.java | 101 ++++++++++++++---- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java index 5a6207e..4c28551 100644 --- a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java +++ b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java @@ -3,40 +3,101 @@ import com.veriq.veriqbe3.domain.SchemeType; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @Component public class SchemeClassifier { - // 💡 1. 두 개의 값을 한 번에 반환하기 위한 전용 포장지 (Record) - public record ClassificationResult(SchemeType type, String typeInfo) {} + // 1. 웹url(http/https 생략된 도메인 형태 포함) + private static final Pattern WEB_URL_PATTERN = Pattern.compile( + "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})([/\\w .-]*)*/?$", //http생략한 경우 포함, 한글자 도메인 이름 포함 + Pattern.CASE_INSENSITIVE + ); + + private static final List SHORTENER_DOMAINS = List.of("bit.ly", "t.co", "goo.gl", "tinyurl.com"); + + // 2. 2차 인증 (OTP) - otpauth://totp/Google:test@gmail.com?secret=... + private static final Pattern OTP_PATTERN = Pattern.compile( + "^otpauth://(totp|hotp)/.*$", + Pattern.CASE_INSENSITIVE + ); + + // 3. 가상화폐 지갑 - bitcoin:1A1zP1eP... + private static final Pattern CRYPTO_PATTERN = Pattern.compile( + "^(bitcoin|ethereum|litecoin|bitcoincash):[a-zA-Z0-9]+$", + Pattern.CASE_INSENSITIVE + ); + + // 4. SMS - sms:+821012345678?body=hello + private static final Pattern SMS_PATTERN = Pattern.compile( + "^sms:(\\+?[0-9\\-]+)(\\?body=.*)?$", + Pattern.CASE_INSENSITIVE + ); + + // 5. WI-FI - WIFI:T:WPA;S:MyNetwork;P:password;; + private static final Pattern WIFI_PATTERN = Pattern.compile( + "^WIFI:S:[^;]+;T:[^;]+;P:[^;]+;;$", + Pattern.CASE_INSENSITIVE + ); + + // 6. 연락처 (vCard / MeCard) + private static final Pattern CONTACT_PATTERN = Pattern.compile( + "^(BEGIN:VCARD|MECARD:).*$", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL + ); + + // 7. 전화번호 (tel:01012345678 또는 순수 숫자 9~13자리) + private static final Pattern TEL_PATTERN = Pattern.compile( + "^(tel:)?((\\+?82|0)1[016789]-?\\d{3,4}-?\\d{4})$", + Pattern.CASE_INSENSITIVE + ); + + // 8. 일반 딥 링크 (특정 앱 실행용 커스텀 스킴 instagram:) + private static final Pattern DEEP_LINK_PATTERN = Pattern.compile( + "^[a-z][a-z0-9+-.]*://.*$", + Pattern.CASE_INSENSITIVE + ); + + public record ClassificationResult(SchemeType type, String typeInfo, boolean toBe2) {} - // 💡 2. public으로 열어주고, 파라미터로 DTO를 받지 않습니다. 오직 텍스트만 받습니다. public ClassificationResult classify(String rawText) { - if (rawText == null || rawText.isEmpty()) { - return new ClassificationResult(SchemeType.OTHER, rawText); + if (rawText == null || rawText.isBlank()) { + return new ClassificationResult(SchemeType.OTHER, rawText, false); } - String lowerText = rawText.toLowerCase(); + String trimmed = rawText.strip(); - // 1. 전화번호 (tel:01012345678) - if (lowerText.startsWith("tel:")) { - return new ClassificationResult(SchemeType.TEL, rawText.substring(4)); - } + // 형식이 명확한 순서로 검사 + + if (OTP_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.OTP, trimmed, false); + if (CRYPTO_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.CRYPTO, trimmed, false); + if (WIFI_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.WIFI, trimmed, false); + if (SMS_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.SMS, trimmed, false); + if (CONTACT_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.CONTACT, trimmed, false); - // 2. 이메일 (mailto:test@veriq.com) - if (lowerText.startsWith("mailto:")) { - return new ClassificationResult(SchemeType.EMAIL, rawText.substring(7)); + Matcher telMatcher = TEL_PATTERN.matcher(trimmed); + if (telMatcher.matches()) { + String phoneNum = trimmed.replaceFirst("(?i)tel:", ""); + return new ClassificationResult(SchemeType.TEL, phoneNum, false); } - // 3. 앱 스토어 (안드로이드 마켓 또는 iOS 앱스토어) - if (lowerText.startsWith("market:") || lowerText.startsWith("itms-apps:")) { - return new ClassificationResult(SchemeType.APP_STORE, rawText); + if (WEB_URL_PATTERN.matcher(trimmed).matches()) { + boolean isShortener = SHORTENER_DOMAINS.stream() + .anyMatch(domain -> trimmed.toLowerCase().contains(domain)); + + if (isShortener) { + return new ClassificationResult(SchemeType.SHORT_URL, trimmed, true); + } + return new ClassificationResult(SchemeType.WEB, trimmed, true); } - // 4. 웹 URL, 단축 URL 판별 - if (lowerText.startsWith("http://") || lowerText.startsWith("https://")) { - return new ClassificationResult(SchemeType.WEB, rawText); + if (DEEP_LINK_PATTERN.matcher(trimmed).matches()) { + return new ClassificationResult(SchemeType.DEEP_LINK, trimmed, false); } - return new ClassificationResult(SchemeType.OTHER, rawText); + // 그 외 일반 텍스트 + return new ClassificationResult(SchemeType.OTHER, trimmed, false); } } \ No newline at end of file From 80e86e774ea1b8a4efdb9f2d41ed855e44669305 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 27 Mar 2026 17:20:59 +0900 Subject: [PATCH 003/136] =?UTF-8?q?[refactor]=20-=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EB=A9=94=EC=86=8C=EB=93=9C=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20be2=EC=97=90=EA=B2=8C=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EB=8A=94=20json=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/controller/QrScanController.java | 4 ++-- .../com/veriq/veriqbe3/service/ProcessQrScan.java | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index bae4aac..a01833f 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -20,8 +20,8 @@ public ResponseEntity uploadQrImage( //반환 http body 타 @RequestHeader(value = "guest_uuid", required = false ) String guestUuid, @RequestParam("image") MultipartFile image) { try { - QrScanResponse response = processQrScan.toFront(image, guestUuid); - return ResponseEntity.ok(response); + QrScanResponse response = processQrScan.process(image, guestUuid); + return ResponseEntity.ok(response); } catch (Exception e) { return ResponseEntity.badRequest().build(); diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index 4a02ea1..b42ad77 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -18,7 +18,7 @@ public class ProcessQrScan { private final ScanHistoryRepository scanHistoryRepository; @Transactional // DB 저장 중 에러 발생 시 모든 작업을 롤백하기 위함 - public QrScanResponse toFront(MultipartFile image, String guest_uuid) throws Exception { + public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exception { String url = qrDecoder.decode(image); //이미지 디코드 @@ -34,10 +34,14 @@ public QrScanResponse toFront(MultipartFile image, String guest_uuid) throws Exc String status = "COMPLETED"; - //WEB이나 단축 URL이면 상태를 변경 + //WEB이나 단축 URL이면 if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { - status = "ANALYSIS_REQUIRED"; - + return QrScanResponse.builder() + .guestUuid(guest_uuid) + .schemeType(result.type()) + .typeInfo(result.typeInfo()) + .status("ANALYSIS_COMPLETED") // BE2 연동 후 실제 결과 상태로 변경 + .build(); } return QrScanResponse.builder() .guestUuid(guest_uuid) // FE가 보낸 식별자 From d6ca38c0ce94535c82a2766f0abc99e3ead0ee47 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:54:25 +0000 Subject: [PATCH 004/136] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- gradlew | 2 +- .../veriqbe3/service/SchemeClassifier.java | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/gradlew b/gradlew index adff685..1b05573 100644 --- a/gradlew +++ b/gradlew @@ -245,4 +245,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java index 4c28551..8f7912c 100644 --- a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java +++ b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java @@ -12,7 +12,7 @@ public class SchemeClassifier { // 1. 웹url(http/https 생략된 도메인 형태 포함) private static final Pattern WEB_URL_PATTERN = Pattern.compile( - "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})([/\\w .-]*)*/?$", //http생략한 경우 포함, 한글자 도메인 이름 포함 + "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,})([/\\w .-?&#=]*)*/?$", //http생략한 경우 포함, 한글자 도메인 이름 포함, query/fragment 허용, TLD 길이 제한 없음 Pattern.CASE_INSENSITIVE ); @@ -36,9 +36,9 @@ public class SchemeClassifier { Pattern.CASE_INSENSITIVE ); - // 5. WI-FI - WIFI:T:WPA;S:MyNetwork;P:password;; + // 5. WI-FI - WIFI:T:WPA;S:MyNetwork;P:password;; (S and T can appear in any order, P is optional) private static final Pattern WIFI_PATTERN = Pattern.compile( - "^WIFI:S:[^;]+;T:[^;]+;P:[^;]+;;$", + "^WIFI:(?=.*S:[^;]+)(?=.*T:[^;]+)(?:S:[^;]+;|T:[^;]+;|P:[^;]+;){2,3};$", Pattern.CASE_INSENSITIVE ); @@ -54,7 +54,19 @@ public class SchemeClassifier { Pattern.CASE_INSENSITIVE ); - // 8. 일반 딥 링크 (특정 앱 실행용 커스텀 스킴 instagram:) + // 8. 이메일 (mailto:user@example.com) + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + Pattern.CASE_INSENSITIVE + ); + + // 9. 앱 스토어 (market://..., itms-apps://...) + private static final Pattern APP_STORE_PATTERN = Pattern.compile( + "^(market|itms-apps)://.*$", + Pattern.CASE_INSENSITIVE + ); + + // 10. 일반 딥 링크 (특정 앱 실행용 커스텀 스킴 instagram:) private static final Pattern DEEP_LINK_PATTERN = Pattern.compile( "^[a-z][a-z0-9+-.]*://.*$", Pattern.CASE_INSENSITIVE @@ -83,6 +95,18 @@ public ClassificationResult classify(String rawText) { return new ClassificationResult(SchemeType.TEL, phoneNum, false); } + Matcher emailMatcher = EMAIL_PATTERN.matcher(trimmed); + if (emailMatcher.matches()) { + String email = trimmed.replaceFirst("(?i)mailto:", ""); + return new ClassificationResult(SchemeType.EMAIL, email, false); + } + + Matcher appStoreMatcher = APP_STORE_PATTERN.matcher(trimmed); + if (appStoreMatcher.matches()) { + String storeUri = trimmed.replaceFirst("(?i)(market|itms-apps)://", ""); + return new ClassificationResult(SchemeType.APP_STORE, storeUri, false); + } + if (WEB_URL_PATTERN.matcher(trimmed).matches()) { boolean isShortener = SHORTENER_DOMAINS.stream() .anyMatch(domain -> trimmed.toLowerCase().contains(domain)); From 721127dc592f74cf29496ea7928b1844971322cc Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Fri, 3 Apr 2026 03:33:13 +0900 Subject: [PATCH 005/136] docs/Update readme.md Added a comprehensive guide for connecting to the Veri-Q server and local development setup, including service ports, Docker deployment, and GitHub Actions for automatic deployment. --- README.md | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bf728d4..2bc65bb 100644 --- a/README.md +++ b/README.md @@ -1 +1,134 @@ -add file ".gitignore" +# Veri-Q 서버 접속 및 개발 가이드 + 구축한 GCP 서버 인프라를 사용하기 위한 가이드입니다. + +## 🌐 서버 정보 +- **공통 외부 IP**: `34.64.218.236` +- **관리자 계정**: `root` (DB 접속 시 사용) +* **서버 사용자**: `sk38808738` (SSH 접속 시 사용) + +### 서비스별 포트 +| 서비스 | 포트 | 용도 | +| :--- | :--- | :--- | +| **Spring BE1** | `8081` | Gateway 서버 | +| **Spring BE3** | `8083` | Platform/Data Hub 서버 | +| **FastAPI** | `8000` | 분석 엔진 서버 | +| **MySQL** | `3306` | 데이터베이스 | +| **Redis** | `6379` | 캐시 서버 | + +--- +## 🛠️ 팀원별 환경 설정 가이드 (로컬 개발용) + +> **공통 외부 IP**: `34.64.218.236` +> 모든 팀원은 로컬에서 테스트할 때 아래 설정값을 참조하여 접속 정보를 수정해 주세요. + + + +### **.🍃 Spring Boot, ⚡ FastAPI ** +`src/main/resources/application.yml`,`.env` 파일의 설정을 아래와 같이 수정합니다. +```yaml + +spring: + datasource: + # GCP MySQL 연결 설정 + url: jdbc:mysql://34.64.218.236:3306/veriq_db?serverTimezone=Asia/Seoul + username: root + password: [카톡방에 공유된 비밀번호] + data: + redis: + # GCP Redis 연결 설정 + host: 34.64.218.236 + port: 6379 + +--- env +# Database 연결 정보 +DB_URL = "mysql+pymysql://root:[비밀번호]@34.64.218.236:3306/veriq_db" + +# Redis 연결 정보 +REDIS_HOST = "34.64.218.236" +REDIS_PORT = 6379 + +``` + + + + + + + +### **📦 Docker 배포 설정 (최상위 경로에 위치) + + + +### **🍃 Spring Boot Dockerfile +```yaml +FROM eclipse-temurin:21-jdk-jammy +WORKDIR /app +# 빌드된 JAR를 app.jar로 복사 (파일명에 상관없이 자동 매칭) +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + + + +### **⚡FastAPI Dockerfile +```yaml +FROM python:3.9-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + +``` + + + + + + + + + + +### **GitHub Actions 자동 배포 (deploy.yml) +위치: .github/workflows/deploy.yml //각자 개발하는 인텔리제이나 fastapi에서 위치함 +```yaml +name: Veri-Q Auto Deploy + +on: + push: + branches: [ "develop" ] # develop 브랜치 푸시 시 자동 배포 + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/sk38808738 + sudo docker-compose pull # 최신 이미지 다운로드 + sudo docker-compose up -d # 컨테이너 재실행 +``` + + + + + + +### * 배포 프로세스 +```yaml +빌드: + +Spring: ./gradlew bootJar 실행하여 JAR 생성 + +FastAPI: 새 라이브러리 추가 시 pip freeze > requirements.txt 갱신 + +푸시: develop 브랜치로 Push하면 서버에 자동 반영됩니다. + +``` + From 04df98d5bcfb70c46d9501bc92f6fad8b7955869 Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Fri, 3 Apr 2026 04:53:13 +0900 Subject: [PATCH 006/136] docs/Update readme.md Updated database connection details in README. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2bc65bb..7228d7a 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ spring: datasource: # GCP MySQL 연결 설정 - url: jdbc:mysql://34.64.218.236:3306/veriq_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul username: root password: [카톡방에 공유된 비밀번호] data: redis: # GCP Redis 연결 설정 - host: 34.64.218.236 + host: veriq-redis port: 6379 --- env From a81f4dda322fd6d7b42d8d65d4bf22c3b9c7de96 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 3 Apr 2026 09:38:42 +0900 Subject: [PATCH 007/136] =?UTF-8?q?[docs]=20-=20WEB=5FURL=5FPATTERN?= =?UTF-8?q?=EC=9D=98=20dos=EA=B3=B5=EA=B2=A9=20=EC=B7=A8=EC=95=BD=EC=A0=90?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20-=20EMAIL=5FPATTERN=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20-=20CRYPTO=5FPATTERN=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=88=84=EB=9D=BD=20-?= =?UTF-8?q?=20shortener,tel=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/service/SchemeClassifier.java | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java index 8f7912c..c687efb 100644 --- a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java +++ b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java @@ -10,35 +10,41 @@ @Component public class SchemeClassifier { - // 1. 웹url(http/https 생략된 도메인 형태 포함) + // 1. 웹 URL DoS 방지 및 특수문자 % 허용 private static final Pattern WEB_URL_PATTERN = Pattern.compile( - "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,})([/\\w .-?&#=]*)*/?$", //http생략한 경우 포함, 한글자 도메인 이름 포함, query/fragment 허용, TLD 길이 제한 없음 + "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,})([/a-zA-Z0-9.~:?#@!$&'()*+,;=%-]*)$", Pattern.CASE_INSENSITIVE ); - private static final List SHORTENER_DOMAINS = List.of("bit.ly", "t.co", "goo.gl", "tinyurl.com"); + // 단축 URL 확인을 위한 패턴 (정확히 호스트명에 해당하는지 검사하기 위함) + private static final List SHORTENER_PATTERNS = List.of( + Pattern.compile("^(https?://)?(www\\.)?bit\\.ly(/.*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?t\\.co(/.*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?goo\\.gl(/.*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?tinyurl\\.com(/.*)?$", Pattern.CASE_INSENSITIVE) + ); - // 2. 2차 인증 (OTP) - otpauth://totp/Google:test@gmail.com?secret=... + // 2. 2차 인증 (OTP) private static final Pattern OTP_PATTERN = Pattern.compile( "^otpauth://(totp|hotp)/.*$", Pattern.CASE_INSENSITIVE ); - // 3. 가상화폐 지갑 - bitcoin:1A1zP1eP... + // 3. 가상화폐 지갑 (?amount= 등 쿼리파라미터 허용) private static final Pattern CRYPTO_PATTERN = Pattern.compile( - "^(bitcoin|ethereum|litecoin|bitcoincash):[a-zA-Z0-9]+$", + "^(bitcoin|ethereum|litecoin|bitcoincash):[a-zA-Z0-9]+(\\?.*)?$", Pattern.CASE_INSENSITIVE ); - // 4. SMS - sms:+821012345678?body=hello + // 4. SMS private static final Pattern SMS_PATTERN = Pattern.compile( - "^sms:(\\+?[0-9\\-]+)(\\?body=.*)?$", + "^sms:(\\+?[0-9\\-]+)(\\?.*)?$", Pattern.CASE_INSENSITIVE ); - // 5. WI-FI - WIFI:T:WPA;S:MyNetwork;P:password;; (S and T can appear in any order, P is optional) + // 5. WI-FI (비밀번호가 없는 Open 네트워크도 존재할 수 있으므로 P:는 필수가 아님) private static final Pattern WIFI_PATTERN = Pattern.compile( - "^WIFI:(?=.*S:[^;]+)(?=.*T:[^;]+)(?:S:[^;]+;|T:[^;]+;|P:[^;]+;){2,3};$", + "^WIFI:.*(S:[^;]+).*;;?$", Pattern.CASE_INSENSITIVE ); @@ -48,25 +54,25 @@ public class SchemeClassifier { Pattern.CASE_INSENSITIVE | Pattern.DOTALL ); - // 7. 전화번호 (tel:01012345678 또는 순수 숫자 9~13자리) + // 7. 전화번호 (일반 유선, 국제번호 포함하여 유연하게 변경) private static final Pattern TEL_PATTERN = Pattern.compile( - "^(tel:)?((\\+?82|0)1[016789]-?\\d{3,4}-?\\d{4})$", + "^(tel:)?(\\+?[0-9\\-]{8,15})$", Pattern.CASE_INSENSITIVE ); - // 8. 이메일 (mailto:user@example.com) + // 8. 이메일 (?subject= 등 쿼리파라미터 허용) private static final Pattern EMAIL_PATTERN = Pattern.compile( - "^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}(\\?.*)?$", Pattern.CASE_INSENSITIVE ); - // 9. 앱 스토어 (market://..., itms-apps://...) + // 9. 앱 스토어 private static final Pattern APP_STORE_PATTERN = Pattern.compile( "^(market|itms-apps)://.*$", Pattern.CASE_INSENSITIVE ); - // 10. 일반 딥 링크 (특정 앱 실행용 커스텀 스킴 instagram:) + // 10. 일반 딥 링크 private static final Pattern DEEP_LINK_PATTERN = Pattern.compile( "^[a-z][a-z0-9+-.]*://.*$", Pattern.CASE_INSENSITIVE @@ -81,8 +87,6 @@ public ClassificationResult classify(String rawText) { String trimmed = rawText.strip(); - // 형식이 명확한 순서로 검사 - if (OTP_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.OTP, trimmed, false); if (CRYPTO_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.CRYPTO, trimmed, false); if (WIFI_PATTERN.matcher(trimmed).matches()) return new ClassificationResult(SchemeType.WIFI, trimmed, false); @@ -108,8 +112,9 @@ public ClassificationResult classify(String rawText) { } if (WEB_URL_PATTERN.matcher(trimmed).matches()) { - boolean isShortener = SHORTENER_DOMAINS.stream() - .anyMatch(domain -> trimmed.toLowerCase().contains(domain)); + // 정규식을 통해 정확히 도메인 형태가 단축 URL인지 판별 + boolean isShortener = SHORTENER_PATTERNS.stream() + .anyMatch(pattern -> pattern.matcher(trimmed).matches()); if (isShortener) { return new ClassificationResult(SchemeType.SHORT_URL, trimmed, true); @@ -121,7 +126,6 @@ public ClassificationResult classify(String rawText) { return new ClassificationResult(SchemeType.DEEP_LINK, trimmed, false); } - // 그 외 일반 텍스트 return new ClassificationResult(SchemeType.OTHER, trimmed, false); } } \ No newline at end of file From 75364df6fa2bc8563c7f608923e9a989dd774011 Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Fri, 3 Apr 2026 10:30:15 +0900 Subject: [PATCH 008/136] docs/Update readme.md Added docker-compose configuration for MySQL, Redis, Spring, and FastAPI services. --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7228d7a..95793e8 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,65 @@ jobs: sudo docker-compose up -d # 컨테이너 재실행 ``` +### **docker-compose.yml - - +```yaml +version: '3.8' + +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: veriq123 + MYSQL_DATABASE: veriq_db + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "6379:6379" + + # 3. Spring BE1 (Gateway - 박상민 ) + veriq-gateway: + image: eclipse-temurin:21-jdk + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + depends_on: + - veriq-db + - veriq-redis + + # 4. Spring BE3 (Platform/Data Hub) + veriq-platform: + image: eclipse-temurin:21-jdk + container_name: veriq-be3 + restart: always + ports: + - "8083:8083" + depends_on: + - veriq-db + - veriq-redis + + # 5. FastAPI (분석 엔진) + veriq-analysis: + image: python:3.9-slim + container_name: veriq-analysis + restart: always + ports: + - "8000:8000" + depends_on: + - veriq-db +``` ### * 배포 프로세스 From 08ac5c504a28c7f7d68e083a2d2f6bd6b1f235be Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 3 Apr 2026 10:38:10 +0900 Subject: [PATCH 009/136] server test --- .github/workflows/deploy.yml | 20 +++++++++++ Dockerfile | 5 +++ docker-compose.yml | 55 ++++++++++++++++++++++++++++++ src/main/resources/application.yml | 11 ++---- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e57d4a0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Veri-Q Auto Deploy + +on: + push: + branches: [ "develop" ] # develop 브랜치 푸시 시 자동 배포 + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/sk38808738 + sudo docker-compose pull # 최신 이미지 다운로드 + sudo docker-compose up -d # 컨테이너 재실행 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd06ca0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jdk-jammy +WORKDIR /app +# 빌드된 JAR를 app.jar로 복사 (파일명에 상관없이 자동 매칭) +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6da9d3f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +version: '3.8' + +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: veriq123 + MYSQL_DATABASE: veriq_db + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "6379:6379" + + # 3. Spring BE1 (Gateway - 박상민 ) + veriq-gateway: + image: eclipse-temurin:21-jdk + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + depends_on: + - veriq-db + - veriq-redis + + # 4. Spring BE3 (Platform/Data Hub) + veriq-platform: + image: eclipse-temurin:21-jdk + container_name: veriq-be3 + restart: always + ports: + - "8083:8083" + depends_on: + - veriq-db + - veriq-redis + + # 5. FastAPI (분석 엔진) + veriq-analysis: + image: python:3.9-slim + container_name: veriq-analysis + restart: always + ports: + - "8000:8000" + depends_on: + - veriq-db \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a4e3cfe..d6b1b5f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,9 +3,9 @@ server: spring: datasource: - url: jdbc:mysql://localhost:3306/veriq?useSSL=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul username: root - password: 1234 + password: veriq123 driver-class-name: com.mysql.cj.jdbc.Driver jpa: @@ -15,9 +15,4 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.MySQLDialect - - data: - redis: - host: localhost - port: 6379 \ No newline at end of file + dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file From 1bd9aaf2395f5017e3d3bd83d6eb9b5ace58173d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 10:54:54 +0900 Subject: [PATCH 010/136] Fix: Update deploy.yml from feat/KD-12 --- .github/workflows/deploy.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3637c41 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Veri-Q Develop Deploy + +on: + push: + branches: [ "develop","feat/KD-12" ] # main이 아니라 develop으로 수정! + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/sk38808738 + sudo docker-compose pull + sudo docker-compose up -d \ No newline at end of file From 736c28104ade5fcec1222f82a5ee36cfa96818dc Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 11:35:07 +0900 Subject: [PATCH 011/136] Fix: Add permissions to deploy.yml for SSH authentication --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3637c41..b8e9b0a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,9 @@ on: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: write + packages: write steps: - name: SSH Remote Commands uses: appleboy/ssh-action@master From 19691c4c0a9c29c33470d9f0a645c9aaca9199a3 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 12:01:26 +0900 Subject: [PATCH 012/136] Fix: Add bootJar and build option to deploy script --- .github/workflows/deploy.yml | 4 +++- build.gradle | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b8e9b0a..3a22f31 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,5 +19,7 @@ jobs: key: ${{ secrets.GCP_SSH_KEY }} script: | cd /home/sk38808738 + git pull origin develop + sudo ./gradlew bootJar sudo docker-compose pull - sudo docker-compose up -d \ No newline at end of file + sudo docker-compose up -d diff --git a/build.gradle b/build.gradle index 794f42a..8170819 100644 --- a/build.gradle +++ b/build.gradle @@ -41,3 +41,6 @@ dependencies { tasks.named('test') { useJUnitPlatform() } +jar { + enabled = false +} \ No newline at end of file From 084b54b44626d58bf24c22db820563c32f708907 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 12:08:55 +0900 Subject: [PATCH 013/136] Fix: Final indentation fix for deploy.yml --- .github/workflows/deploy.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a22f31..241e2ec 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Veri-Q Develop Deploy on: push: - branches: [ "develop","feat/KD-12" ] # main이 아니라 develop으로 수정! + branches: [ "develop", "feat/KD-12" ] jobs: deploy: @@ -18,8 +18,9 @@ jobs: username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} script: | - cd /home/sk38808738 + cd /home/sk38808738/main-server git pull origin develop + chmod +x gradlew sudo ./gradlew bootJar - sudo docker-compose pull - sudo docker-compose up -d + sudo docker-compose up --build -d + sudo docker image prune -f \ No newline at end of file From 5c540e173fb6ab5cb697b51b02e4fb2ae24b59bc Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 12:41:34 +0900 Subject: [PATCH 014/136] Fix: Setup project directory and database configuration --- .github/workflows/deploy.yml | 1 - src/main/resources/application.yml | 13 +++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 241e2ec..d07c818 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,4 +23,3 @@ jobs: chmod +x gradlew sudo ./gradlew bootJar sudo docker-compose up --build -d - sudo docker image prune -f \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d6b1b5f..ae54fe5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,13 @@ server: spring: datasource: - url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root password: veriq123 driver-class-name: com.mysql.cj.jdbc.Driver + # DB 연결을 최대 30초까지 기다려주는 인내심 설정 + hikari: + connection-timeout: 30000 jpa: hibernate: @@ -15,4 +18,10 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: veriq-redis + port: 6379 \ No newline at end of file From acb1a3f9b68e808eb366fe08f92453abbbd90799 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 3 Apr 2026 15:52:38 +0900 Subject: [PATCH 015/136] Fix-Final sync: Docker config and dual profiles --- Dockerfile | 11 +- build.gradle | 1 + docker-compose.yml | 6 + .../veriqgateway/VeriQGatewayApplication.java | 27 ++++ .../controller/GatewayController.java | 129 ++++++++++++++++++ .../veriqgateway/dto/CaptchaRequest.java | 15 ++ .../dto/GoogleCaptchaResponse.java | 16 +++ .../veriq/veriqgateway/dto/ScanResponse.java | 13 ++ .../veriqgateway/service/SecurityService.java | 78 +++++++++++ src/main/resources/application-be1.yml | 27 ++++ src/main/resources/application-be3.yml | 27 ++++ 11 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java create mode 100644 src/main/java/com/veriq/veriqgateway/controller/GatewayController.java create mode 100644 src/main/java/com/veriq/veriqgateway/dto/CaptchaRequest.java create mode 100644 src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java create mode 100644 src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java create mode 100644 src/main/java/com/veriq/veriqgateway/service/SecurityService.java create mode 100644 src/main/resources/application-be1.yml create mode 100644 src/main/resources/application-be3.yml diff --git a/Dockerfile b/Dockerfile index cd06ca0..9cee869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -FROM eclipse-temurin:21-jdk-jammy -WORKDIR /app -# 빌드된 JAR를 app.jar로 복사 (파일명에 상관없이 자동 매칭) -COPY build/libs/*.jar app.jar -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +FROM eclipse-temurin:21-jdk +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +# 기본 실행 명령은 비워두거나 기본값 설정 (docker-compose에서 덮어씀) +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8170819..002f24b 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index 6da9d3f..855aed3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,9 @@ services: restart: always ports: - "8081:8081" + volumes: + - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs + entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be1", "/home/sk38808738/main-server/build/libs/app.jar" ] depends_on: - veriq-db - veriq-redis @@ -40,6 +43,9 @@ services: restart: always ports: - "8083:8083" + volumes: + - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs + entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be3", "/home/sk38808738/main-server/build/libs/app.jar" ] depends_on: - veriq-db - veriq-redis diff --git a/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java b/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java new file mode 100644 index 0000000..d640da7 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java @@ -0,0 +1,27 @@ +package com.veriq.veriqgateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestTemplate; + + + + +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) + +public class VeriQGatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(VeriQGatewayApplication.class, args); + } + /** + * 고근 님(BE 3) 서버와 통신할 때 사용할 RestTemplate 빈을 등록 + */ + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java new file mode 100644 index 0000000..1e15646 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -0,0 +1,129 @@ +package com.veriq.veriqgateway.controller; +import com.veriq.veriqgateway.dto.ScanResponse; +import com.veriq.veriqgateway.service.SecurityService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; +import com.veriq.veriqgateway.dto.CaptchaRequest; +import java.util.UUID; +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class GatewayController { + private final SecurityService securityService; + private final RestTemplate restTemplate; + + // 🔗 고근 님(BE 3) 서버의 실제 API 주소 + // 실전에서는 localhost 대신 도커 네트워크 서비스 명칭을 쓰기도 하지만, + // 지금은 로컬 테스트용으로 작성했습니다. + private final String BE3_URL = "http://localhost:8083/api/v1/scan/upload"; + + /** + * [POST] /api/v1/scan + * 1. Redis(도커)를 통한 5회 제한 보안 검사 + * 2. 통과 시 BE 3 서버로 이미지 및 guestId 전송 + */ + @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity processScan( + @RequestHeader("guest_uuid") String guestUuid, + @RequestParam("image") MultipartFile image) { + + // --- [STEP 1] 보안 검사 (Redis 연동) --- + // 도커로 띄운 Redis에 접속해 해당 guestId의 요청 횟수를 체크합니다. + if (!securityService.isAllowed(guestUuid)) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) // 429 에러 + .body(ScanResponse.builder() + .guestUuid(guestUuid) + .status("REJECTED") + .error_code("REQUIRE_CAPTCHA") + .message("요청 횟수 초과. 안전한 이용을 위해 캡차 인증이 필요합니다.") + .build()); + } + + // --- [STEP 2] 데이터 위임 (BE 3로 전송) --- + // 보안 검사를 통과했다면, 받은 이미지와 guestId를 고근 님 서버로 전달합니다. + try { + // 멀티파트 형식의 바구니 생성 (파일 전송용) + MultiValueMap body = new LinkedMultiValueMap<>(); + //Resource imageResource = image.getResource(); + // RestTemplate은 파일명이 없으면 8081에서 "파일이 아니다"라고 판단할 수 있습니다. + Resource fileResource = new ByteArrayResource(image.getBytes()) { + @Override + public String getFilename() { + return image.getOriginalFilename(); // 여기서 파일명을 넘겨주는게 핵심! + } + }; + + body.add("image", fileResource);// 받은 이미지 파일을 그대로 담음 + //body.add("guestUuid", guestUuid); + + // 전송할 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + //headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("guest_uuid", guestUuid); // BE3 서버도 누가 보냈는지 알아야 함 + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // RestTemplate을 이용해 서버(BE 3) 호출 + restTemplate.postForEntity(BE3_URL, requestEntity, String.class); + + // --- [STEP 3] 최종 성공 응답 --- + return ResponseEntity.ok(ScanResponse.builder() + .guestUuid(guestUuid) + .status("PENDING") + .message("보안 검사 통과 및 분석 요청이 성공적으로 전달되었습니다.") + .build()); + + } catch (Exception e) { + // 고근 님 서버가 꺼져 있거나 통신 에러가 났을 경우 처리 + e.printStackTrace(); // 1. 에러의 원인(Stack Trace)을 콘솔에 강제로 출력합니다. + System.out.println(">>> 에러 메세지: " + e.getMessage()); // 2. 에러 메세지 요약 출력 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ScanResponse.builder() + .guestUuid(guestUuid) + .status("ERROR") + .message("플랫폼 서버(BE 3)와의 연결에 실패했습니다: " + e.getMessage()) + .build()); + } + } + + /** + * [POST] /api/v1/auth/captcha/verify + * 캡차 성공 시 해당 사용자의 Redis 제한 카운트를 초기화합니다. + */ + + @PostMapping("/auth/captcha/verify") + public ResponseEntity verifyCaptcha(@RequestBody CaptchaRequest request) { + + // [STEP 1] 구글 실전 검증 + String currentUuid = request.getGuestUuid(); + boolean isHuman = securityService.verifyWithGoogle(request.getCaptchaToken()); + + if (!isHuman) { + // 매크로인인 경우 + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ScanResponse.builder() + .guestUuid(currentUuid) + .status("REJECTED") + .message("캡차 검증에 실패했습니다. 다시 시도해 주세요.") + .build()); + } + + // [STEP 2] 검증 성공 시 Redis 카운트 초기화 + securityService.resetCount(currentUuid); + + // [STEP 3] 성공 응답 (프런트가 메시지를 받고 스캔을 재시도함) + return ResponseEntity.ok(ScanResponse.builder() + .guestUuid(currentUuid) + .status("SUCCESS") + .message("캡차 인증 성공! 이제 다시 스캔을 이용하실 수 있습니다.") + .build());} +} diff --git a/src/main/java/com/veriq/veriqgateway/dto/CaptchaRequest.java b/src/main/java/com/veriq/veriqgateway/dto/CaptchaRequest.java new file mode 100644 index 0000000..8aca5b7 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/CaptchaRequest.java @@ -0,0 +1,15 @@ +package com.veriq.veriqgateway.dto; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder + +public class CaptchaRequest { + + private String captchaToken; // 구글에서 준 토큰 + private String guestUuid ; // 초기화할 유저 ID +} diff --git a/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java b/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java new file mode 100644 index 0000000..7e68c5b --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java @@ -0,0 +1,16 @@ +package com.veriq.veriqgateway.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.List; +@Getter +@NoArgsConstructor + +public class GoogleCaptchaResponse { + private boolean success; // 진짜 사람이면 true + private String challenge_ts; // 인증 시간 + private String hostname; // 요청된 도메인 + + @JsonProperty("error-codes") + private List errorCodes; // 실패 시 에러 코드들 +} diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java new file mode 100644 index 0000000..64c221a --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -0,0 +1,13 @@ +package com.veriq.veriqgateway.dto; +import lombok.Builder; +import lombok.Getter; +@Getter +@Builder + + +public class ScanResponse { + String guestUuid; // 사용자 식별용 + private String status; // PENDING 등 + private String error_code; // REQUIRE_CAPTCHA + private String message; +} diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java new file mode 100644 index 0000000..87fbeed --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -0,0 +1,78 @@ +package com.veriq.veriqgateway.service; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.client.RestTemplate; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import com.veriq.veriqgateway.dto.GoogleCaptchaResponse; +@Service +@RequiredArgsConstructor + +public class SecurityService { + // Redis 조작을 위한 스프링 표준 템플릿 + private final StringRedisTemplate redisTemplate; + + // 사용자(Guest)에게 허용할 최대 스캔 횟수 + private static final int LIMIT = 10; + private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 + @Value("${google.recaptcha.secret-key}") + private String secretKey; + @Value("${google.recaptcha.verify-url}") + private String verifyUrl; + + /** + * 유저의 요청이 허용 범위 내에 있는지 확인합니다. + * @param guestUuid 사용자 식별자 + * @return 허용 여부 (true: 통과, false: 캡차 필요) + */ + public boolean isAllowed(String guestUuid) { + // Redis에 저장할 키 이름 (예: limit:user-1234) + String key = "limit:" + guestUuid; + + // Redis에서 현재 카운트 값을 가져옴 + String val = redisTemplate.opsForValue().get(key); + int count = (val == null) ? 0 : Integer.parseInt(val); + + // 1. 요청 횟수가 제한치(5회)에 도달했는지 확인 + if (count >= LIMIT) { + return false; // 더 이상 허용하지 않음 + } + + // 2. 카운트 증가 처리 + if (count == 0) { + // 처음 요청하는 경우, 1을 저장하고 1시간 후 자동 삭제(TTL) 설정 + redisTemplate.opsForValue().set(key, "1", Duration.ofHours(1)); + } else { + // 이미 기록이 있는 경우 카운트만 1 증가 + redisTemplate.opsForValue().increment(key); + } + + return true; // 보안 통과 + } + + /** + * 캡차 인증에 성공했을 때, 해당 유저의 요청 횟수 기록을 지워줍니다. + */ + public void resetCount(String guestUuid) { + redisTemplate.delete("limit:" + guestUuid); + } + public boolean verifyWithGoogle(String captchaToken) { + // 구글 API는 폼 데이터 형식을 원합니다. + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("secret", secretKey); + params.add("response", captchaToken); + + try { + // 구글에 "이 토큰 진짜야?"라고 물어봄 + GoogleCaptchaResponse response = restTemplate.postForObject(verifyUrl, params, GoogleCaptchaResponse.class); + return response != null && response.isSuccess(); + } catch (Exception e) { + System.err.println("구글 캡차 통신 에러: " + e.getMessage()); + return false; + } + } +} + diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml new file mode 100644 index 0000000..89f05b7 --- /dev/null +++ b/src/main/resources/application-be1.yml @@ -0,0 +1,27 @@ +server: + port: 8081 + +spring: + datasource: + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: veriq123 + driver-class-name: com.mysql.cj.jdbc.Driver + # DB 연결을 최대 30초까지 기다려주는 인내심 설정 + hikari: + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: veriq-redis + port: 6379 \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml new file mode 100644 index 0000000..ae54fe5 --- /dev/null +++ b/src/main/resources/application-be3.yml @@ -0,0 +1,27 @@ +server: + port: 8083 + +spring: + datasource: + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: veriq123 + driver-class-name: com.mysql.cj.jdbc.Driver + # DB 연결을 최대 30초까지 기다려주는 인내심 설정 + hikari: + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: veriq-redis + port: 6379 \ No newline at end of file From 2c0f2ef95349adc9790596f933f8a65ff094113d Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Mon, 6 Apr 2026 15:11:38 +0900 Subject: [PATCH 016/136] docs/Update readme.md --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 95793e8..fca3af6 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,12 @@ REDIS_PORT = 6379 ### **🍃 Spring Boot Dockerfile ```yaml -FROM eclipse-temurin:21-jdk-jammy -WORKDIR /app -# 빌드된 JAR를 app.jar로 복사 (파일명에 상관없이 자동 매칭) -COPY build/libs/*.jar app.jar -ENTRYPOINT ["java", "-jar", "app.jar"] +FROM eclipse-temurin:21-jdk +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +# 기본 실행 명령은 비워두거나 기본값 설정 (docker-compose에서 덮어씀) +ENTRYPOINT ["java", "-jar", "/app.jar"] ``` @@ -149,6 +150,9 @@ services: restart: always ports: - "8081:8081" + volumes: + - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs + entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be1", "/home/sk38808738/main-server/build/libs/app.jar" ] depends_on: - veriq-db - veriq-redis @@ -160,6 +164,9 @@ services: restart: always ports: - "8083:8083" + volumes: + - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs + entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be3", "/home/sk38808738/main-server/build/libs/app.jar" ] depends_on: - veriq-db - veriq-redis From 6e01d9f110fa419c0245006c5d163b1d4c17caab Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Wed, 8 Apr 2026 01:48:15 +0900 Subject: [PATCH 017/136] docs/Update readme.md Updated README to include environment setup and Docker configurations. --- README.md | 244 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 174 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index fca3af6..f1b600d 100644 --- a/README.md +++ b/README.md @@ -20,26 +20,89 @@ > **공통 외부 IP**: `34.64.218.236` > 모든 팀원은 로컬에서 테스트할 때 아래 설정값을 참조하여 접속 정보를 수정해 주세요. +> +> > 🚨 **중요: 보안을 위해 DB 비밀번호가 코드에서 제거되었습니다.** +> 프로젝트를 처음 클론받거나 Pull 한 후, 반드시 프로젝트 최상단(루트) 경로에 **`.env`** 파일을 생성해 주세요. +### 1️⃣ `.env` 파일 생성 (필수) +프로젝트 루트에 `.env` 파일을 만들고 아래 내용을 입력합니다. (비밀번호는 카톡방 공지 확인) +```text +MYSQL_ROOT_PASSWORD=여기에_공유된_비밀번호 +``` + +### **.🍃 Spring Boot +`src/main/resources/application-be1.yml` 아래와 같이 수정합니다. +```yaml + +server: + port: 8081 + +spring: + datasource: + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: ${MYSQL_ROOT_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + # DB 연결을 최대 30초까지 기다려주는 인내심 설정 + hikari: + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: veriq-redis + port: 6379 -### **.🍃 Spring Boot, ⚡ FastAPI ** -`src/main/resources/application.yml`,`.env` 파일의 설정을 아래와 같이 수정합니다. + + be3: + api: + url: "http://localhost:8083/api/v1/scan/upload" +``` +`src/main/resources/application-be3.yml` 아래와 같이 수정합니다. ```yaml +server: + port: 8083 spring: datasource: - # GCP MySQL 연결 설정 - url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root - password: [카톡방에 공유된 비밀번호] + password: ${MYSQL_ROOT_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + # DB 연결을 최대 30초까지 기다려주는 인내심 설정 + hikari: + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + data: redis: - # GCP Redis 연결 설정 host: veriq-redis port: 6379 ---- env +``` + +### **⚡FastAPI +--env파일을 다음과 같이 수정합니다 +```yaml # Database 연결 정보 DB_URL = "mysql+pymysql://root:[비밀번호]@34.64.218.236:3306/veriq_db" @@ -55,18 +118,26 @@ REDIS_PORT = 6379 -### **📦 Docker 배포 설정 (최상위 경로에 위치) +### **📦 인프라 및 배포 설정 파일 안내(Dockerfile) ### **🍃 Spring Boot Dockerfile + ```yaml FROM eclipse-temurin:21-jdk + +# 애플리케이션 전용 비루트 사용자 생성 +RUN groupadd -r appgroup && useradd -r -g appgroup appuser +WORKDIR /app + +# JAR 파일 복사 및 권한 부여 ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} app.jar +COPY --chown=appuser:appgroup ${JAR_FILE} /app/app.jar -# 기본 실행 명령은 비워두거나 기본값 설정 (docker-compose에서 덮어씀) -ENTRYPOINT ["java", "-jar", "/app.jar"] +# 사용자 전환 및 실행 +USER appuser +ENTRYPOINT ["java", "-jar", "/app/app.jar"] ``` @@ -81,46 +152,8 @@ COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` - - - - - - - - - - -### **GitHub Actions 자동 배포 (deploy.yml) -위치: .github/workflows/deploy.yml //각자 개발하는 인텔리제이나 fastapi에서 위치함 +### **🐳 docker-compose.yml(공통) (최상위 경로) ```yaml -name: Veri-Q Auto Deploy - -on: - push: - branches: [ "develop" ] # develop 브랜치 푸시 시 자동 배포 - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: SSH Remote Commands - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.GCP_IP }} - username: ${{ secrets.GCP_USER }} - key: ${{ secrets.GCP_SSH_KEY }} - script: | - cd /home/sk38808738 - sudo docker-compose pull # 최신 이미지 다운로드 - sudo docker-compose up -d # 컨테이너 재실행 -``` - -### **docker-compose.yml - -```yaml -version: '3.8' - services: # 1. MySQL 데이터베이스 veriq-db: @@ -128,9 +161,9 @@ services: container_name: veriq-db restart: always ports: - - "3306:3306" + - "127.0.0.1:3306:3306" # 외부 IP 접속 차단, 로컬 호스트만 허용 environment: - MYSQL_ROOT_PASSWORD: veriq123 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: veriq_db volumes: - ./mysql_data:/var/lib/mysql @@ -141,57 +174,128 @@ services: container_name: veriq-redis restart: always ports: - - "6379:6379" + - "127.0.0.1:6379:6379" - # 3. Spring BE1 (Gateway - 박상민 ) + # 3. Spring BE1 (Gateway) veriq-gateway: - image: eclipse-temurin:21-jdk + build: + context: . + dockerfile: Dockerfile container_name: veriq-be1 restart: always ports: - "8081:8081" - volumes: - - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs - entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be1", "/home/sk38808738/main-server/build/libs/app.jar" ] + environment: + SPRING_PROFILES_ACTIVE: be1 + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" # 도커 내부망 주소로 덮어쓰기 depends_on: - veriq-db - veriq-redis # 4. Spring BE3 (Platform/Data Hub) veriq-platform: - image: eclipse-temurin:21-jdk + build: + context: . + dockerfile: Dockerfile container_name: veriq-be3 restart: always ports: - "8083:8083" - volumes: - - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs - entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be3", "/home/sk38808738/main-server/build/libs/app.jar" ] + environment: + SPRING_PROFILES_ACTIVE: be3 depends_on: - veriq-db - veriq-redis # 5. FastAPI (분석 엔진) veriq-analysis: - image: python:3.9-slim + build: + context: ../analysis-engine + dockerfile: Dockerfile container_name: veriq-analysis restart: always ports: - - "8000:8000" + - "8000:8000" depends_on: - - veriq-db + - veriq-db + ``` -### * 배포 프로세스 + + + + + + + + +### **GitHub Actions 자동 배포(🍃 Spring Boot) (.github/workflows/deploy.yml) +develop 브랜치에 코드가 푸시되면 자동으로 GCP 서버에 배포됩니다. + +```yaml +name: Veri-Q Develop Deploy + +on: + push: + branches: [ "develop" ] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - name: SSH Remote Commands + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/sk38808738/main-server + git pull origin develop + chmod +x gradlew + sudo ./gradlew bootJar + + # GitHub Secrets에서 안전하게 비밀번호를 가져와 서버에 .env 파일 즉석 생성 + echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env + + # 컨테이너 빌드 및 재실행 + sudo docker-compose up --build -d +``` +### **GitHub Actions 자동 배포(⚡FastAPI) (.github/workflows/deploy.yml) +develop 브랜치에 코드가 푸시되면 자동으로 GCP 서버에 배포됩니다. + ```yaml -빌드: +name: FastAPI Auto Deploy -Spring: ./gradlew bootJar 실행하여 JAR 생성 +on: + push: + branches: [ "develop" ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands for FastAPI + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + # 1. FastAPI 폴더로 이동해서 최신 코드 pull + cd /home/sk38808738/analysis-engine + git pull origin develop + # 2. 도커 설계도가 있는 메인 서버 폴더로 이동 + cd /home/sk38808738/main-server + sudo docker-compose up --build -d veriq-analysis -FastAPI: 새 라이브러리 추가 시 pip freeze > requirements.txt 갱신 -푸시: develop 브랜치로 Push하면 서버에 자동 반영됩니다. ``` + + From 4afcd22a4941f50bf983f69ac1e28dd32864bdcb Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 8 Apr 2026 02:08:03 +0900 Subject: [PATCH 018/136] =?UTF-8?q?fix/server-develop:=20CI/CD=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=B0=8F=20=EB=8F=84?= =?UTF-8?q?=EC=BB=A4=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 ++ Dockerfile | 10 +++++--- docker-compose.yml | 35 +++++++++++++++----------- src/main/resources/application-be1.yml | 9 +++++-- src/main/resources/application-be3.yml | 2 +- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d07c818..52c757c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,4 +22,6 @@ jobs: git pull origin develop chmod +x gradlew sudo ./gradlew bootJar + echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env sudo docker-compose up --build -d + diff --git a/Dockerfile b/Dockerfile index 9cee869..f97abd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM eclipse-temurin:21-jdk +RUN groupadd -r appgroup && useradd -r -g appgroup appuser +WORKDIR /app ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} app.jar - -# 기본 실행 명령은 비워두거나 기본값 설정 (docker-compose에서 덮어씀) -ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file +COPY ${JAR_FILE} /app/app.jar +RUN chown -R appuser:appgroup /app +USER appuser +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 855aed3..9f53666 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: container_name: veriq-db restart: always ports: - - "3306:3306" + - "127.0.0.1:3306:3306" environment: - MYSQL_ROOT_PASSWORD: veriq123 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: veriq_db volumes: - ./mysql_data:/var/lib/mysql @@ -20,42 +20,49 @@ services: container_name: veriq-redis restart: always ports: - - "6379:6379" + - "127.0.0.1:6379:6379" # 3. Spring BE1 (Gateway - 박상민 ) veriq-gateway: - image: eclipse-temurin:21-jdk + build: + context: . + dockerfile: Dockerfile container_name: veriq-be1 restart: always ports: - "8081:8081" - volumes: - - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs - entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be1", "/home/sk38808738/main-server/build/libs/app.jar" ] + environment: + SPRING_PROFILES_ACTIVE: be1 + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" + depends_on: - veriq-db - veriq-redis # 4. Spring BE3 (Platform/Data Hub) veriq-platform: - image: eclipse-temurin:21-jdk + build: + context: . + dockerfile: Dockerfile container_name: veriq-be3 restart: always ports: - "8083:8083" - volumes: - - /home/sk38808738/main-server/build/libs:/home/sk38808738/main-server/build/libs - entrypoint: [ "java", "-jar", "-Dspring.profiles.active=be3", "/home/sk38808738/main-server/build/libs/app.jar" ] + environment: + SPRING_PROFILES_ACTIVE: be3 + depends_on: - veriq-db - veriq-redis # 5. FastAPI (분석 엔진) veriq-analysis: - image: python:3.9-slim + build: + context: ../analysis-engine + dockerfile: Dockerfile container_name: veriq-analysis restart: always ports: - - "8000:8000" + - "8000:8000" depends_on: - - veriq-db \ No newline at end of file + - veriq-db \ No newline at end of file diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 89f05b7..30ebb81 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -5,7 +5,7 @@ spring: datasource: url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root - password: veriq123 + password: ${MYSQL_ROOT_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: @@ -24,4 +24,9 @@ spring: data: redis: host: veriq-redis - port: 6379 \ No newline at end of file + port: 6379 + + + be3: + api: + url: "http://localhost:8083/api/v1/scan/upload" \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index ae54fe5..f5db5be 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -5,7 +5,7 @@ spring: datasource: url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root - password: veriq123 + password: ${MYSQL_ROOT_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: From d20bad4c7429d36875d668a12e3010433429dedb Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 8 Apr 2026 02:15:03 +0900 Subject: [PATCH 019/136] =?UTF-8?q?feat/KD-15:=20IP=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20Rate=20Limit=20=EB=B0=8F=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../controller/GatewayController.java | 43 +++++++++++++----- .../veriqgateway/service/SecurityService.java | 44 ++++++++++--------- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 8d569d6..0636041 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ out/ .gradle/ # 비밀번호가 담긴 설정 파일은 제외 src/main/resources/application.yml +# Environments +.env diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 1e15646..1599967 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -7,10 +7,13 @@ import org.springframework.http.*; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import com.veriq.veriqgateway.dto.CaptchaRequest; +import org.springframework.beans.factory.annotation.Value; + import java.util.UUID; @RestController @RequestMapping("/api/v1") @@ -22,21 +25,38 @@ public class GatewayController { // 🔗 고근 님(BE 3) 서버의 실제 API 주소 // 실전에서는 localhost 대신 도커 네트워크 서비스 명칭을 쓰기도 하지만, // 지금은 로컬 테스트용으로 작성했습니다. - private final String BE3_URL = "http://localhost:8083/api/v1/scan/upload"; - + //private final String BE3_URL = "http://localhost:8083/api/v1/scan/upload"; + @Value("${be3.api.url}") + private String be3Url; /** * [POST] /api/v1/scan * 1. Redis(도커)를 통한 5회 제한 보안 검사 * 2. 통과 시 BE 3 서버로 이미지 및 guestId 전송 */ + /** + * 클라이언트의 실제 IP를 추출하는 헬퍼 메서드 (도커/프록시 환경 대응) + */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 여러 IP가 거쳐온 경우 첫 번째 IP가 실제 클라이언트 IP + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( @RequestHeader("guest_uuid") String guestUuid, - @RequestParam("image") MultipartFile image) { + @RequestParam("image") MultipartFile image, + HttpServletRequest request) { // --- [STEP 1] 보안 검사 (Redis 연동) --- - // 도커로 띄운 Redis에 접속해 해당 guestId의 요청 횟수를 체크합니다. - if (!securityService.isAllowed(guestUuid)) { + // 도커로 띄운 클라이언트가 보낸 UUID가 아닌, 실제 IP를 식별자로 사용. + String clientIp = getClientIp(request); + if (!securityService.isAllowed(clientIp)) { return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) // 429 에러 .body(ScanResponse.builder() .guestUuid(guestUuid) @@ -73,7 +93,7 @@ public String getFilename() { HttpEntity> requestEntity = new HttpEntity<>(body, headers); // RestTemplate을 이용해 서버(BE 3) 호출 - restTemplate.postForEntity(BE3_URL, requestEntity, String.class); + restTemplate.postForEntity(be3Url, requestEntity, String.class); // --- [STEP 3] 최종 성공 응답 --- return ResponseEntity.ok(ScanResponse.builder() @@ -101,11 +121,14 @@ public String getFilename() { */ @PostMapping("/auth/captcha/verify") - public ResponseEntity verifyCaptcha(@RequestBody CaptchaRequest request) { + public ResponseEntity verifyCaptcha( + @RequestBody CaptchaRequest captchaReq, + HttpServletRequest request) { // [STEP 1] 구글 실전 검증 - String currentUuid = request.getGuestUuid(); - boolean isHuman = securityService.verifyWithGoogle(request.getCaptchaToken()); + String clientIp = getClientIp(request); + String currentUuid = captchaReq.getGuestUuid(); + boolean isHuman = securityService.verifyWithGoogle(captchaReq.getCaptchaToken()); if (!isHuman) { // 매크로인인 경우 @@ -118,7 +141,7 @@ public ResponseEntity verifyCaptcha(@RequestBody CaptchaRequest re } // [STEP 2] 검증 성공 시 Redis 카운트 초기화 - securityService.resetCount(currentUuid); + securityService.resetCount(clientIp); // [STEP 3] 성공 응답 (프런트가 메시지를 받고 스캔을 재시도함) return ResponseEntity.ok(ScanResponse.builder() diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index 87fbeed..b9d66c2 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -25,39 +25,43 @@ public class SecurityService { /** * 유저의 요청이 허용 범위 내에 있는지 확인합니다. - * @param guestUuid 사용자 식별자 + * @param clinetIp 사용자 식별자 * @return 허용 여부 (true: 통과, false: 캡차 필요) */ - public boolean isAllowed(String guestUuid) { - // Redis에 저장할 키 이름 (예: limit:user-1234) - String key = "limit:" + guestUuid; + public boolean isAllowed(String clinetIp) { + // Redis에 저장할 키 이름 (limit+ip) + String key = "limit:" + clinetIp; + // 1. [원자적 연산] 읽기 + 비교하기 전에 무조건 1을 먼저 증가시킵니다. + // Redis는 자체적으로 동시성을 제어하므로, 이 연산은 절대 꼬이지 않습니다. + Long currentCount = redisTemplate.opsForValue().increment(key); - // Redis에서 현재 카운트 값을 가져옴 - String val = redisTemplate.opsForValue().get(key); - int count = (val == null) ? 0 : Integer.parseInt(val); + if (currentCount == null) { + return false; // Redis 에러 대비 안전 장치 + } - // 1. 요청 횟수가 제한치(5회)에 도달했는지 확인 - if (count >= LIMIT) { - return false; // 더 이상 허용하지 않음 + // 2. 만약 방금 증가시킨 결과가 1이라면 (최초 요청이라면) + if (currentCount == 1L) { + // 1시간 뒤에 기록이 사라지도록 타이머(TTL)를 설정합니다. + redisTemplate.expire(key, Duration.ofHours(1)); } - // 2. 카운트 증가 처리 - if (count == 0) { - // 처음 요청하는 경우, 1을 저장하고 1시간 후 자동 삭제(TTL) 설정 - redisTemplate.opsForValue().set(key, "1", Duration.ofHours(1)); - } else { - // 이미 기록이 있는 경우 카운트만 1 증가 - redisTemplate.opsForValue().increment(key); + // 3. 만약 방금 올린 숫자가 제한선(LIMIT)을 넘어버렸다면? + if (currentCount > LIMIT) { + // 이번 요청은 차단할 것이므로, 방금 올렸던 카운트를 다시 빼서 원상복구(Rollback) 해줍니다. + redisTemplate.opsForValue().decrement(key); + return false; // 차단 } - return true; // 보안 통과 + // 4. 10회 이하라면 무사히 통과 + return true; // 통과 + } /** * 캡차 인증에 성공했을 때, 해당 유저의 요청 횟수 기록을 지워줍니다. */ - public void resetCount(String guestUuid) { - redisTemplate.delete("limit:" + guestUuid); + public void resetCount(String clinetIp) { + redisTemplate.delete("limit:" + clinetIp); } public boolean verifyWithGoogle(String captchaToken) { // 구글 API는 폼 데이터 형식을 원합니다. From 4b304b13d033abe85e259be66c5046bbeb6bd3a3 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 8 Apr 2026 02:56:38 +0900 Subject: [PATCH 020/136] fix/server-develop --- .env | 1 + .github/workflows/deploy.yml | 1 + src/main/resources/application-be1.yml | 3 +-- src/main/resources/application-be3.yml | 3 +-- 4 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..a0bd612 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +MYSQL_ROOT_PASSWORD=veriq123 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52c757c..6ff4d10 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,5 +23,6 @@ jobs: chmod +x gradlew sudo ./gradlew bootJar echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env + sudo docker-compose build --no-cache sudo docker-compose up --build -d diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 30ebb81..5d790d4 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -18,8 +18,7 @@ spring: properties: hibernate: format_sql: true - # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 - dialect: org.hibernate.dialect.MySQL8Dialect + data: redis: diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index f5db5be..a24530c 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -18,8 +18,7 @@ spring: properties: hibernate: format_sql: true - # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 - dialect: org.hibernate.dialect.MySQL8Dialect + data: redis: From cad71abf4f7268c9078a4a4c32f24a66397bf463 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 8 Apr 2026 11:13:55 +0900 Subject: [PATCH 021/136] =?UTF-8?q?fix/KD-15=20:CodeRabbit=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(Redis=20Lua=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?X-Forwarded-For=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 11 +----- .../veriqgateway/service/SecurityService.java | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 1599967..61bee3c 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -37,15 +37,8 @@ public class GatewayController { * 클라이언트의 실제 IP를 추출하는 헬퍼 메서드 (도커/프록시 환경 대응) */ private String getClientIp(HttpServletRequest request) { - String ip = request.getHeader("X-Forwarded-For"); - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getRemoteAddr(); - } - // 여러 IP가 거쳐온 경우 첫 번째 IP가 실제 클라이언트 IP - if (ip != null && ip.contains(",")) { - ip = ip.split(",")[0].trim(); - } - return ip; + + return request.getRemoteAddr(); } @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index b9d66c2..8a5c60a 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -8,6 +8,9 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import com.veriq.veriqgateway.dto.GoogleCaptchaResponse; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import java.util.Collections; @Service @RequiredArgsConstructor @@ -33,27 +36,37 @@ public boolean isAllowed(String clinetIp) { String key = "limit:" + clinetIp; // 1. [원자적 연산] 읽기 + 비교하기 전에 무조건 1을 먼저 증가시킵니다. // Redis는 자체적으로 동시성을 제어하므로, 이 연산은 절대 꼬이지 않습니다. - Long currentCount = redisTemplate.opsForValue().increment(key); + // 2. Lua 스크립트 작성: INCR과 EXPIRE를 쪼개질 수 없는 하나의 덩어리로 묶음 + String script = + "local current = redis.call('INCR', KEYS[1]) \n" + + "if current == 1 then \n" + + " redis.call('EXPIRE', KEYS[1], ARGV[1]) \n" + + "end \n" + + "return current"; - if (currentCount == null) { - return false; // Redis 에러 대비 안전 장치 - } + // 스크립트 객체 생성 (반환값은 Long 타입) + RedisScript redisScript = new DefaultRedisScript<>(script, Long.class); - // 2. 만약 방금 증가시킨 결과가 1이라면 (최초 요청이라면) - if (currentCount == 1L) { - // 1시간 뒤에 기록이 사라지도록 타이머(TTL)를 설정합니다. - redisTemplate.expire(key, Duration.ofHours(1)); + // 2. 스크립트 실행 (키 1개 전달, 만료 시간은 3600초(1시간) 전달) + Long currentCount = redisTemplate.execute( + redisScript, + Collections.singletonList(key), + "3600" + ); + + if (currentCount == null) { + return false; // Redis 통신 에러 대비 } - // 3. 만약 방금 올린 숫자가 제한선(LIMIT)을 넘어버렸다면? + // 3. 만약 방금 올린 숫자가 제한선(LIMIT)을 넘어버렸다면? (기존 로직 유지) if (currentCount > LIMIT) { - // 이번 요청은 차단할 것이므로, 방금 올렸던 카운트를 다시 빼서 원상복구(Rollback) 해줍니다. + // 차단할 것이므로 원상복구(Rollback) redisTemplate.opsForValue().decrement(key); - return false; // 차단 + return false; // 차단 } - // 4. 10회 이하라면 무사히 통과 - return true; // 통과 + // 4. 제한선 이하라면 무사히 통과 + return true; } From c804899b7eca41a80c638117ea3662122742fbde Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 8 Apr 2026 14:26:27 +0900 Subject: [PATCH 022/136] =?UTF-8?q?feat/KD-16:=EB=B9=84=20url=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=ED=95=9C=20url=20redis=EC=97=90=EC=84=9C=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../veriqbe3/dto/RedisScanHistoryDto.java | 23 ++++ .../veriq/veriqbe3/dto/RedisUrlCacheDto.java | 19 +++ .../veriqbe3/service/QrScanRedisService.java | 115 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java create mode 100644 src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java create mode 100644 src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java diff --git a/build.gradle b/build.gradle index 794f42a..32de111 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.fasterxml.jackson.core:jackson-databind' } tasks.named('test') { diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java new file mode 100644 index 0000000..2357d16 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -0,0 +1,23 @@ +package com.veriq.veriqbe3.dto; +import lombok.Builder; +import lombok.Getter; +import java.time.LocalDateTime; +@Getter +@Builder + +public class RedisScanHistoryDto { + private String schemeType; + private String typeInfo; + private String status; + private String scannedAt; // 스캔시간 + + // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. + public static RedisScanHistoryDto from(QrScanResponse response) { + return RedisScanHistoryDto.builder() + .schemeType(response.getSchemeType().name()) + .typeInfo(response.getTypeInfo()) + .status(response.getStatus()) + .scannedAt(LocalDateTime.now().toString()) // 현재 시간 자동 입력 + .build(); + } +} diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java new file mode 100644 index 0000000..dc8a33a --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java @@ -0,0 +1,19 @@ +package com.veriq.veriqbe3.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor + +public class RedisUrlCacheDto { + private String resultStatus; // SAFE, WARNING, CRITICAL + private int trustScore; // 85점 + private String riskLevel; // 낮음 + private String siteName; // 네이버 + private String sslStatus; // 유효함 + private String domainCreated; // 1997.12.15 + // 그 외 결과 페이지에 들어가는 모든 텍스트 정보들... +} diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java new file mode 100644 index 0000000..bd99640 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -0,0 +1,115 @@ +package com.veriq.veriqbe3.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.veriq.veriqbe3.dto.QrScanResponse; +import com.veriq.veriqbe3.dto.RedisScanHistoryDto; +import com.veriq.veriqbe3.dto.RedisUrlCacheDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.Duration; +@Slf4j +@Service +@RequiredArgsConstructor + +public class QrScanRedisService { + private final QrDecoder qrDecoder; + private final SchemeClassifier schemeClassifier; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + // ⭐ 고근 님이 작업하실 클래스 (나중에 고근님이 구현하시면 주석 해제) + // private final ProcessQrScan processQrScan; + + private static final int MAX_HISTORY_SIZE = 50; + private static final Duration HISTORY_TTL = Duration.ofDays(7); + + public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) throws Exception { + + String url = qrDecoder.decode(image); + SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); + + QrScanResponse response; + + if (!result.toBe2()) { + // [비 URL] + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status("COMPLETED").build(); + } else { + // [URL] Redis 캐시 확인 + String urlKey = "url_cache:" + result.typeInfo(); + String cachedResult = redisTemplate.opsForValue().get(urlKey); + + if (cachedResult != null) { + // redis 캐시 존재 + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status(cachedResult).build(); + } else { + // redis 캐시 없음(비동기 호출) + // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); + + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status("PROCESSING").build(); + } + } + + // Redis 스캔 히스토리 저장 + saveHistoryToRedis(guestUuid, response); + + return response; + } + /** + * 고근 님의 콜백 API에서 분석이 끝난 후 호출할 메서드 + */ + public void cacheAnalysisResult(String url, RedisUrlCacheDto resultDto) { + try { + // 1. 상세 리포트 캐싱 (String 타입으로 1:1 저장) + String urlKey = "url_cache:" + url; + String jsonCache = objectMapper.writeValueAsString(resultDto); + redisTemplate.opsForValue().set(urlKey, jsonCache, Duration.ofHours(24)); + + log.info("URL 상세 분석 결과 캐싱 완료: {}", url); + + } catch (Exception e) { + log.error("Redis 캐싱 실패 - URL: {}", url, e); + } + } + + private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { + try { + String historyKey = "history:" + guestUuid; + RedisScanHistoryDto item = RedisScanHistoryDto.from(response); + String jsonItem = objectMapper.writeValueAsString(item); + + redisTemplate.opsForList().leftPush(historyKey, jsonItem); + // 항상 50개만 남기고 옛날 데이터는 잘라버림 + redisTemplate.opsForList().trim(historyKey, 0, MAX_HISTORY_SIZE - 1); + redisTemplate.expire(historyKey, HISTORY_TTL); + } catch (Exception e) { + log.error("Redis 히스토리 저장 실패 - guestUuid: {}", guestUuid, e); + } + +} //스캔 내역 url클릭시 다시 프론트로 url에 상세보고서 보내는 api + public RedisUrlCacheDto getUrlDetail(String url) throws Exception { + // 1. Redis 키 생성 + String cacheKey = "url_cache:" + url; + + // 2. Redis에서 데이터 꺼내기 + String jsonCache = redisTemplate.opsForValue().get(cacheKey); + + if (jsonCache == null) { + // 캐시가 비어있다면? (만료되었거나 처음 보는 URL인 경우) + // 이때는 고근 님께 다시 분석 요청을 하거나, 에러를 던져야 합니다. + return null; + } + + // 3. JSON 문자열을 다시 자바 객체(RedisUrlCacheDto)로 변환해서 반환 + return objectMapper.readValue(jsonCache, RedisUrlCacheDto.class); + } + +} From d19b0158681109904887f9d7adf916c420100cee Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 10:10:24 +0900 Subject: [PATCH 023/136] =?UTF-8?q?@PrePersist=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 5b75984..e970e5e 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -25,7 +25,7 @@ public class ScanHistory { private SchemeType schemeType; private LocalDateTime scannedAt; - @PrePersist + @PrePersist //첫 insert직전 실행 public void prePersist() { this.scannedAt = LocalDateTime.now(); } From 4510862eaf521191a5a6d4d9e2ed2162480ae496 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 10:11:19 +0900 Subject: [PATCH 024/136] =?UTF-8?q?url=EC=9D=84=20gcp=EC=9D=98=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20ip=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d6b1b5f..1e2a8e8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: datasource: - url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://34.64.182.89/veriq_db?serverTimezone=Asia/Seoul username: root password: veriq123 driver-class-name: com.mysql.cj.jdbc.Driver From 3f27b101b808472ce3104b8e1c2e23d30d1d84b5 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 10:12:00 +0900 Subject: [PATCH 025/136] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index b42ad77..23ef52c 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -17,7 +17,7 @@ public class ProcessQrScan { private final SchemeClassifier schemeClassifier; private final ScanHistoryRepository scanHistoryRepository; - @Transactional // DB 저장 중 에러 발생 시 모든 작업을 롤백하기 위함 + @Transactional // DB 저장 중 에러 발생 시 롤백하기 위함 public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exception { String url = qrDecoder.decode(image); //이미지 디코드 @@ -34,7 +34,6 @@ public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exc String status = "COMPLETED"; - //WEB이나 단축 URL이면 if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { return QrScanResponse.builder() .guestUuid(guest_uuid) @@ -44,7 +43,7 @@ public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exc .build(); } return QrScanResponse.builder() - .guestUuid(guest_uuid) // FE가 보낸 식별자 + .guestUuid(guest_uuid) .schemeType(result.type()) .typeInfo(result.typeInfo()) .status(status) From c368d9d2684a93b631fe9464ebef46100ac18597 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 10:12:17 +0900 Subject: [PATCH 026/136] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 45367b2..01e5d40 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -8,7 +8,6 @@ @Builder public class QrScanResponse { private String guestUuid; - private SchemeType schemeType; private String typeInfo; private String status; From 91ca61c7050b8a22a846dab987fd54a71eccd9c1 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 10:34:21 +0900 Subject: [PATCH 027/136] =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95,=20web,shor?= =?UTF-8?q?t=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=EB=A7=8C=20db=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/service/ProcessQrScan.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index 23ef52c..f054027 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -24,17 +24,18 @@ public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exc SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); //scheme 분류 - ScanHistory history = ScanHistory.builder() //entity 생성 - .guestUuid(guest_uuid) - .originalUrl(url) - .extractedData(result.typeInfo()) - .schemeType(result.type()) - .build(); - scanHistoryRepository.save(history); //repository에 저장 - String status = "COMPLETED"; if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { + + ScanHistory history = ScanHistory.builder() //entity 생성 + .guestUuid(guest_uuid) + .originalUrl(url) + .extractedData(result.typeInfo()) + .schemeType(result.type()) + .build(); + scanHistoryRepository.save(history); //repository에 저장 + return QrScanResponse.builder() .guestUuid(guest_uuid) .schemeType(result.type()) From 80679e07c5644eb9497a1526aefe704e1a687cc4 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 9 Apr 2026 11:12:27 +0900 Subject: [PATCH 028/136] =?UTF-8?q?refactor/feat/KD-16:Redis=20url=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=82=A4=20=ED=95=B4=EC=8B=B1=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20TTL=20=EB=8F=99=EA=B8=B0=ED=99=94,=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/dto/RedisScanHistoryDto.java | 4 +- .../veriqbe3/service/QrScanRedisService.java | 40 +++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 2357d16..4e937ee 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -8,7 +8,7 @@ public class RedisScanHistoryDto { private String schemeType; private String typeInfo; - private String status; + //private String status; private String scannedAt; // 스캔시간 // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. @@ -16,7 +16,7 @@ public static RedisScanHistoryDto from(QrScanResponse response) { return RedisScanHistoryDto.builder() .schemeType(response.getSchemeType().name()) .typeInfo(response.getTypeInfo()) - .status(response.getStatus()) + //.status(response.getStatus()) .scannedAt(LocalDateTime.now().toString()) // 현재 시간 자동 입력 .build(); } diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index bd99640..03190ba 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -8,6 +8,7 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.springframework.util.DigestUtils; import java.time.Duration; @Slf4j @@ -25,6 +26,7 @@ public class QrScanRedisService { private static final int MAX_HISTORY_SIZE = 50; private static final Duration HISTORY_TTL = Duration.ofDays(7); + private static final Duration URL_CACHE_TTL = HISTORY_TTL; public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) throws Exception { @@ -40,14 +42,16 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th .status("COMPLETED").build(); } else { // [URL] Redis 캐시 확인 - String urlKey = "url_cache:" + result.typeInfo(); - String cachedResult = redisTemplate.opsForValue().get(urlKey); + String urlKey = buildUrlCacheKey(result.typeInfo()); + String cachedJson = redisTemplate.opsForValue().get(urlKey); - if (cachedResult != null) { + if (cachedJson != null) { + + RedisUrlCacheDto cacheDto = objectMapper.readValue(cachedJson, RedisUrlCacheDto.class); // redis 캐시 존재 response = QrScanResponse.builder() .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status(cachedResult).build(); + .status(cacheDto.getResultStatus()).build(); } else { // redis 캐시 없음(비동기 호출) // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); @@ -69,9 +73,9 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th public void cacheAnalysisResult(String url, RedisUrlCacheDto resultDto) { try { // 1. 상세 리포트 캐싱 (String 타입으로 1:1 저장) - String urlKey = "url_cache:" + url; + String urlKey = buildUrlCacheKey(url); String jsonCache = objectMapper.writeValueAsString(resultDto); - redisTemplate.opsForValue().set(urlKey, jsonCache, Duration.ofHours(24)); + redisTemplate.opsForValue().set(urlKey, jsonCache, URL_CACHE_TTL); log.info("URL 상세 분석 결과 캐싱 완료: {}", url); @@ -97,7 +101,7 @@ private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { } //스캔 내역 url클릭시 다시 프론트로 url에 상세보고서 보내는 api public RedisUrlCacheDto getUrlDetail(String url) throws Exception { // 1. Redis 키 생성 - String cacheKey = "url_cache:" + url; + String cacheKey = buildUrlCacheKey(url); // 2. Redis에서 데이터 꺼내기 String jsonCache = redisTemplate.opsForValue().get(cacheKey); @@ -111,5 +115,27 @@ public RedisUrlCacheDto getUrlDetail(String url) throws Exception { // 3. JSON 문자열을 다시 자바 객체(RedisUrlCacheDto)로 변환해서 반환 return objectMapper.readValue(jsonCache, RedisUrlCacheDto.class); } + /** + * URL 캐시 키를 안전하게 정규화하고 해싱하는 헬퍼 메서드 + */ + private String buildUrlCacheKey(String rawUrl) { + if (rawUrl == null) { + return "url_cache:empty"; + } + + // 1. 정규화: 앞뒤 공백 제거 및 소문자 변환 + String cleanUrl = rawUrl.trim().toLowerCase(); + + // 2. 쿼리 파라미터(?) 제거 (순수 도메인/경로만 캐싱하여 적중률 향상) + int questionIndex = cleanUrl.indexOf('?'); + if (questionIndex != -1) { + cleanUrl = cleanUrl.substring(0, questionIndex); + } + + // 3. 해싱: MD5 알고리즘을 사용하여 어떤 길이의 URL이든 짧고 안전한 문자열로 변환 + String hashedUrl = DigestUtils.md5DigestAsHex(cleanUrl.getBytes()); + + return "url_cache:" + hashedUrl; + } } From 9709a9536f2010d0a9a534ba640a62f4b21987dd Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 9 Apr 2026 21:44:32 +0900 Subject: [PATCH 029/136] =?UTF-8?q?fix/develop:Be3=5FURL=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20controller=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20application.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqgateway/controller/GatewayController.java | 4 +++- src/main/resources/application.yml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 1e15646..a52d0a7 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -11,6 +11,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import com.veriq.veriqgateway.dto.CaptchaRequest; +import org.springframework.beans.factory.annotation.Value; import java.util.UUID; @RestController @RequestMapping("/api/v1") @@ -22,7 +23,8 @@ public class GatewayController { // 🔗 고근 님(BE 3) 서버의 실제 API 주소 // 실전에서는 localhost 대신 도커 네트워크 서비스 명칭을 쓰기도 하지만, // 지금은 로컬 테스트용으로 작성했습니다. - private final String BE3_URL = "http://localhost:8083/api/v1/scan/upload"; + @Value("${be3.api.url}") + private String BE3_URL ; /** * [POST] /api/v1/scan diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 364bf67..6c50707 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,11 @@ server: port: 8083 spring: + profiles: + active: dev # 현재 활성화할 프로필 + include: + - be1 # application-be1.yml 을 읽어옴 + - be3 # application-be3.yml 을 읽어옴 datasource: url: jdbc:mysql://34.64.182.89/veriq_db?serverTimezone=Asia/Seoul username: root From a3e12d11f792f55d12c901267bc5452f27a34b54 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 22:27:57 +0900 Subject: [PATCH 030/136] =?UTF-8?q?be2=EC=97=90=EA=B2=8C=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=EB=B0=9B=EC=9D=84=20=EB=B6=84=EC=84=9D=20dto=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java new file mode 100644 index 0000000..9e195da --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -0,0 +1,65 @@ +package com.veriq.veriqbe3.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; +import java.util.List; + +public record AnalysisResponse( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX") + LocalDateTime analysisTime, + String originalUrl, + HttpsInfo https, + ShortUrlInfo shortUrl, + MlInfo ml, + ExternalApiInfo externalApi, + InternalDbInfo internalDb, + RedirectInfo redirect, + ServerInfo serverInfo, + int score, + String riskLevel +) { + public record HttpsInfo( + boolean isSecure + ) {} + + public record ShortUrlInfo( + boolean isShortened + ) {} + + public record MlInfo( + List threats, + int score + ) {} + + public record ExternalApiInfo( + boolean checked, + String provider, + String result // 실패/결과 없을 시 null 대응을 위해 String 유지 + ) {} + + public record InternalDbInfo( + boolean exists, + Integer reportCount, // null 대응을 위해 래퍼 클래스 Integer 사용 + Integer blockCount + ) {} + + public record RedirectInfo( + String finalUrl, + int redirectCount + ) {} + + public record ServerInfo( + String type, + String location, + CertificateInfo certificate + ) {} + + public record CertificateInfo( + boolean valid, + String issuer, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + LocalDateTime validFrom, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + LocalDateTime validTo + ) {} +} \ No newline at end of file From 773052e401ac555d7156dd2324874e1d0c998483 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 22:35:14 +0900 Subject: [PATCH 031/136] =?UTF-8?q?be2=EC=97=90=EA=B2=8C=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=EB=B0=9B=EC=9D=84=20=EB=B6=84=EC=84=9D=20dto=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A4=91=EC=97=90=EC=84=9C=20enum=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20riskLevel=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/domain/RiskLevel.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java diff --git a/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java b/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java new file mode 100644 index 0000000..4481a7b --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java @@ -0,0 +1,31 @@ +package com.veriq.veriqbe3.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum RiskLevel { + SAFE("safe"), + SUSPICIOUS("suspicious"), + DANGER("danger"); + + private final String value; + + RiskLevel(String value) { + this.value = value; + } + + @JsonValue // JSON으로 변환될 때나 응답할 때 소문자 값을 사용함 + public String getValue() { + return value; + } + + @JsonCreator // JSON의 문자열을 Enum으로 매핑할 때 사용함 + public static RiskLevel from(String value) { + for (RiskLevel level : RiskLevel.values()) { + if (level.value.equalsIgnoreCase(value)) { + return level; + } + } + return null; // 혹은 기본값 설정 + } +} \ No newline at end of file From 85134ca937302d0241ec3d27dace219be9113571 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 9 Apr 2026 22:35:17 +0900 Subject: [PATCH 032/136] refactor-fix/develop --- docker-compose.yml | 2 +- src/main/resources/application.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9f53666..b17aace 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: ports: - "8081:8081" environment: - SPRING_PROFILES_ACTIVE: be1 + SPRING_PROFILES_ACTIVE: prod,be1 BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" depends_on: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6c50707..585b3f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: dev # 현재 활성화할 프로필 + active: ${SPRING_PROFILES_ACTIVE:dev} include: - be1 # application-be1.yml 을 읽어옴 - be3 # application-be3.yml 을 읽어옴 From 2700f9374da1db67a002185d3f86240e703f1944 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:17:34 +0900 Subject: [PATCH 033/136] =?UTF-8?q?db=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20entity=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20final=5Furl=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8B=B1=20=EC=B6=94=EA=B0=80(be2=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=82=AC=ED=95=AD)=20-=20response=20json=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B3=84=EC=B8=B5=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/entity/ScanHistory.java | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index e970e5e..18af402 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -7,26 +7,87 @@ import java.time.LocalDateTime; @Entity -@Table(name = "scan_history") -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Table(name = "scan_history",indexes={@Index(name = "idx_final_url", columnList = "finalUrl")}) +@Getter @Builder @NoArgsConstructor @AllArgsConstructor + public class ScanHistory { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private String guestUuid; @Column(length = 1000) private String originalUrl; - @Column(length = 1000) - private String extractedData; - @Enumerated(EnumType.STRING) //어노테이션으로 enum의 숫자가 아닌 String 저장 + + private String typeInfo; + + @Enumerated(EnumType.STRING) private SchemeType schemeType; + private LocalDateTime scannedAt; - @PrePersist //첫 insert직전 실행 + @PrePersist public void prePersist() { this.scannedAt = LocalDateTime.now(); } + + private LocalDateTime analysisTime; + private int totalScore; + private String riskLevel; + + @Embedded private HttpsInfo https; + @Embedded private ShortUrlInfo shortUrl; + @Embedded private MlInfo ml; + @Embedded private ExternalApiInfo externalApi; + @Embedded private InternalDbInfo internalDb; + @Embedded private RedirectInfo redirect; + @Embedded private ServerInfo serverInfo; + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class HttpsInfo { private boolean isSecure; } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class ShortUrlInfo { private boolean isShortened; } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class MlInfo { + private String threats; + private int mlScore; + } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class ExternalApiInfo { + private boolean apiChecked; + private String apiProvider; + private String apiResult; + } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class InternalDbInfo { + private boolean dbExists; + private Integer dbReportCount; + private Integer dbBlockCount; + } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class RedirectInfo { + private String finalUrl; + private int redirectCount; + } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class ServerInfo { + private String serverType; + private String serverLocation; + @Embedded private CertificateInfo certificate; + } + + @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class CertificateInfo { + private boolean certValid; + private String certIssuer; + private LocalDateTime certValidFrom; + private LocalDateTime certValidTo; + } } \ No newline at end of file From 8cfcf38a4a7004a669932d63236b55131444a8fb Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:20:54 +0900 Subject: [PATCH 034/136] =?UTF-8?q?-=20entity=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=9D=BC=20build=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/service/ProcessQrScan.java | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index f054027..31e3fae 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -33,18 +33,73 @@ public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exc .originalUrl(url) .extractedData(result.typeInfo()) .schemeType(result.type()) + .analysisTime(response.analysisTime()) + .totalScore(response.score()) + .riskLevel(response.riskLevel()) + + // 1. HttpsInfo 빌드 + .https(ScanHistory.HttpsInfo.builder() + .isSecure(response.https() != null && response.https().isSecure()) + .build()) + + // 2. ShortUrlInfo 빌드 + .shortUrl(ScanHistory.ShortUrlInfo.builder() + .isShortened(response.shortUrl() != null && response.shortUrl().isShortened()) + .build()) + + // 3. MlInfo 빌드 + .ml(ScanHistory.MlInfo.builder() + .threats(response.ml() != null && response.ml().threats() != null + ? String.join(", ", response.ml().threats()) : null) + .mlScore(response.ml() != null ? response.ml().score() : 0) + .build()) + + // 4. ExternalApiInfo 빌드 + .externalApi(ScanHistory.ExternalApiInfo.builder() + .apiChecked(response.externalApi() != null && response.externalApi().checked()) + .apiProvider(response.externalApi() != null ? response.externalApi().provider() : null) + .apiResult(response.externalApi() != null ? response.externalApi().result() : null) + .build()) + + // 5. InternalDbInfo 빌드 + .internalDb(ScanHistory.InternalDbInfo.builder() + .dbExists(response.internalDb() != null && response.internalDb().exists()) + .dbReportCount(response.internalDb() != null ? response.internalDb().reportCount() : 0) + .dbBlockCount(response.internalDb() != null ? response.internalDb().blockCount() : 0) + .build()) + + // 6. RedirectInfo 빌드 + .redirect(ScanHistory.RedirectInfo.builder() + .finalUrl(response.redirect() != null ? response.redirect().finalUrl() : url) + .redirectCount(response.redirect() != null ? response.redirect().redirectCount() : 0) + .build()) + + // 7. ServerInfo 및 CertificateInfo + .serverInfo(ScanHistory.ServerInfo.builder() + .serverType(response.serverInfo() != null ? response.serverInfo().type() : null) + .serverLocation(response.serverInfo() != null ? response.serverInfo().location() : null) + .certificate(response.serverInfo() != null && response.serverInfo().certificate() != null + ? ScanHistory.CertificateInfo.builder() + .certValid(response.serverInfo().certificate().valid()) + .certIssuer(response.serverInfo().certificate().issuer()) + .certValidFrom(response.serverInfo().certificate().validFrom()) + .certValidTo(response.serverInfo().certificate().validTo()) + .build() + : null) + .build()) .build(); - scanHistoryRepository.save(history); //repository에 저장 + + scanHistoryRepository.save(scanHistory); //repository에 저장 return QrScanResponse.builder() - .guestUuid(guest_uuid) + .guestUuid(guestUuid) .schemeType(result.type()) .typeInfo(result.typeInfo()) .status("ANALYSIS_COMPLETED") // BE2 연동 후 실제 결과 상태로 변경 .build(); } return QrScanResponse.builder() - .guestUuid(guest_uuid) + .guestUuid(guestUuid) .schemeType(result.type()) .typeInfo(result.typeInfo()) .status(status) From 69acb225738824cb2cbfe8953dcad81e136c2472 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:21:28 +0900 Subject: [PATCH 035/136] =?UTF-8?q?-=20=EC=A7=81=EA=B4=80=EC=A0=81?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index 31e3fae..e0327ea 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -31,7 +31,7 @@ public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exc ScanHistory history = ScanHistory.builder() //entity 생성 .guestUuid(guest_uuid) .originalUrl(url) - .extractedData(result.typeInfo()) + .typeInfo(result.typeInfo()) .schemeType(result.type()) .analysisTime(response.analysisTime()) .totalScore(response.score()) From 211bb1a74a7c353d7c152f45c6af3a58d7e9cd2c Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:22:02 +0900 Subject: [PATCH 036/136] =?UTF-8?q?-=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EA=B0=99=EC=9D=80=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index e0327ea..b9ea35f 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -18,7 +18,7 @@ public class ProcessQrScan { private final ScanHistoryRepository scanHistoryRepository; @Transactional // DB 저장 중 에러 발생 시 롤백하기 위함 - public QrScanResponse process(MultipartFile image, String guest_uuid) throws Exception { + public QrScanResponse process(MultipartFile image, String guestUuid) throws Exception { String url = qrDecoder.decode(image); //이미지 디코드 From 09bdd59cde4f9fcee6a20a51abd493eab7b4b990 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:22:39 +0900 Subject: [PATCH 037/136] =?UTF-8?q?-=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/service/ProcessQrScan.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java index b9ea35f..58b3ad3 100644 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java @@ -1,6 +1,7 @@ package com.veriq.veriqbe3.service; import com.veriq.veriqbe3.domain.SchemeType; +import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqbe3.entity.ScanHistory; import com.veriq.veriqbe3.repository.ScanHistoryRepository; @@ -9,6 +10,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class ProcessQrScan { @@ -28,8 +31,24 @@ public QrScanResponse process(MultipartFile image, String guestUuid) throws Exce if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { - ScanHistory history = ScanHistory.builder() //entity 생성 - .guestUuid(guest_uuid) + // BE2 response 테스트값 + AnalysisResponse response=new AnalysisResponse( + LocalDateTime.now(), // analysisTime + url, // originalUrl + new AnalysisResponse.HttpsInfo(true), + new AnalysisResponse.ShortUrlInfo(false), + new AnalysisResponse.MlInfo(java.util.List.of("safe"), 10), + new AnalysisResponse.ExternalApiInfo(true, "Google", "Clean"), + new AnalysisResponse.InternalDbInfo(false, 0, 0), + new AnalysisResponse.RedirectInfo(url, 0), + new AnalysisResponse.ServerInfo("Nginx", "KR", + new AnalysisResponse.CertificateInfo(true, "DigiCert", LocalDateTime.now(), LocalDateTime.now().plusYears(1))), + 95, // score + "SAFE" // riskLevel + ); + + ScanHistory scanHistory = ScanHistory.builder() + .guestUuid(guestUuid) .originalUrl(url) .typeInfo(result.typeInfo()) .schemeType(result.type()) From e9948060d02f2474b915d6b0565f2b617457cdf8 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Thu, 9 Apr 2026 23:23:31 +0900 Subject: [PATCH 038/136] =?UTF-8?q?-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A4=91=20=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/controller/QrScanController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index a01833f..5a46a0f 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -24,6 +24,7 @@ public ResponseEntity uploadQrImage( //반환 http body 타 return ResponseEntity.ok(response); } catch (Exception e) { + e.printStackTrace(); return ResponseEntity.badRequest().build(); } } From 6553bba941b5906f003aed18584747629c77dce5 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 9 Apr 2026 23:25:49 +0900 Subject: [PATCH 039/136] refactor-fix/develop --- docker-compose.yml | 2 +- .../com/veriq/veriqgateway/controller/GatewayController.java | 2 ++ src/main/resources/application-be3.yml | 3 ++- src/main/resources/application.yml | 4 +--- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b17aace..9f53666 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: ports: - "8081:8081" environment: - SPRING_PROFILES_ACTIVE: prod,be1 + SPRING_PROFILES_ACTIVE: be1 BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" depends_on: diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index a52d0a7..2489651 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -1,6 +1,7 @@ package com.veriq.veriqgateway.controller; import com.veriq.veriqgateway.dto.ScanResponse; import com.veriq.veriqgateway.service.SecurityService; +import org.springframework.context.annotation.Profile; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import lombok.RequiredArgsConstructor; @@ -16,6 +17,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor +@Profile("be1") public class GatewayController { private final SecurityService securityService; private final RestTemplate restTemplate; diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index a24530c..3b06738 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -23,4 +23,5 @@ spring: data: redis: host: veriq-redis - port: 6379 \ No newline at end of file + port: 6379 + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 585b3f2..1008cdb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,9 +4,7 @@ server: spring: profiles: active: ${SPRING_PROFILES_ACTIVE:dev} - include: - - be1 # application-be1.yml 을 읽어옴 - - be3 # application-be3.yml 을 읽어옴 + datasource: url: jdbc:mysql://34.64.182.89/veriq_db?serverTimezone=Asia/Seoul username: root From e159f635e71edbd6e937f5231623d840a13b80a5 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 9 Apr 2026 23:42:50 +0900 Subject: [PATCH 040/136] refactor-fix/develop --- .../config/ProfileValidationConfig.java | 27 +++++++++++++++++++ src/main/resources/application.yml | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/veriq/veriqgateway/config/ProfileValidationConfig.java diff --git a/src/main/java/com/veriq/veriqgateway/config/ProfileValidationConfig.java b/src/main/java/com/veriq/veriqgateway/config/ProfileValidationConfig.java new file mode 100644 index 0000000..8ef72d1 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/config/ProfileValidationConfig.java @@ -0,0 +1,27 @@ +package com.veriq.veriqgateway.config; +import jakarta.annotation.PostConstruct; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor + +public class ProfileValidationConfig { + private final Environment env; + + @PostConstruct + public void validateProfile() { + // 현재 켜진 프로필 목록을 가져옵니다. + String[] activeProfiles = env.getActiveProfiles(); + + // 프로필이 하나도 설정되지 않았다면 강제로 예외를 발생시켜 서버를 끕니다! + if (activeProfiles.length == 0) { + throw new IllegalStateException( + "🚨 [치명적 에러] 활성화된 스프링 프로필(SPRING_PROFILES_ACTIVE)이 없습니다! " + + "배포 환경 변수나 로컬 실행 옵션에 'be1' 또는 'be3'를 반드시 설정하고 다시 실행해 주세요." + ); + } + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1008cdb..5e9d57b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:} datasource: url: jdbc:mysql://34.64.182.89/veriq_db?serverTimezone=Asia/Seoul From 1e5596ed4b028691c62738ac33539e3e45f3bd99 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 01:06:39 +0900 Subject: [PATCH 041/136] =?UTF-8?q?fix:=20MySQL8Dialect=EB=A5=BC=20MySQLDi?= =?UTF-8?q?alect=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=98=EC=97=AC=20Hiberna?= =?UTF-8?q?te=207=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e9d57b..0c0ae46 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,7 +22,7 @@ spring: hibernate: format_sql: true # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 - dialect: org.hibernate.dialect.MySQL8Dialect + dialect: org.hibernate.dialect.MySQLDialect data: redis: From 1324b01aa77a2d4d3a0f0eef347d649c11288584 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 01:39:19 +0900 Subject: [PATCH 042/136] =?UTF-8?q?fix:=20docker-compose.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 9f53666..dcaa2a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: - "8081:8081" environment: SPRING_PROFILES_ACTIVE: be1 + SERVER_PORT: 8081 BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" depends_on: From afe351077befd26814bbbc10c3fea0395ccb3e0f Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 09:10:54 +0900 Subject: [PATCH 043/136] =?UTF-8?q?fix:=20Dockerfile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f97abd7..59ef4e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,26 @@ +# 1단계: 빌드 스테이지 (Gradle 환경에서 직접 빌드) +FROM gradle:8.5-jdk21 AS build +WORKDIR /app + +# 빌드에 필요한 파일들만 먼저 복사 +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . +COPY src src + +# gradlew 실행 권한 부여 및 빌드 (clean을 포함해서 찌꺼기 제거!) +RUN chmod +x ./gradlew +RUN ./gradlew clean build -x test + +# 2단계: 실행 스테이지 (경량화된 이미지) FROM eclipse-temurin:21-jdk RUN groupadd -r appgroup && useradd -r -g appgroup appuser WORKDIR /app -ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} /app/app.jar + +# 1단계(build)에서 생성된 따끈따끈한 jar 파일만 쏙 가져오기 +COPY --from=build /app/build/libs/*.jar /app/app.jar + RUN chown -R appuser:appgroup /app USER appuser ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file From a5ddd437c032daf4c8968051b8501fa3a5f26772 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 09:22:07 +0900 Subject: [PATCH 044/136] =?UTF-8?q?fix:=20Dockerfile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 59ef4e5..1c0dc9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,21 @@ # 1단계: 빌드 스테이지 (Gradle 환경에서 직접 빌드) +# 1단계: 빌드 스테이지 FROM gradle:8.5-jdk21 AS build WORKDIR /app -# 빌드에 필요한 파일들만 먼저 복사 -COPY gradlew . -COPY gradle gradle -COPY build.gradle . -COPY settings.gradle . -COPY src src +# 현재 폴더의 모든 파일을 도커 안으로 복사 +COPY . . -# gradlew 실행 권한 부여 및 빌드 (clean을 포함해서 찌꺼기 제거!) -RUN chmod +x ./gradlew -RUN ./gradlew clean build -x test -# 2단계: 실행 스테이지 (경량화된 이미지) + +RUN gradle clean build -x test + +# 2단계: 실행 스테이지 FROM eclipse-temurin:21-jdk RUN groupadd -r appgroup && useradd -r -g appgroup appuser WORKDIR /app -# 1단계(build)에서 생성된 따끈따끈한 jar 파일만 쏙 가져오기 +# 1단계에서 빌드된 jar 파일 가져오기 COPY --from=build /app/build/libs/*.jar /app/app.jar RUN chown -R appuser:appgroup /app From 60e61470554a376d4ce4857f3ca78fbafb4f1410 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 09:25:45 +0900 Subject: [PATCH 045/136] =?UTF-8?q?fix:=20Dockerfile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c0dc9b..0342783 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,18 @@ -# 1단계: 빌드 스테이지 (Gradle 환경에서 직접 빌드) -# 1단계: 빌드 스테이지 -FROM gradle:8.5-jdk21 AS build +# 1단계: 빌드 스테이지 (Gradle 버전을 최신으로 올립니다) +FROM gradle:jdk21 AS build WORKDIR /app -# 현재 폴더의 모든 파일을 도커 안으로 복사 +# 모든 소스 파일 복사 COPY . . - - +# 설치된 최신 버전의 gradle로 빌드 RUN gradle clean build -x test -# 2단계: 실행 스테이지 +# 2단계: 실행 스테이지 (이후는 동일) FROM eclipse-temurin:21-jdk RUN groupadd -r appgroup && useradd -r -g appgroup appuser WORKDIR /app - -# 1단계에서 빌드된 jar 파일 가져오기 COPY --from=build /app/build/libs/*.jar /app/app.jar - RUN chown -R appuser:appgroup /app USER appuser ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file From b607d7b19652c6e032185e8f67d646627d5074ef Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 09:56:33 +0900 Subject: [PATCH 046/136] =?UTF-8?q?fix:=20Dockerfile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 16 ++++++++++------ build.gradle | 13 +++++++++++++ docker-compose.yml | 5 +++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0342783..5e96c29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,22 @@ -# 1단계: 빌드 스테이지 (Gradle 버전을 최신으로 올립니다) +# 1단계: 빌드 스테이지 FROM gradle:jdk21 AS build WORKDIR /app - -# 모든 소스 파일 복사 COPY . . -# 설치된 최신 버전의 gradle로 빌드 -RUN gradle clean build -x test +# 빌드 인자로 메인 클래스를 받습니다 (기본값은 Gateway) +ARG MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication + +# 지정된 메인 클래스로 빌드하도록 명령 +RUN gradle clean build -x test -PmainClass=${MAIN_CLASS} -# 2단계: 실행 스테이지 (이후는 동일) +# 2단계: 실행 스테이지 FROM eclipse-temurin:21-jdk RUN groupadd -r appgroup && useradd -r -g appgroup appuser WORKDIR /app + +# 빌드된 jar 파일 가져오기 COPY --from=build /app/build/libs/*.jar /app/app.jar + RUN chown -R appuser:appgroup /app USER appuser ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 002f24b..4ef127a 100644 --- a/build.gradle +++ b/build.gradle @@ -44,4 +44,17 @@ tasks.named('test') { } jar { enabled = false +} + +// 외부에서 전달된 mainClass 파라미터가 있으면 그걸 사용하고, 없으면 기본값 사용 +def selectedMainClass = project.hasProperty('mainClass') ? project.getProperty('mainClass') : 'com.veriq.veriqgateway.VeriQGatewayApplication' + +// 2. Spring Boot가 실행 가능한 jar를 만들 때 이 클래스를 메인으로 사용하도록 설정 +bootJar { + mainClass = selectedMainClass +} + +// 3. (중요) Gradle 자체의 메인 클래스 설정도 동기화해줍니다. +springBoot { + mainClass = selectedMainClass } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index dcaa2a3..ccabb2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,8 @@ services: build: context: . dockerfile: Dockerfile + args: + - MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication # 👈 Gateway 메인 지정 container_name: veriq-be1 restart: always ports: @@ -45,12 +47,15 @@ services: build: context: . dockerfile: Dockerfile + args: + - MAIN_CLASS=com.veriq.veriqbe3.VeriQBe3Application container_name: veriq-be3 restart: always ports: - "8083:8083" environment: SPRING_PROFILES_ACTIVE: be3 + SERVER_PORT: 8083 depends_on: - veriq-db From bce48c73ab8e2ecca80b8c7c7a5a83b6df6cd368 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 11:04:46 +0900 Subject: [PATCH 047/136] =?UTF-8?q?fix:=20application-be1.yml=20=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be1.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 5d790d4..86086cd 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -19,6 +19,12 @@ spring: hibernate: format_sql: true + oogle: + recaptcha: + # 환경변수로 받거나, 일단 에러 안 나게 더미값이라도 넣어줍니다. + secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY:6LcFLIssAAAAAPFDzhaixpw0fnxyQW-ieQS8O8TD} + + data: redis: @@ -28,4 +34,4 @@ spring: be3: api: - url: "http://localhost:8083/api/v1/scan/upload" \ No newline at end of file + url: "http://veriq-platform:8083/api/v1/scan/upload" \ No newline at end of file From 3f832b279371a790811407bec7db1bf1a1fb786d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 12:34:35 +0900 Subject: [PATCH 048/136] =?UTF-8?q?fix:=20application-be1.yml=20=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be1.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 86086cd..fa71a88 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -19,10 +19,11 @@ spring: hibernate: format_sql: true - oogle: + google: recaptcha: # 환경변수로 받거나, 일단 에러 안 나게 더미값이라도 넣어줍니다. secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY:6LcFLIssAAAAAPFDzhaixpw0fnxyQW-ieQS8O8TD} + verify-url: https://www.google.com/recaptcha/api/siteverify From 8ffc03af027690ad657488749f07a4fcca3cbe1e Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 12:55:06 +0900 Subject: [PATCH 049/136] =?UTF-8?q?fix:=20application-be1.yml=20=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be1.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index fa71a88..b4245bd 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -10,6 +10,10 @@ spring: # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: connection-timeout: 30000 + servlet: + multipart: + max-file-size: 10MB # 개별 파일 당 최대 용량 + max-request-size: 10MB # 한 번의 요청 당 최대 용량 jpa: hibernate: From ac732a755747068540924b2d536771ec8068a891 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 10 Apr 2026 15:20:18 +0900 Subject: [PATCH 050/136] =?UTF-8?q?-=20application.yml=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=95=98=EB=82=98=EB=A1=9C=20=EC=95=95?= =?UTF-8?q?=EC=B6=95=20-=20delploy.yml=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 5 ++- .../controller/GatewayController.java | 4 +- src/main/resources/application-be1.yml | 37 --------------- src/main/resources/application-be3.yml | 27 ----------- src/main/resources/application.yml | 45 +++++++++++++------ 5 files changed, 35 insertions(+), 83 deletions(-) delete mode 100644 src/main/resources/application-be1.yml delete mode 100644 src/main/resources/application-be3.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ff4d10..e475173 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,8 +17,9 @@ jobs: host: ${{ secrets.GCP_IP }} username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} - script: | - cd /home/sk38808738/main-server + script: + git config --global --add safe.directory /opt/main-server + cd /opt/main-server git pull origin develop chmod +x gradlew sudo ./gradlew bootJar diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 2489651..52595ed 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -13,7 +13,6 @@ import org.springframework.web.multipart.MultipartFile; import com.veriq.veriqgateway.dto.CaptchaRequest; import org.springframework.beans.factory.annotation.Value; -import java.util.UUID; @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -88,7 +87,6 @@ public String getFilename() { } catch (Exception e) { // 고근 님 서버가 꺼져 있거나 통신 에러가 났을 경우 처리 - e.printStackTrace(); // 1. 에러의 원인(Stack Trace)을 콘솔에 강제로 출력합니다. System.out.println(">>> 에러 메세지: " + e.getMessage()); // 2. 에러 메세지 요약 출력 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ScanResponse.builder() @@ -99,7 +97,7 @@ public String getFilename() { } } - /** + /**.. * [POST] /api/v1/auth/captcha/verify * 캡차 성공 시 해당 사용자의 Redis 제한 카운트를 초기화합니다. */ diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml deleted file mode 100644 index 86086cd..0000000 --- a/src/main/resources/application-be1.yml +++ /dev/null @@ -1,37 +0,0 @@ -server: - port: 8081 - -spring: - datasource: - url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: root - password: ${MYSQL_ROOT_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - # DB 연결을 최대 30초까지 기다려주는 인내심 설정 - hikari: - connection-timeout: 30000 - - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - - oogle: - recaptcha: - # 환경변수로 받거나, 일단 에러 안 나게 더미값이라도 넣어줍니다. - secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY:6LcFLIssAAAAAPFDzhaixpw0fnxyQW-ieQS8O8TD} - - - - data: - redis: - host: veriq-redis - port: 6379 - - - be3: - api: - url: "http://veriq-platform:8083/api/v1/scan/upload" \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml deleted file mode 100644 index 3b06738..0000000 --- a/src/main/resources/application-be3.yml +++ /dev/null @@ -1,27 +0,0 @@ -server: - port: 8083 - -spring: - datasource: - url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: root - password: ${MYSQL_ROOT_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - # DB 연결을 최대 30초까지 기다려주는 인내심 설정 - hikari: - connection-timeout: 30000 - - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - - - data: - redis: - host: veriq-redis - port: 6379 - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0c0ae46..048dfc0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,19 +1,40 @@ -server: - port: 8083 - +# [공통 설정] spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:} + active: gateway + +--- +# [Gateway 설정 구역 - BE1] +spring: + config: + activate: + on-profile: gateway + +server: + port: 8081 +be3: + api: + url: http://veriq-be3:8083 + +google: + recaptcha: + secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY:6LcFLIssAAAAAPFDzhaixpw0fnxyQW-ieQS8O8TD} + +--- +# [BE3 설정 구역 - 나] +spring: + config: + activate: + on-profile: be3 + # 데이터베이스 및 JPA 설정을 하나의 spring: 블록 아래로 모았습니다. datasource: - url: jdbc:mysql://34.64.182.89/veriq_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root - password: veriq123 + password: ${MYSQL_ROOT_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: connection-timeout: 30000 - jpa: hibernate: ddl-auto: update @@ -21,10 +42,6 @@ spring: properties: hibernate: format_sql: true - # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 - dialect: org.hibernate.dialect.MySQLDialect - data: - redis: - host: veriq-redis - port: 6379 \ No newline at end of file +server: + port: 8083 \ No newline at end of file From f401ccd86eb69d88d111f0687fe592c3d741b496 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 21:02:05 +0900 Subject: [PATCH 051/136] =?UTF-8?q?fix:=20deploy.yml=20=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ff4d10..d22cc75 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} script: | - cd /home/sk38808738/main-server + cd /opt/main-server git pull origin develop chmod +x gradlew sudo ./gradlew bootJar From 979d43916b6b659d999429830144a3b9e022448e Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 21:44:44 +0900 Subject: [PATCH 052/136] =?UTF-8?q?fix:=20application-be1.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be1.yml | 15 +++++++-------- src/main/resources/application-be3.yml | 4 ++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index b4245bd..39ef8ef 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -10,10 +10,10 @@ spring: # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: connection-timeout: 30000 - servlet: - multipart: - max-file-size: 10MB # 개별 파일 당 최대 용량 - max-request-size: 10MB # 한 번의 요청 당 최대 용량 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB jpa: hibernate: @@ -36,7 +36,6 @@ spring: host: veriq-redis port: 6379 - - be3: - api: - url: "http://veriq-platform:8083/api/v1/scan/upload" \ No newline at end of file +be3: + api: + url: "http://veriq-be3:8083/api/v1/scan/upload" \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 3b06738..3fa2402 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -10,6 +10,10 @@ spring: # DB 연결을 최대 30초까지 기다려주는 인내심 설정 hikari: connection-timeout: 30000 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB jpa: hibernate: From 5f8d48c5810ec4036eb5dee8ad6041bcdb8f2eeb Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 10 Apr 2026 23:42:35 +0900 Subject: [PATCH 053/136] =?UTF-8?q?fix:=20application-be1.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqgateway/service/SecurityService.java | 2 +- src/main/resources/application-be1.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index 87fbeed..e7680a5 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -18,7 +18,7 @@ public class SecurityService { // 사용자(Guest)에게 허용할 최대 스캔 횟수 private static final int LIMIT = 10; private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 - @Value("${google.recaptcha.secret-key}") + @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") private String secretKey; @Value("${google.recaptcha.verify-url}") private String verifyUrl; diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 39ef8ef..0c4c1bf 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -26,7 +26,7 @@ spring: google: recaptcha: # 환경변수로 받거나, 일단 에러 안 나게 더미값이라도 넣어줍니다. - secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY:6LcFLIssAAAAAPFDzhaixpw0fnxyQW-ieQS8O8TD} + secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY} verify-url: https://www.google.com/recaptcha/api/siteverify From 6a35bb63d3564b573ab099e8c4d987d2e8188ce8 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 00:41:45 +0900 Subject: [PATCH 054/136] =?UTF-8?q?fix:=20application-be1.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- .gitignore | 1 + docker-compose.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d22cc75..fb72bc7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,12 +17,12 @@ jobs: host: ${{ secrets.GCP_IP }} username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} + command_timeout: 30m script: | cd /opt/main-server git pull origin develop chmod +x gradlew sudo ./gradlew bootJar - echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env sudo docker-compose build --no-cache sudo docker-compose up --build -d diff --git a/.gitignore b/.gitignore index 8d569d6..5f382e0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ out/ .gradle/ # 비밀번호가 담긴 설정 파일은 제외 src/main/resources/application.yml +.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ccabb2e..661fdb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: environment: SPRING_PROFILES_ACTIVE: be1 SERVER_PORT: 8081 + GOOGLE_RECAPTCHA_SECRET_KEY: ${GOOGLE_RECAPTCHA_SECRET_KEY} BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" depends_on: From e01118ce72c9f025959ffa99b90c1e8b56319d8c Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 01:13:45 +0900 Subject: [PATCH 055/136] =?UTF-8?q?fix:=20application-be1.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be1.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 0c4c1bf..196e3bc 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -22,20 +22,20 @@ spring: properties: hibernate: format_sql: true - - google: - recaptcha: - # 환경변수로 받거나, 일단 에러 안 나게 더미값이라도 넣어줍니다. - secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY} - verify-url: https://www.google.com/recaptcha/api/siteverify - - - data: redis: host: veriq-redis port: 6379 +google: + recaptcha: + secret-key: ${GOOGLE_RECAPTCHA_SECRET_KEY} + verify-url: https://www.google.com/recaptcha/api/siteverify + + + + + be3: api: url: "http://veriq-be3:8083/api/v1/scan/upload" \ No newline at end of file From b16cd242dcb3ce974da6ebcfa8ae54ae0bc9890e Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 01:49:25 +0900 Subject: [PATCH 056/136] =?UTF-8?q?fix:=20application-be1.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 661fdb7..7ced3cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: SPRING_PROFILES_ACTIVE: be1 SERVER_PORT: 8081 GOOGLE_RECAPTCHA_SECRET_KEY: ${GOOGLE_RECAPTCHA_SECRET_KEY} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" depends_on: @@ -57,6 +59,7 @@ services: environment: SPRING_PROFILES_ACTIVE: be3 SERVER_PORT: 8083 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} depends_on: - veriq-db From e60c66696093f31a052880e4687d9e3835898983 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 02:50:45 +0900 Subject: [PATCH 057/136] =?UTF-8?q?test:be1->be3=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/RestTemplateConfig.java | 23 +++++++++++++++++++ .../controller/GatewayController.java | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java diff --git a/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java new file mode 100644 index 0000000..8e9545d --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package com.veriq.veriqgateway.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; +import java.time.Duration; +import java.time.Duration; +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + // 1. 타임아웃 설정을 담당하는 공장(Factory) 생성 + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + // 2. 10초 시한폭탄 장착! + factory.setConnectTimeout(Duration.ofSeconds(10)); // 연결 시도 10초 제한 + factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 대기 10초 제한 + + // 3. 설정된 공장을 RestTemplate에 넣어서 완성 + return new RestTemplate(factory); + } + +} diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 2489651..15772af 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -77,7 +77,10 @@ public String getFilename() { HttpEntity> requestEntity = new HttpEntity<>(body, headers); // RestTemplate을 이용해 서버(BE 3) 호출 + System.out.println(">>> [BE1] BE3(" + BE3_URL + ")로 데이터 전송 시도 중..."); restTemplate.postForEntity(BE3_URL, requestEntity, String.class); + System.out.println(">>> [BE1] BE3로부터 응답 무사히 도착!"); + // --- [STEP 3] 최종 성공 응답 --- return ResponseEntity.ok(ScanResponse.builder() From ebdf75ed2dd9b72091e34ed6dca16add6a4cd9ea Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 07:43:32 +0900 Subject: [PATCH 058/136] =?UTF-8?q?test:be1->be3=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fb72bc7..dfbd72e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,7 +17,7 @@ jobs: host: ${{ secrets.GCP_IP }} username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} - command_timeout: 30m + command_timeout: 50m script: | cd /opt/main-server git pull origin develop From 01cdd1b2bc25f4db4a6df42338c83e6e6faadfc4 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 16:17:16 +0900 Subject: [PATCH 059/136] =?UTF-8?q?test:be1->be3=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqgateway/VeriQGatewayApplication.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java b/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java index d640da7..0a7ee42 100644 --- a/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java +++ b/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java @@ -16,12 +16,6 @@ public class VeriQGatewayApplication { public static void main(String[] args) { SpringApplication.run(VeriQGatewayApplication.class, args); } - /** - * 고근 님(BE 3) 서버와 통신할 때 사용할 RestTemplate 빈을 등록 - */ - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } + } From 7b1b8e34e8eecfbdc6bb79f57040e03dec9272a3 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 17:48:48 +0900 Subject: [PATCH 060/136] =?UTF-8?q?refactor-feat/kd-15:=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20ip=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 01a7c76..a7a88f7 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -39,6 +39,25 @@ public class GatewayController { */ private String getClientIp(HttpServletRequest request) { + // 프록시 환경(Nginx, Docker Gateway 등)에서 진짜 IP를 담는 헤더들 + String[] headerNames = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + + for (String header : headerNames) { + String ip = request.getHeader(header); + // IP가 유효하고 'unknown'이 아닐 때만 반환 + if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { + // X-Forwarded-For: IP1, IP2, IP3 형태일 경우 첫 번째(원본) IP 추출 + return ip.split(",")[0].trim(); + } + } + + // 헤더에 정보가 없으면 마지막 수단으로 직접 접속 IP 반환 return request.getRemoteAddr(); } @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) From be752960706026a4b6b6c6b2093752d24ec72bf5 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 11 Apr 2026 23:50:49 +0900 Subject: [PATCH 061/136] =?UTF-8?q?refactor-feat/kd-16:=20db=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=20=EC=9E=88=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20url=20=EC=83=81=EC=84=B8=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=20=EB=82=B4=EC=97=AD=20redis=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EB=A1=9C=20=EC=8A=A4=EC=BA=94=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B3=B4=EB=82=B4=EB=8A=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 31 +++++ .../repository/ScanHistoryRepository.java | 3 + .../veriqbe3/service/QrScanRedisService.java | 131 ++++++++++++++---- 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 5a46a0f..5804466 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -1,19 +1,25 @@ package com.veriq.veriqbe3.controller; +import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqbe3.service.ProcessQrScan; +import com.veriq.veriqbe3.service.QrScanRedisService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +@Slf4j @RestController //json반환 컨트롤러 @RequestMapping("/api/v1/scan") //공통 경로 @RequiredArgsConstructor //api 객체 생성 public class QrScanController { private final ProcessQrScan processQrScan; //의존성 주입 + private final QrScanRedisService qrScanRedisService; // 서비스 의존성 주입 @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //multipart_form_data 제한 public ResponseEntity uploadQrImage( //반환 http body 타입 제한 @@ -28,4 +34,29 @@ public ResponseEntity uploadQrImage( //반환 http body 타 return ResponseEntity.badRequest().build(); } } + // 스캔 내역 상세 조회 API 엔드포인트 + @GetMapping("/detail") + public ResponseEntity getUrlDetail(@RequestParam("url") String url) { + try { + log.info("상세 분석 결과 요청 들어옴 - URL: {}", url); + + // Look-Aside 캐시 로직 호출 + AnalysisResponse response = qrScanRedisService.getUrlDetail(url); + + if (response == null) { + // 캐시에도 없고 DB에도 없는 경우 (분석 중이거나 아예 없는 URL) + log.warn("URL 분석 결과를 찾을 수 없습니다: {}", url); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("해당 URL의 분석 결과를 찾을 수 없습니다."); + } + + // 성공 시 200 OK와 함께 데이터 쫙 뿌려주기 + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("상세 조회 중 서버 에러 발생 - URL: {}", url, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("서버 내부 오류가 발생했습니다."); + } + } } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java b/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java index 736f426..cfe6e07 100644 --- a/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java +++ b/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java @@ -3,7 +3,10 @@ import com.veriq.veriqbe3.entity.ScanHistory; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; @Repository public interface ScanHistoryRepository extends JpaRepository { + Optional findFirstByOriginalUrlOrderByScannedAtDesc(String originalUrl); + } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 03190ba..ee06950 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -1,7 +1,10 @@ package com.veriq.veriqbe3.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqbe3.dto.RedisScanHistoryDto; +import com.veriq.veriqbe3.entity.ScanHistory; +import com.veriq.veriqbe3.repository.ScanHistoryRepository; import com.veriq.veriqbe3.dto.RedisUrlCacheDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,6 +14,9 @@ import org.springframework.util.DigestUtils; import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor @@ -20,6 +26,7 @@ public class QrScanRedisService { private final SchemeClassifier schemeClassifier; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper = new ObjectMapper(); + private final ScanHistoryRepository scanHistoryRepository; // ⭐ 고근 님이 작업하실 클래스 (나중에 고근님이 구현하시면 주석 해제) // private final ProcessQrScan processQrScan; @@ -47,18 +54,37 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th if (cachedJson != null) { - RedisUrlCacheDto cacheDto = objectMapper.readValue(cachedJson, RedisUrlCacheDto.class); + AnalysisResponse cacheDto = objectMapper.readValue(cachedJson, AnalysisResponse.class); + //RedisUrlCacheDto cacheDto = objectMapper.readValue(cachedJson, RedisUrlCacheDto.class); // redis 캐시 존재 response = QrScanResponse.builder() .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status(cacheDto.getResultStatus()).build(); + .status(cacheDto.riskLevel()) + .build(); } else { - // redis 캐시 없음(비동기 호출) - // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status("PROCESSING").build(); + Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(result.typeInfo()); + if (dbHistory.isPresent()) { + // DB에 존재 -> 엔티티를 DTO로 변환 + AnalysisResponse dbResponse = convertToAnalysisResponse(dbHistory.get()); + + // ⭐ 꺼내온 데이터를 다음 요청을 위해 Redis에 캐싱 (Look-Aside) + redisTemplate.opsForValue().set(urlKey, objectMapper.writeValueAsString(dbResponse), URL_CACHE_TTL); + + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status(dbResponse.riskLevel()) + .build(); + } + else { + // 3. DB에도 없음 -> 신규 URL이므로 분석 서버(고근 님)로 비동기 호출 + // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); + + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status("PROCESSING").build(); + } + } } @@ -67,9 +93,31 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th return response; } - /** - * 고근 님의 콜백 API에서 분석이 끝난 후 호출할 메서드 - */ + // ⭐ 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API + public AnalysisResponse getUrlDetail(String url) throws Exception { + String cacheKey = buildUrlCacheKey(url); + String jsonCache = redisTemplate.opsForValue().get(cacheKey); + + if (jsonCache != null) { + log.info("Redis에서 상세 분석 결과 반환: {}", url); + return objectMapper.readValue(jsonCache, AnalysisResponse.class); + } + + // ⭐ Redis에 없으면 DB에서 조회 + Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(url); + if (dbHistory.isEmpty()) { + return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL + } + + AnalysisResponse dbResponse = convertToAnalysisResponse(dbHistory.get()); + + // 다시 요청될 수 있으니 Redis에 캐싱 + redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(dbResponse), URL_CACHE_TTL); + log.info("DB 조회 후 Redis 캐싱 및 반환: {}", url); + + return dbResponse; + } + public void cacheAnalysisResult(String url, RedisUrlCacheDto resultDto) { try { // 1. 상세 리포트 캐싱 (String 타입으로 1:1 저장) @@ -98,23 +146,7 @@ private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { log.error("Redis 히스토리 저장 실패 - guestUuid: {}", guestUuid, e); } -} //스캔 내역 url클릭시 다시 프론트로 url에 상세보고서 보내는 api - public RedisUrlCacheDto getUrlDetail(String url) throws Exception { - // 1. Redis 키 생성 - String cacheKey = buildUrlCacheKey(url); - - // 2. Redis에서 데이터 꺼내기 - String jsonCache = redisTemplate.opsForValue().get(cacheKey); - - if (jsonCache == null) { - // 캐시가 비어있다면? (만료되었거나 처음 보는 URL인 경우) - // 이때는 고근 님께 다시 분석 요청을 하거나, 에러를 던져야 합니다. - return null; - } - - // 3. JSON 문자열을 다시 자바 객체(RedisUrlCacheDto)로 변환해서 반환 - return objectMapper.readValue(jsonCache, RedisUrlCacheDto.class); - } + } /** * URL 캐시 키를 안전하게 정규화하고 해싱하는 헬퍼 메서드 */ @@ -137,5 +169,50 @@ private String buildUrlCacheKey(String rawUrl) { return "url_cache:" + hashedUrl; } + /** + * ⭐ DB 엔티티(ScanHistory)를 Redis용 캐시/응답 DTO(AnalysisResponse)로 변환하는 헬퍼 메서드 + */ + private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { + // DB의 threats(String)를 List으로 변환 (콤마 분리 가정) + java.util.List threatsList = Collections.emptyList(); + if (entity.getMl() != null && entity.getMl().getThreats() != null && !entity.getMl().getThreats().isBlank()) { + threatsList = Arrays.asList(entity.getMl().getThreats().split(",\\s*")); + } + + return new AnalysisResponse( + entity.getAnalysisTime(), + entity.getOriginalUrl(), + entity.getHttps() != null ? new AnalysisResponse.HttpsInfo(entity.getHttps().isSecure()) : null, + entity.getShortUrl() != null ? new AnalysisResponse.ShortUrlInfo(entity.getShortUrl().isShortened()) : null, + entity.getMl() != null ? new AnalysisResponse.MlInfo(threatsList, entity.getMl().getMlScore()) : null, + entity.getExternalApi() != null ? new AnalysisResponse.ExternalApiInfo( + entity.getExternalApi().isApiChecked(), + entity.getExternalApi().getApiProvider(), + entity.getExternalApi().getApiResult() + ) : null, + entity.getInternalDb() != null ? new AnalysisResponse.InternalDbInfo( + entity.getInternalDb().isDbExists(), + entity.getInternalDb().getDbReportCount(), + entity.getInternalDb().getDbBlockCount() + ) : null, + entity.getRedirect() != null ? new AnalysisResponse.RedirectInfo( + entity.getRedirect().getFinalUrl(), + entity.getRedirect().getRedirectCount() + ) : null, + entity.getServerInfo() != null ? new AnalysisResponse.ServerInfo( + entity.getServerInfo().getServerType(), + entity.getServerInfo().getServerLocation(), + entity.getServerInfo().getCertificate() != null ? new AnalysisResponse.CertificateInfo( + entity.getServerInfo().getCertificate().isCertValid(), + entity.getServerInfo().getCertificate().getCertIssuer(), + entity.getServerInfo().getCertificate().getCertValidFrom(), + entity.getServerInfo().getCertificate().getCertValidTo() + ) : null + ) : null, + entity.getTotalScore(), + entity.getRiskLevel() + ); + } + } From 71e6adb55b8e7d741ae834f29a624e92fb70b2f3 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 12 Apr 2026 04:04:33 +0900 Subject: [PATCH 062/136] =?UTF-8?q?refactor-feat/kd-16:=20Redis=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=B6=84=EC=84=9D=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 26 ++++- .../veriqbe3/dto/RedisScanHistoryDto.java | 6 +- .../veriq/veriqbe3/dto/RedisUrlCacheDto.java | 19 --- .../veriqbe3/service/QrScanRedisService.java | 108 ++++++++++++++++-- .../controller/GatewayController.java | 12 +- 5 files changed, 132 insertions(+), 39 deletions(-) delete mode 100644 src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 5804466..8677f6c 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -26,7 +26,7 @@ public ResponseEntity uploadQrImage( //반환 http body 타 @RequestHeader(value = "guest_uuid", required = false ) String guestUuid, @RequestParam("image") MultipartFile image) { try { - QrScanResponse response = processQrScan.process(image, guestUuid); + QrScanResponse response = qrScanRedisService.processWithRedis(image, guestUuid); return ResponseEntity.ok(response); } catch (Exception e) { @@ -34,7 +34,7 @@ public ResponseEntity uploadQrImage( //반환 http body 타 return ResponseEntity.badRequest().build(); } } - // 스캔 내역 상세 조회 API 엔드포인트 + // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 @GetMapping("/detail") public ResponseEntity getUrlDetail(@RequestParam("url") String url) { try { @@ -59,4 +59,26 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { .body("서버 내부 오류가 발생했습니다."); } } + + // ML 서버가 분석을 마치고 결과를 던져주는 콜백 API + @PostMapping("/callback") + public ResponseEntity analysisCallback( + @RequestHeader(value = "guest_uuid", required = false) String guestUuid, + @RequestBody AnalysisResponse resultDto) { + + try { + log.info("ML 서버로부터 분석 완료 콜백 수신 - URL: {}", resultDto.originalUrl()); + + // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! + qrScanRedisService.saveAndCacheAnalysisResult(resultDto, guestUuid); + + // 2. 가흔 님 서버에게 "잘 받았어!" 라고 200 OK 응답 + return ResponseEntity.ok("DB 저장 및 캐싱 완벽하게 완료!"); + + } catch (Exception e) { + log.error("콜백 데이터 처리 중 에러 발생", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("콜백 처리 실패: " + e.getMessage()); + } + } } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 4e937ee..481cbf0 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -7,9 +7,10 @@ public class RedisScanHistoryDto { private String schemeType; - private String typeInfo; + private String typeInfo;// 프론트에서 보여줄 원본 URL (예: https://www.naver.com) //private String status; private String scannedAt; // 스캔시간 + private String riskLevel; // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. public static RedisScanHistoryDto from(QrScanResponse response) { @@ -17,7 +18,8 @@ public static RedisScanHistoryDto from(QrScanResponse response) { .schemeType(response.getSchemeType().name()) .typeInfo(response.getTypeInfo()) //.status(response.getStatus()) - .scannedAt(LocalDateTime.now().toString()) // 현재 시간 자동 입력 + .scannedAt(LocalDateTime.now().toString()) + .riskLevel(response.getStatus()) .build(); } } diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java deleted file mode 100644 index dc8a33a..0000000 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisUrlCacheDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.veriq.veriqbe3.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor - -public class RedisUrlCacheDto { - private String resultStatus; // SAFE, WARNING, CRITICAL - private int trustScore; // 85점 - private String riskLevel; // 낮음 - private String siteName; // 네이버 - private String sslStatus; // 유효함 - private String domainCreated; // 1997.12.15 - // 그 외 결과 페이지에 들어가는 모든 텍스트 정보들... -} diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index ee06950..d985f86 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -5,13 +5,14 @@ import com.veriq.veriqbe3.dto.RedisScanHistoryDto; import com.veriq.veriqbe3.entity.ScanHistory; import com.veriq.veriqbe3.repository.ScanHistoryRepository; -import com.veriq.veriqbe3.dto.RedisUrlCacheDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.util.DigestUtils; +import org.springframework.transaction.annotation.Transactional; +import com.veriq.veriqbe3.domain.SchemeType; import java.time.Duration; import java.util.Arrays; @@ -68,7 +69,7 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th // DB에 존재 -> 엔티티를 DTO로 변환 AnalysisResponse dbResponse = convertToAnalysisResponse(dbHistory.get()); - // ⭐ 꺼내온 데이터를 다음 요청을 위해 Redis에 캐싱 (Look-Aside) + // 꺼내온 데이터를 다음 요청을 위해 Redis에 캐싱 (Look-Aside) redisTemplate.opsForValue().set(urlKey, objectMapper.writeValueAsString(dbResponse), URL_CACHE_TTL); response = QrScanResponse.builder() @@ -77,7 +78,7 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th .build(); } else { - // 3. DB에도 없음 -> 신규 URL이므로 분석 서버(고근 님)로 비동기 호출 + // 3. DB에도 없음 -> 콜백api호출(분석서버에 요청) // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); response = QrScanResponse.builder() @@ -93,7 +94,7 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th return response; } - // ⭐ 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API + // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API public AnalysisResponse getUrlDetail(String url) throws Exception { String cacheKey = buildUrlCacheKey(url); String jsonCache = redisTemplate.opsForValue().get(cacheKey); @@ -103,7 +104,7 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { return objectMapper.readValue(jsonCache, AnalysisResponse.class); } - // ⭐ Redis에 없으면 DB에서 조회 + // Redis에 없으면 DB에서 조회 Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(url); if (dbHistory.isEmpty()) { return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL @@ -117,10 +118,12 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { return dbResponse; } - - public void cacheAnalysisResult(String url, RedisUrlCacheDto resultDto) { + /** + * 고근 님의 콜백 API에서 분석이 끝난 후 호출할 메서드 + */ + public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { try { - // 1. 상세 리포트 캐싱 (String 타입으로 1:1 저장) + // 1. 상세 리포트 캐싱 (AnalysisResponse 객체를 통째로 JSON으로 변환하여 저장) String urlKey = buildUrlCacheKey(url); String jsonCache = objectMapper.writeValueAsString(resultDto); redisTemplate.opsForValue().set(urlKey, jsonCache, URL_CACHE_TTL); @@ -170,7 +173,7 @@ private String buildUrlCacheKey(String rawUrl) { return "url_cache:" + hashedUrl; } /** - * ⭐ DB 엔티티(ScanHistory)를 Redis용 캐시/응답 DTO(AnalysisResponse)로 변환하는 헬퍼 메서드 + * DB 엔티티(ScanHistory)를 Redis용 캐시/응답 DTO(AnalysisResponse)로 변환하는 헬퍼 메서드 */ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { // DB의 threats(String)를 List으로 변환 (콤마 분리 가정) @@ -214,5 +217,92 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { ); } + @Transactional // DB 저장하다 에러 나면 롤백!,분석서버에서 온 결과 db 및 redis에 저장 + public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String guestUuid) { + + try { + // ========================================== + // 1. 프론트용 DTO를 DB 저장용 Entity로 변환 + // ========================================== + String url = responseDto.originalUrl(); // 편의를 위해 변수 추출 + + ScanHistory scanHistory = ScanHistory.builder() + .guestUuid(guestUuid) + .originalUrl(url) + .typeInfo(url) // 보통 원본 URL을 넣습니다 + .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 + .analysisTime(responseDto.analysisTime()) + .totalScore(responseDto.score()) + .riskLevel(responseDto.riskLevel()) + + // 1. HttpsInfo 빌드 + .https(ScanHistory.HttpsInfo.builder() + .isSecure(responseDto.https() != null && responseDto.https().isSecure()) + .build()) + + // 2. ShortUrlInfo 빌드 + .shortUrl(ScanHistory.ShortUrlInfo.builder() + .isShortened(responseDto.shortUrl() != null && responseDto.shortUrl().isShortened()) + .build()) + + // 3. MlInfo 빌드 + .ml(ScanHistory.MlInfo.builder() + .threats(responseDto.ml() != null && responseDto.ml().threats() != null + ? String.join(", ", responseDto.ml().threats()) : null) + .mlScore(responseDto.ml() != null ? responseDto.ml().score() : 0) + .build()) + + // 4. ExternalApiInfo 빌드 + .externalApi(ScanHistory.ExternalApiInfo.builder() + .apiChecked(responseDto.externalApi() != null && responseDto.externalApi().checked()) + .apiProvider(responseDto.externalApi() != null ? responseDto.externalApi().provider() : null) + .apiResult(responseDto.externalApi() != null ? responseDto.externalApi().result() : null) + .build()) + + // 5. InternalDbInfo 빌드 + .internalDb(ScanHistory.InternalDbInfo.builder() + .dbExists(responseDto.internalDb() != null && responseDto.internalDb().exists()) + .dbReportCount(responseDto.internalDb() != null ? responseDto.internalDb().reportCount() : 0) + .dbBlockCount(responseDto.internalDb() != null ? responseDto.internalDb().blockCount() : 0) + .build()) + + // 6. RedirectInfo 빌드 + .redirect(ScanHistory.RedirectInfo.builder() + .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) + .redirectCount(responseDto.redirect() != null ? responseDto.redirect().redirectCount() : 0) + .build()) + + // 7. ServerInfo 및 CertificateInfo + .serverInfo(ScanHistory.ServerInfo.builder() + .serverType(responseDto.serverInfo() != null ? responseDto.serverInfo().type() : null) + .serverLocation(responseDto.serverInfo() != null ? responseDto.serverInfo().location() : null) + .certificate(responseDto.serverInfo() != null && responseDto.serverInfo().certificate() != null + ? ScanHistory.CertificateInfo.builder() + .certValid(responseDto.serverInfo().certificate().valid()) + .certIssuer(responseDto.serverInfo().certificate().issuer()) + .certValidFrom(responseDto.serverInfo().certificate().validFrom()) + .certValidTo(responseDto.serverInfo().certificate().validTo()) + .build() + : null) + .build()) + .build(); + + // ========================================== + // 2. DB에 저장 + // ========================================== + scanHistoryRepository.save(scanHistory); + log.info("DB 저장 완료: {}", url); + + // ========================================== + // 3. Redis 캐시 워밍 + // ========================================== + cacheAnalysisResult(url, responseDto); + + } catch (Exception e) { + log.error("DB 저장 및 캐싱 중 에러 발생: {}", responseDto.originalUrl(), e); + throw new RuntimeException("DB 저장 실패", e); + } + } + } diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index a7a88f7..5ca5882 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -61,7 +61,7 @@ private String getClientIp(HttpServletRequest request) { return request.getRemoteAddr(); } @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity processScan( + public ResponseEntity processScan( @RequestHeader("guest_uuid") String guestUuid, @RequestParam("image") MultipartFile image, HttpServletRequest request) { @@ -107,16 +107,14 @@ public String getFilename() { // RestTemplate을 이용해 서버(BE 3) 호출 System.out.println(">>> [BE1] BE3(" + BE3_URL + ")로 데이터 전송 시도 중..."); - restTemplate.postForEntity(BE3_URL, requestEntity, String.class); + // BE3가 반환하는 QrScanResponse(JSON)를 그대로 Object 형태로 받습니다. + ResponseEntity responseFromBe3 = restTemplate.postForEntity(BE3_URL, requestEntity, Object.class); System.out.println(">>> [BE1] BE3로부터 응답 무사히 도착!"); // --- [STEP 3] 최종 성공 응답 --- - return ResponseEntity.ok(ScanResponse.builder() - .guestUuid(guestUuid) - .status("PENDING") - .message("보안 검사 통과 및 분석 요청이 성공적으로 전달되었습니다.") - .build()); + return ResponseEntity.status(responseFromBe3.getStatusCode()) + .body(responseFromBe3.getBody()); } catch (Exception e) { // 고근 님 서버가 꺼져 있거나 통신 에러가 났을 경우 처리 From ea52fdcf9c648854ab02cd9d92ada1c1f6558ea7 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 12 Apr 2026 14:09:51 +0900 Subject: [PATCH 063/136] =?UTF-8?q?refactor-feat/kd-16:=20MSA=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- docker-compose.yml | 8 ++- .../veriq/veriqbe3/config/JacksonConfig.java | 22 +++++++ .../veriqbe3/controller/QrScanController.java | 25 +++++++- .../veriqbe3/dto/RedisScanHistoryDto.java | 5 ++ .../veriq/veriqbe3/entity/ScanHistory.java | 5 +- .../veriqbe3/service/QrScanRedisService.java | 59 +++++++++++++++---- .../controller/GatewayController.java | 15 +++-- src/main/resources/application-be3.yml | 4 ++ 9 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/veriq/veriqbe3/config/JacksonConfig.java diff --git a/build.gradle b/build.gradle index 3f29b65..e604d63 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' @@ -39,6 +38,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 7ced3cb..ad8af45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,8 @@ services: SERVER_PORT: 8081 GOOGLE_RECAPTCHA_SECRET_KEY: ${GOOGLE_RECAPTCHA_SECRET_KEY} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + ML_SECRET_KEY: ${ML_SECRET_KEY} + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" @@ -60,6 +62,7 @@ services: SPRING_PROFILES_ACTIVE: be3 SERVER_PORT: 8083 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + ML_SECRET_KEY: ${ML_SECRET_KEY} depends_on: - veriq-db @@ -74,5 +77,8 @@ services: restart: always ports: - "8000:8000" + environment: + ML_SECRET_KEY: ${ML_SECRET_KEY} + depends_on: - - veriq-db \ No newline at end of file + - veriq-db diff --git a/src/main/java/com/veriq/veriqbe3/config/JacksonConfig.java b/src/main/java/com/veriq/veriqbe3/config/JacksonConfig.java new file mode 100644 index 0000000..8b4f663 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/JacksonConfig.java @@ -0,0 +1,22 @@ +package com.veriq.veriqbe3.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + + + mapper.registerModule(new JavaTimeModule()); + + // 날짜를 [2026,4,12] 같은 숫자 배열이 아닌 "2026-04-12..." 문자열로 출력 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } +} diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 8677f6c..6856228 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -11,6 +11,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.beans.factory.annotation.Value; @Slf4j @RestController //json반환 컨트롤러 @@ -61,18 +63,29 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { } // ML 서버가 분석을 마치고 결과를 던져주는 콜백 API + @Value("${ml.server.secret}") + private String mlServerSecret; @PostMapping("/callback") public ResponseEntity analysisCallback( @RequestHeader(value = "guest_uuid", required = false) String guestUuid, + @RequestHeader(value = "X-ML-Secret", required = false) String providedSecret, @RequestBody AnalysisResponse resultDto) { try { + if (providedSecret == null || !providedSecret.equals(mlServerSecret)) { + log.warn("🚨 [보안 경고] 유효하지 않은 콜백 요청 접근 시도! URL: {}", resultDto.originalUrl()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("접근 권한이 없습니다 (Invalid Secret)."); + } log.info("ML 서버로부터 분석 완료 콜백 수신 - URL: {}", resultDto.originalUrl()); // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! + // 2. 비밀번호가 맞을 때만 DB 저장 및 캐싱을 진행 qrScanRedisService.saveAndCacheAnalysisResult(resultDto, guestUuid); - // 2. 가흔 님 서버에게 "잘 받았어!" 라고 200 OK 응답 + + + // 3. 가흔 님 서버에게 "잘 받았어!" 라고 200 OK 응답 return ResponseEntity.ok("DB 저장 및 캐싱 완벽하게 완료!"); } catch (Exception e) { @@ -81,4 +94,14 @@ public ResponseEntity analysisCallback( .body("콜백 처리 실패: " + e.getMessage()); } } + + // 프론트엔드가 파이프를 꽂으러 오는 곳 + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity subscribe(@RequestParam("guest_uuid") String guestUuid) { + + // 타임아웃 5분(300,000ms)짜리 파이프 생성 + SseEmitter emitter = qrScanRedisService.createEmitter(guestUuid); + + return ResponseEntity.ok(emitter); + } } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 481cbf0..a557652 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -1,9 +1,14 @@ package com.veriq.veriqbe3.dto; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; + import java.time.LocalDateTime; @Getter @Builder +@NoArgsConstructor +@AllArgsConstructor public class RedisScanHistoryDto { private String schemeType; diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 18af402..bbe4342 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -7,7 +7,10 @@ import java.time.LocalDateTime; @Entity -@Table(name = "scan_history",indexes={@Index(name = "idx_final_url", columnList = "finalUrl")}) +@Table(name = "scan_history",indexes={ + @Index(name = "idx_final_url", columnList = "finalUrl"), + @Index(name = "idx_original_url_scanned_at", columnList = "originalUrl, scannedAt DESC") +}) @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class ScanHistory { diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index d985f86..d05479f 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -13,6 +13,10 @@ import org.springframework.util.DigestUtils; import org.springframework.transaction.annotation.Transactional; import com.veriq.veriqbe3.domain.SchemeType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + import java.time.Duration; import java.util.Arrays; @@ -26,8 +30,9 @@ public class QrScanRedisService { private final QrDecoder qrDecoder; private final SchemeClassifier schemeClassifier; private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; private final ScanHistoryRepository scanHistoryRepository; + private final Map emitters = new ConcurrentHashMap<>(); // ⭐ 고근 님이 작업하실 클래스 (나중에 고근님이 구현하시면 주석 해제) // private final ProcessQrScan processQrScan; @@ -119,7 +124,7 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { return dbResponse; } /** - * 고근 님의 콜백 API에서 분석이 끝난 후 호출할 메서드 + */ public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { try { @@ -136,6 +141,10 @@ public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { } private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { + // 방어 로직: UUID가 없거나 비어있거나 익명이면, 저장하지 않고 그냥 반환 + if (guestUuid == null || guestUuid.isBlank() || guestUuid.equals("ANONYMOUS")) { + return; + } try { String historyKey = "history:" + guestUuid; RedisScanHistoryDto item = RedisScanHistoryDto.from(response); @@ -154,19 +163,19 @@ private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { * URL 캐시 키를 안전하게 정규화하고 해싱하는 헬퍼 메서드 */ private String buildUrlCacheKey(String rawUrl) { - if (rawUrl == null) { + if (rawUrl == null || rawUrl.isBlank()) { return "url_cache:empty"; } - // 1. 정규화: 앞뒤 공백 제거 및 소문자 변환 - String cleanUrl = rawUrl.trim().toLowerCase(); + // 1. 정규화: 앞뒤 공백 제거 + String cleanUrl = rawUrl.trim(); - // 2. 쿼리 파라미터(?) 제거 (순수 도메인/경로만 캐싱하여 적중률 향상) - int questionIndex = cleanUrl.indexOf('?'); - if (questionIndex != -1) { - cleanUrl = cleanUrl.substring(0, questionIndex); + // 2. Fragment(#)만 제거하고, QueryString(?)은 반드시 보존! + // 브라우저 내부용인 # 뒤는 잘라도 되지만, ? 뒤는 데이터이므로 남깁니다. + int hashIndex = cleanUrl.indexOf('#'); + if (hashIndex != -1) { + cleanUrl = cleanUrl.substring(0, hashIndex); } - // 3. 해싱: MD5 알고리즘을 사용하여 어떤 길이의 URL이든 짧고 안전한 문자열로 변환 String hashedUrl = DigestUtils.md5DigestAsHex(cleanUrl.getBytes()); @@ -225,9 +234,10 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // 1. 프론트용 DTO를 DB 저장용 Entity로 변환 // ========================================== String url = responseDto.originalUrl(); // 편의를 위해 변수 추출 + String safeGuestUuid = (guestUuid != null && !guestUuid.isBlank()) ? guestUuid : "ANONYMOUS"; ScanHistory scanHistory = ScanHistory.builder() - .guestUuid(guestUuid) + .guestUuid(safeGuestUuid) .originalUrl(url) .typeInfo(url) // 보통 원본 URL을 넣습니다 .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 @@ -298,11 +308,38 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // ========================================== cacheAnalysisResult(url, responseDto); + log.info("DB 저장 완료. 프론트엔드(guest_uuid: {})로 SSE 알림 발송 시도", guestUuid); + // 프론트엔드로 프로세스 완료 알림 + SseEmitter emitter = emitters.get(guestUuid); + if (emitter != null) { + // "COMPLETE"라는 이름표를 붙여서 URL 데이터를 쏴줌 + emitter.send(SseEmitter.event() + .name("COMPLETE") + .data(responseDto.originalUrl())); + } + } catch (Exception e) { log.error("DB 저장 및 캐싱 중 에러 발생: {}", responseDto.originalUrl(), e); throw new RuntimeException("DB 저장 실패", e); } } + // 1. 파이프(진동벨) 만들어주는 메서드 + public SseEmitter createEmitter(String guestUuid) { + SseEmitter emitter = new SseEmitter(300000L); // 5분 타임아웃 + emitters.put(guestUuid, emitter); // 명부에 등록 + + // 파이프 연결이 끊기면 명부에서 삭제 + emitter.onCompletion(() -> emitters.remove(guestUuid)); + emitter.onTimeout(() -> emitters.remove(guestUuid)); + + try { + // 연결되자마자 503 에러 방지용 더미(Dummy) 데이터 하나 전송 + emitter.send(SseEmitter.event().name("INIT").data("Connected!")); + } catch (Exception e) { + emitters.remove(guestUuid); + } + return emitter; + } } diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 5ca5882..d84155c 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -116,15 +116,22 @@ public String getFilename() { return ResponseEntity.status(responseFromBe3.getStatusCode()) .body(responseFromBe3.getBody()); + } catch (org.springframework.web.client.RestClientResponseException e) { + // ⭐ [추가] BE3가 던진 4xx, 5xx 에러를 그대로 받아서 프론트에 토스! + System.out.println(">>> [BE1] BE3로부터 에러 응답 수신: " + e.getStatusCode()); + + return ResponseEntity.status(e.getStatusCode()) // BE3가 준 400, 401 등을 그대로 사용 + .body(e.getResponseBodyAsString()); // BE3가 준 에러 메시지도 그대로 전달 + } catch (Exception e) { - // 고근 님 서버가 꺼져 있거나 통신 에러가 났을 경우 처리 - e.printStackTrace(); // 1. 에러의 원인(Stack Trace)을 콘솔에 강제로 출력합니다. - System.out.println(">>> 에러 메세지: " + e.getMessage()); // 2. 에러 메세지 요약 출력 + // 여기는 진짜 서버가 꺼졌거나 알 수 없는 심각한 오류일 때만 타게 됨 + e.printStackTrace(); + System.out.println(">>> 에러 메시지: " + e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ScanResponse.builder() .guestUuid(guestUuid) .status("ERROR") - .message("플랫폼 서버(BE 3)와의 연결에 실패했습니다: " + e.getMessage()) + .message("게이트웨이 통신 중 알 수 없는 오류 발생") .build()); } } diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 3fa2402..48f8192 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -29,3 +29,7 @@ spring: host: veriq-redis port: 6379 +ml: + server: + # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽어 (없으면 default_secret 사용) + secret: ${ML_SECRET_KEY:default_secret} \ No newline at end of file From fc876fefd215339949c46bd164df68978d0fe676 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 14 Apr 2026 18:15:12 +0900 Subject: [PATCH 064/136] =?UTF-8?q?refactor-feat/kd-16:=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=ED=86=B5=EC=8B=A0=20?= =?UTF-8?q?=EA=B7=9C=EC=95=BD=20=EC=A4=80=EC=88=98=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9D=91=EB=8B=B5=20DTO=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 + .../veriq/veriqbe3/VeriQBe3Application.java | 2 + .../veriqbe3/config/RestTemplateConfig.java | 25 ++ .../veriqbe3/controller/QrScanController.java | 126 ++++++++- .../veriq/veriqbe3/dto/ProgressRequest.java | 9 + .../veriqbe3/dto/RedisScanHistoryDto.java | 13 +- .../veriqbe3/service/MlRequestService.java | 52 ++++ .../veriqbe3/service/QrScanRedisService.java | 243 +++++++++++++----- .../config/RestTemplateConfig.java | 2 +- .../controller/GatewayController.java | 22 +- src/main/resources/application-be1.yml | 5 +- src/main/resources/application-be3.yml | 11 +- 12 files changed, 433 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java create mode 100644 src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java create mode 100644 src/main/java/com/veriq/veriqbe3/service/MlRequestService.java diff --git a/docker-compose.yml b/docker-compose.yml index ad8af45..f06ff4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: GOOGLE_RECAPTCHA_SECRET_KEY: ${GOOGLE_RECAPTCHA_SECRET_KEY} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ML_SECRET_KEY: ${ML_SECRET_KEY} + FRONTEND_CORS_URL: http://34.64.182.89:3000 BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" @@ -63,6 +64,7 @@ services: SERVER_PORT: 8083 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ML_SECRET_KEY: ${ML_SECRET_KEY} + FRONTEND_CORS_URL: http://34.64.182.89:3000 depends_on: - veriq-db @@ -79,6 +81,8 @@ services: - "8000:8000" environment: ML_SECRET_KEY: ${ML_SECRET_KEY} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + depends_on: - veriq-db diff --git a/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java index 25eb7ea..5e5848d 100644 --- a/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java +++ b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class VeriQBe3Application { public static void main(String[] args) { diff --git a/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java new file mode 100644 index 0000000..61d26d2 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java @@ -0,0 +1,25 @@ +package com.veriq.veriqbe3.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; +import java.time.Duration; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + // 1. 타임아웃 설정을 담당하는 공장(Factory) 생성 + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + // 2. 10초 시한폭탄 장착! + factory.setConnectTimeout(Duration.ofSeconds(10)); // 연결 시도 10초 제한 + factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 대기 10초 제한 + + // 3. 설정된 공장을 RestTemplate에 넣어서 완성 + return new RestTemplate(factory); + } +} + diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 6856228..56f052a 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -1,9 +1,12 @@ package com.veriq.veriqbe3.controller; import com.veriq.veriqbe3.dto.AnalysisResponse; +import com.veriq.veriqbe3.dto.ProgressRequest; import com.veriq.veriqbe3.dto.QrScanResponse; +import com.veriq.veriqbe3.service.MlRequestService; import com.veriq.veriqbe3.service.ProcessQrScan; import com.veriq.veriqbe3.service.QrScanRedisService; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -14,14 +17,24 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.beans.factory.annotation.Value; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + @Slf4j @RestController //json반환 컨트롤러 @RequestMapping("/api/v1/scan") //공통 경로 @RequiredArgsConstructor //api 객체 생성 public class QrScanController { - private final ProcessQrScan processQrScan; //의존성 주입 + //private final ProcessQrScan processQrScan; //의존성 주입 private final QrScanRedisService qrScanRedisService; // 서비스 의존성 주입 + private final ObjectMapper objectMapper; + + + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //multipart_form_data 제한 public ResponseEntity uploadQrImage( //반환 http body 타입 제한 @@ -36,7 +49,7 @@ public ResponseEntity uploadQrImage( //반환 http body 타 return ResponseEntity.badRequest().build(); } } - // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 + // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 @GetMapping("/detail") public ResponseEntity getUrlDetail(@RequestParam("url") String url) { try { @@ -62,7 +75,10 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { } } - // ML 서버가 분석을 마치고 결과를 던져주는 콜백 API + + + + // ML 서버가 분석을 마치고 분석최종결과를 던져주는 콜백 API @Value("${ml.server.secret}") private String mlServerSecret; @PostMapping("/callback") @@ -72,8 +88,18 @@ public ResponseEntity analysisCallback( @RequestBody AnalysisResponse resultDto) { try { - if (providedSecret == null || !providedSecret.equals(mlServerSecret)) { - log.warn("🚨 [보안 경고] 유효하지 않은 콜백 요청 접근 시도! URL: {}", resultDto.originalUrl()); + // [보안 로직] 상수 시간 비교(Constant-time comparison)를 통한 타이밍 공격 방어 + boolean isSecretValid = false; + if (providedSecret != null && mlServerSecret != null) { + byte[] providedBytes = providedSecret.getBytes(StandardCharsets.UTF_8); + byte[] expectedBytes = mlServerSecret.getBytes(StandardCharsets.UTF_8); + + // MessageDigest.isEqual은 중간에 틀려도 끝까지 비교하여 실행 시간을 일정하게 유지합니다. + isSecretValid = MessageDigest.isEqual(providedBytes, expectedBytes); + } + + if (!isSecretValid) { + log.warn(" [보안 경고] 유효하지 않은 콜백 요청 접근 시도 (타이밍 공격 방어 적용됨)! URL: {}", resultDto.originalUrl()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body("접근 권한이 없습니다 (Invalid Secret)."); } @@ -82,10 +108,39 @@ public ResponseEntity analysisCallback( // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! // 2. 비밀번호가 맞을 때만 DB 저장 및 캐싱을 진행 qrScanRedisService.saveAndCacheAnalysisResult(resultDto, guestUuid); + // 3.스캔 히스토리 저장 + qrScanRedisService.saveHistoryToRedis(guestUuid, resultDto); + + //4.프런트로 완료 신호 전송 - // 3. 가흔 님 서버에게 "잘 받았어!" 라고 200 OK 응답 + try { + // [STEP 1] COMPLETE 채널에 담을 통합 데이터 구성 + Map completePayload = new HashMap<>(); + completePayload.put("step", "분석 완료"); + completePayload.put("status", "COMPLETED"); + completePayload.put("message", "분석이 완료되었습니다. 결과 페이지로 이동합니다."); + completePayload.put("originalUrl", resultDto.originalUrl()); // 이동할 목적지 URL + completePayload.put("isUrl", true); + + // [STEP 2] JSON 문자열로 변환 + String jsonPayload = objectMapper.writeValueAsString(completePayload); + + // [STEP 3] COMPLETE 채널로 전송! + // 이제 프론트는 이 JSON을 받아서 메시지도 띄우고, originalUrl로 이동도 시킵니다. + qrScanRedisService.sendSseCompleteEvent(guestUuid, jsonPayload); + + log.info("ML 분석 완료 및 통합 COMPLETE 알림 전송 - User: {}", guestUuid); + + } catch (Exception e) { + log.error("최종 COMPLETE 메시지 생성 실패", e); + } + + + + + // 4. ML 서버에게 "잘 받았어!" 라고 200 OK 응답 return ResponseEntity.ok("DB 저장 및 캐싱 완벽하게 완료!"); } catch (Exception e) { @@ -95,13 +150,66 @@ public ResponseEntity analysisCallback( } } + // 파이썬 서버가 중간 단계가 끝날 때마다 쏴주는 중간보고 API + @PostMapping("/callback/progress") + public ResponseEntity analysisProgressCallback( + // 누구의 파이프인지 식별자 필수! + @RequestHeader(value = "X-ML-Secret", required = false) String providedSecret, + @RequestBody ProgressRequest request) { // { "step": "DOMAIN_CHECK", "message": "도메인 사칭 분석 완료" } + + try { + // 1. 보안 검증 (기존 로직과 동일하게 유지) + if (providedSecret == null || !providedSecret.equals(mlServerSecret)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("접근 권한이 없습니다."); + } + + // 2. 파이썬이 보내준 메시지 추출 + String ssePayload = objectMapper.writeValueAsString(request); + + // 3. 해당 guestUuid의 SSE 파이프를 찾아서 메시지 전송! + // ( SseEmitter 서비스 호출) + qrScanRedisService.sendSseEvent(request.guestUuid(), ssePayload); + + log.info("[SSE 중간 보고] User: {}, Step: {}, Status: {}", + request.guestUuid(), request.step(), request.status()); + return ResponseEntity.ok("상태 업데이트 성공!"); + + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("에러 발생"); + } + } + // 프론트엔드가 파이프를 꽂으러 오는 곳 + @CrossOrigin(origins = "${frontend.url}")//프론트의 주소에 따라 유동, 해당 주소만 파이프 받아들임 @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity subscribe(@RequestParam("guest_uuid") String guestUuid) { - // 타임아웃 5분(300,000ms)짜리 파이프 생성 - SseEmitter emitter = qrScanRedisService.createEmitter(guestUuid); + try { + // 1. 파이프 생성 시도 + SseEmitter emitter = qrScanRedisService.createEmitter(guestUuid); + return ResponseEntity.ok(emitter); + + } catch (Exception e) { + // 2. 만약 실패하면 로그를 남기고, 프론트엔드에게 500 에러 반환 + log.error("SSE 구독 생성 실패 - guestUuid: {}", guestUuid, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + //스캔 리스트 제공 api(프런트) + @GetMapping("/history") + public ResponseEntity> getScanHistory + ( + @RequestHeader(value = "guest_uuid", required = true) String guestUuid) { + + // 방어 로직: UUID가 없으면 빈 리스트 반환 + if (guestUuid == null || guestUuid.isBlank()) { + return ResponseEntity.ok(List.of()); + } + + // 서비스 계층 호출해서 Redis 리스트 가져오기 + List historyList = qrScanRedisService.getHistoryFromRedis(guestUuid); - return ResponseEntity.ok(emitter); + return ResponseEntity.ok(historyList); } + } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java new file mode 100644 index 0000000..5b16813 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java @@ -0,0 +1,9 @@ +package com.veriq.veriqbe3.dto; + +public record ProgressRequest( + String guestUuid, // 식별자 (바디) + String step, // UI 매핑용 키값 (예: "내부 db조회 ", "AI 위험 분석") + String status, // 상태 (예: "IN_PROGRESS", "COMPLETED") + String message // 프론트에 띄울 실제 문구 +) { +} \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index a557652..1db152c 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -5,6 +5,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + @Getter @Builder @NoArgsConstructor @@ -16,15 +18,16 @@ public class RedisScanHistoryDto { //private String status; private String scannedAt; // 스캔시간 private String riskLevel; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. - public static RedisScanHistoryDto from(QrScanResponse response) { + public static RedisScanHistoryDto from(AnalysisResponse mlResponse) { return RedisScanHistoryDto.builder() - .schemeType(response.getSchemeType().name()) - .typeInfo(response.getTypeInfo()) + .schemeType("URL") // 분석서버에서 callback해서 온것이므로 무조건 url + .typeInfo(mlResponse.originalUrl()) //.status(response.getStatus()) - .scannedAt(LocalDateTime.now().toString()) - .riskLevel(response.getStatus()) + .scannedAt(LocalDateTime.now().format(FORMATTER)) + .riskLevel(mlResponse.riskLevel()) .build(); } } diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java new file mode 100644 index 0000000..f554493 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -0,0 +1,52 @@ +package com.veriq.veriqbe3.service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; +@Slf4j +@Service +@RequiredArgsConstructor +public class MlRequestService { + @Value("${ml.api.url}") // application.yml에 정의된 파이썬 서버 URL (예: http://.../analyze) + private String mlServerUrl; + + @Value("${ml.server.secret}") // 파이썬 서버랑 맞춘 비밀키 + private String mlServerSecret; + + private final RestTemplate restTemplate; + + public void sendToPythonServer(String guestUuid, String url) { + try { + // 1. 헤더 세팅 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-ML-Secret", mlServerSecret); + headers.set("guest_uuid", guestUuid); // 파이썬이 나중에 콜백할 때 이 UUID를 그대로 돌려줌! + + // 2. 바디 세팅 (파이썬 서버가 요구하는 JSON 규격에 맞춤) 원본 url들어감 + Map body = new HashMap<>(); + body.put("url:", url);//url:"https://naver.com" 이 json형태로 들어감 + + // 3. 엔티티 조립 바디랑 헤더 조립 + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // 4. 파이썬 서버로 POST 요청 발사 + + restTemplate.postForObject(mlServerUrl, requestEntity, String.class); + + log.info("[ML 분석 요청 발송 성공] User: {}, URL: {}", guestUuid, url); + + } catch (Exception e) { + log.error("[ML 분석 요청 발송 실패] User: {}, URL: {}", guestUuid, url, e); + throw new RuntimeException("ML 서버 연동 실패"); + } + } + +} diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index d05479f..dec5932 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -1,6 +1,7 @@ package com.veriq.veriqbe3.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.veriq.veriqbe3.dto.AnalysisResponse; +import com.veriq.veriqbe3.dto.ProgressRequest; import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqbe3.dto.RedisScanHistoryDto; import com.veriq.veriqbe3.entity.ScanHistory; @@ -14,14 +15,15 @@ import org.springframework.transaction.annotation.Transactional; import com.veriq.veriqbe3.domain.SchemeType; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import java.util.Map; + + +import java.io.IOException; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; -import java.util.Optional; + @Slf4j @Service @RequiredArgsConstructor @@ -32,6 +34,7 @@ public class QrScanRedisService { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final ScanHistoryRepository scanHistoryRepository; + private final MlRequestService mlRequestService; private final Map emitters = new ConcurrentHashMap<>(); // ⭐ 고근 님이 작업하실 클래스 (나중에 고근님이 구현하시면 주석 해제) @@ -44,71 +47,119 @@ public class QrScanRedisService { public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) throws Exception { String url = qrDecoder.decode(image); + // 2. 디코딩 성공 시, 프론트로 실시간 텍스트만 쏘기! + if (url != null && !url.isEmpty()) { + String decodingMsg; + //url이 비 url인지 그냥 url인지 구별하는 함수: schemeClassifier + SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); + + if (result.toBe2()) { + // URL일 때 문구 + decodingMsg = "QR 코드 디코딩이 완료되었습니다."; + } else { + // 비 URL일 때 문구 + decodingMsg = "QR 코드 내용을 확인한 결과 URL 형식이 아니었습니다."; + } + + + + this.sendSseEvent(guestUuid, decodingMsg); + + log.info("[SSE] QR 디코딩 완료 알림 전송 - User: {}", guestUuid); + } + SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); QrScanResponse response; if (!result.toBe2()) { // [비 URL] + try { + // 1. [UI 업데이트] 분석 완료 (준비 중) 쏘기 -> progress 채널 + ProgressRequest prepProgress = new ProgressRequest( + guestUuid, + "분석 완료", + "IN_PROGRESS", + "분석 결과를 정리하고 결과 페이지로 이동할 준비를 하고 있습니다" + ); + this.sendSseEvent(guestUuid, objectMapper.writeValueAsString(prepProgress)); + + // 비 URL은 너무 빨리 끝나버려서 UI가 바뀌는 걸 사용자가 볼 수 없으므로, + // 0.5초 정도 살짝 딜레이를 주면 UX가 훨씬 부드러워집니다. + Thread.sleep(500); + + // 2. [UI 업데이트] 분석 완료 (완료됨) 쏘기 -> progress 채널 + ProgressRequest doneProgress = new ProgressRequest( + guestUuid, + "분석 완료", + "COMPLETED", + "분석이 완료되었습니다. 결과 페이지로 이동합니다." + ); + this.sendSseEvent(guestUuid, objectMapper.writeValueAsString(doneProgress)); + + // 3. [데이터 전달] 실제 비 URL 데이터 포장 + Map nonUrlData = new HashMap<>(); + nonUrlData.put("isUrl", false); + nonUrlData.put("schemeType", result.type().name()); + nonUrlData.put("typeInfo", result.typeInfo()); + + String jsonPayload = objectMapper.writeValueAsString(nonUrlData); + + // 4. [종료 신호] 데이터 통째로 전송 후 파이프 닫기 -> COMPLETE 채널 + this.sendSseCompleteEvent(guestUuid, jsonPayload); + + } catch (Exception e) { + log.error("비 URL 처리 중 에러 발생", e); + } + + response = QrScanResponse.builder() .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) .status("COMPLETED").build(); - } else { - // [URL] Redis 캐시 확인 - String urlKey = buildUrlCacheKey(result.typeInfo()); - String cachedJson = redisTemplate.opsForValue().get(urlKey); - - if (cachedJson != null) { - - AnalysisResponse cacheDto = objectMapper.readValue(cachedJson, AnalysisResponse.class); - //RedisUrlCacheDto cacheDto = objectMapper.readValue(cachedJson, RedisUrlCacheDto.class); - // redis 캐시 존재 - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status(cacheDto.riskLevel()) - .build(); - } else { - Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(result.typeInfo()); - if (dbHistory.isPresent()) { - // DB에 존재 -> 엔티티를 DTO로 변환 - AnalysisResponse dbResponse = convertToAnalysisResponse(dbHistory.get()); + }else { + - // 꺼내온 데이터를 다음 요청을 위해 Redis에 캐싱 (Look-Aside) - redisTemplate.opsForValue().set(urlKey, objectMapper.writeValueAsString(dbResponse), URL_CACHE_TTL); - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status(dbResponse.riskLevel()) - .build(); - } - else { - // 3. DB에도 없음 -> 콜백api호출(분석서버에 요청) - // processQrScan.requestAnalysisAsync(result.typeInfo(), guestUuid); + response = QrScanResponse.builder() + .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) + .status("PROCESSING") // 프론트에게 "이제 로딩바 띄우고 기다려" 라고 알림 + .build(); - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status("PROCESSING").build(); - } + // 파이썬 서버로 던지는 로직 + try { + mlRequestService.sendToPythonServer(guestUuid, result.typeInfo()); + log.info("파이썬 서버로 분석 요청 완료 - User: {}, URL: {}", guestUuid, result.typeInfo()); + } catch (Exception e) { + log.error("파이썬 서버 요청 중 에러 발생", e); } + + } - // Redis 스캔 히스토리 저장 - saveHistoryToRedis(guestUuid, response); return response; + } // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API public AnalysisResponse getUrlDetail(String url) throws Exception { String cacheKey = buildUrlCacheKey(url); String jsonCache = redisTemplate.opsForValue().get(cacheKey); + //상세보고서 요청시 redis에서 분석 결과 꺼내오는 과정 + //캐시값이 꺠졌을떄 대비해서 try catch문 추가 if (jsonCache != null) { - log.info("Redis에서 상세 분석 결과 반환: {}", url); - return objectMapper.readValue(jsonCache, AnalysisResponse.class); + try { + log.info("Redis 캐시 적중! 상세 분석 결과 반환: {}", url); + return objectMapper.readValue(jsonCache, AnalysisResponse.class); + } catch (Exception e) { + // 캐시 데이터가 깨졌을 경우 처리 + log.error("Redis 캐시 파싱 실패 (손상된 데이터). 캐시 삭제 후 DB 조회를 시도합니다. URL: {}", url, e); + redisTemplate.delete(cacheKey); // 깨진 데이터 삭제 + // 여기서 return하지 않고 그대로 두면 아래의 DB 조회 로직으로 자연스럽게 넘어감 + } } - // Redis에 없으면 DB에서 조회 Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(url); if (dbHistory.isEmpty()) { @@ -140,16 +191,17 @@ public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { } } - private void saveHistoryToRedis(String guestUuid, QrScanResponse response) { + public void saveHistoryToRedis(String guestUuid, AnalysisResponse mlResponse) { // 방어 로직: UUID가 없거나 비어있거나 익명이면, 저장하지 않고 그냥 반환 if (guestUuid == null || guestUuid.isBlank() || guestUuid.equals("ANONYMOUS")) { return; } try { String historyKey = "history:" + guestUuid; - RedisScanHistoryDto item = RedisScanHistoryDto.from(response); + // QR 원본 정보와 ML 분석 결과를 합쳐서 히스토리 DTO 생성! + RedisScanHistoryDto item = RedisScanHistoryDto.from(mlResponse); String jsonItem = objectMapper.writeValueAsString(item); - + // Redis 리스트의 맨 앞(최신)에 밀어 넣기 redisTemplate.opsForList().leftPush(historyKey, jsonItem); // 항상 50개만 남기고 옛날 데이터는 잘라버림 redisTemplate.opsForList().trim(historyKey, 0, MAX_HISTORY_SIZE - 1); @@ -226,7 +278,9 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { ); } - @Transactional // DB 저장하다 에러 나면 롤백!,분석서버에서 온 결과 db 및 redis에 저장 + @Transactional + // DB 저장하다 에러 나면 롤백!, + // 분석서버에서 온 결과 db 및 redis에 저장 public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String guestUuid) { try { @@ -308,15 +362,9 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // ========================================== cacheAnalysisResult(url, responseDto); - log.info("DB 저장 완료. 프론트엔드(guest_uuid: {})로 SSE 알림 발송 시도", guestUuid); + log.info("DB 저장 완료"); // 프론트엔드로 프로세스 완료 알림 - SseEmitter emitter = emitters.get(guestUuid); - if (emitter != null) { - // "COMPLETE"라는 이름표를 붙여서 URL 데이터를 쏴줌 - emitter.send(SseEmitter.event() - .name("COMPLETE") - .data(responseDto.originalUrl())); - } + } catch (Exception e) { log.error("DB 저장 및 캐싱 중 에러 발생: {}", responseDto.originalUrl(), e); @@ -327,18 +375,97 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues public SseEmitter createEmitter(String guestUuid) { SseEmitter emitter = new SseEmitter(300000L); // 5분 타임아웃 emitters.put(guestUuid, emitter); // 명부에 등록 + //3. 연결되자마자 프론트엔드에게 환영 인사(Init) 보내기! + try { + emitter.send(SseEmitter.event() + .name("INIT") // 채널 이름을 INIT으로 설정 + .data("SSE 파이프라인 연결 성공! 분석 대기 중...")); + + log.info(" [SSE] 유저와 파이프라인 연결 성공: guestUuid = {}", guestUuid); + } catch (IOException e) { + emitters.remove(guestUuid); + log.error(" SSE 초기 메시지 전송 실패", e); + } // 파이프 연결이 끊기면 명부에서 삭제 emitter.onCompletion(() -> emitters.remove(guestUuid)); emitter.onTimeout(() -> emitters.remove(guestUuid)); + + return emitter; + } + /** + * 특정 guestUuid를 가진 프론트엔드 파이프로 메시지를 전송합니다. + */ + public void sendSseEvent(String guestUuid, String message) { + // 1. Map에 저장해둔 해당 유저의 파이프(SseEmitter)를 꺼냅니다. + + SseEmitter emitter = emitters.get(guestUuid); + + if (emitter != null) { + try { + // 2. 파이프가 존재하면 프론트로 메시지를 전송 + emitter.send(SseEmitter.event() + .name("progress") // 프론트엔드에서 이 이름으로 이벤트를 수신할 수 있습니다. + .data(message)); // 실제 전송할 텍스트 ("도메인 사칭 분석 완료" 등) + + } catch (Exception e) { + // 3. 만약 쏘는 중에 에러가 났다? (프론트가 새로고침해서 파이프가 끊긴 경우 등) + // 그러면 죽은 파이프는 미련 없이 버립니다. + emitters.remove(guestUuid); + log.error(" SSE 파이프 전송 실패 및 삭제: guestUuid = {}", guestUuid, e); + } + } else { + // 파이프가 아예 없는 경우 (이미 만료되었거나 연결된 적 없는 경우) + log.warn("⚠전송할 SSE 파이프를 찾을 수 없습니다: guestUuid = {}", guestUuid); + } + } + // 🏁 최종완료용 (채널 이름: COMPLETE) + public void sendSseCompleteEvent(String guestUuid, String url) { + SseEmitter emitter = emitters.get(guestUuid); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("COMPLETE") + .data(url)); + + // 완료 신호를 보낸 후에는 파이프를 깔끔하게 닫아주는 게 좋습니다. + emitter.complete(); + emitters.remove(guestUuid); + } catch (Exception e) { + emitters.remove(guestUuid); + } + } + + } + // 저장된 히스토리 목록 가져오기 + public List getHistoryFromRedis(String guestUuid) { + String historyKey = "history:" + guestUuid; try { - // 연결되자마자 503 에러 방지용 더미(Dummy) 데이터 하나 전송 - emitter.send(SseEmitter.event().name("INIT").data("Connected!")); + // 0부터 -1은 리스트의 '처음부터 끝까지'를 의미합니다. + List jsonHistoryList = redisTemplate.opsForList().range(historyKey, 0, -1); + + if (jsonHistoryList == null || jsonHistoryList.isEmpty()) { + return List.of(); + } + + // Redis에 저장된 String(JSON)을 다시 Object(Map)로 변환해서 리턴해야 + // 프론트엔드에서 깔끔한 JSON 배열로 받을 수 있습니다. + return jsonHistoryList.stream() + .map(json -> { + try { + return objectMapper.readValue(json, Object.class); + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } catch (Exception e) { - emitters.remove(guestUuid); + log.error("Redis 히스토리 조회 실패 - guestUuid: {}", guestUuid, e); + return List.of(); } - return emitter; } diff --git a/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java index 8e9545d..4d0761b 100644 --- a/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java +++ b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java @@ -4,7 +4,7 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; import java.time.Duration; -import java.time.Duration; + @Configuration public class RestTemplateConfig { @Bean diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index d84155c..df368c9 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -1,4 +1,5 @@ package com.veriq.veriqgateway.controller; +import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqgateway.dto.ScanResponse; import com.veriq.veriqgateway.service.SecurityService; import org.springframework.context.annotation.Profile; @@ -107,17 +108,28 @@ public String getFilename() { // RestTemplate을 이용해 서버(BE 3) 호출 System.out.println(">>> [BE1] BE3(" + BE3_URL + ")로 데이터 전송 시도 중..."); - // BE3가 반환하는 QrScanResponse(JSON)를 그대로 Object 형태로 받습니다. - ResponseEntity responseFromBe3 = restTemplate.postForEntity(BE3_URL, requestEntity, Object.class); + + ResponseEntity responseFromBe3 = restTemplate.postForEntity(BE3_URL, requestEntity, QrScanResponse.class); System.out.println(">>> [BE1] BE3로부터 응답 무사히 도착!"); // --- [STEP 3] 최종 성공 응답 --- - return ResponseEntity.status(responseFromBe3.getStatusCode()) - .body(responseFromBe3.getBody()); + QrScanResponse be3Data = responseFromBe3.getBody(); + if (be3Data == null) { + throw new RuntimeException("BE3 서버로부터 빈 응답(null)이 도착했습니다."); + } + + ScanResponse finalResponse = ScanResponse.builder() + .guestUuid(be3Data.getGuestUuid()) + .status(be3Data.getStatus()) + // BE3의 핵심 데이터(URL 등)를 프론트가 읽기 편하게 message에 담아줍니다. + .message("스캔 완료. 타입: " + be3Data.getSchemeType() + ", 정보: " + be3Data.getTypeInfo()) + .build(); + + return ResponseEntity.status(responseFromBe3.getStatusCode()).body(finalResponse); } catch (org.springframework.web.client.RestClientResponseException e) { - // ⭐ [추가] BE3가 던진 4xx, 5xx 에러를 그대로 받아서 프론트에 토스! + // BE3가 던진 4xx, 5xx 에러를 그대로 받아서 프론트에 토스! System.out.println(">>> [BE1] BE3로부터 에러 응답 수신: " + e.getStatusCode()); return ResponseEntity.status(e.getStatusCode()) // BE3가 준 400, 401 등을 그대로 사용 diff --git a/src/main/resources/application-be1.yml b/src/main/resources/application-be1.yml index 196e3bc..d4af7f2 100644 --- a/src/main/resources/application-be1.yml +++ b/src/main/resources/application-be1.yml @@ -38,4 +38,7 @@ google: be3: api: - url: "http://veriq-be3:8083/api/v1/scan/upload" \ No newline at end of file + url: "http://veriq-be3:8083/api/v1/scan/upload" +# 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) +frontend: + url: ${FRONTEND_CORS_URL:http://localhost:3000} \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 48f8192..c0fec0a 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -31,5 +31,12 @@ spring: ml: server: - # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽어 (없으면 default_secret 사용) - secret: ${ML_SECRET_KEY:default_secret} \ No newline at end of file + # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽음 + secret: ${ML_SECRET_KEY} + api : + url: "http://veriq-analysis:8000/api/v1/scan/upload"#재조정 + + +# 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) +frontend: + url: ${FRONTEND_CORS_URL:http://localhost:3000} \ No newline at end of file From b75df3f4ff4d06677aaea1fe27b423add02d60a9 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 15 Apr 2026 10:02:08 +0900 Subject: [PATCH 065/136] =?UTF-8?q?refactor-feat/kd-16:=20QR=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 11 +- .../veriq/veriqbe3/dto/QrScanResponse.java | 2 + .../veriqbe3/service/MlRequestService.java | 17 ++- .../veriqbe3/service/QrScanRedisService.java | 126 +++++++----------- src/main/resources/application-be3.yml | 2 +- 5 files changed, 74 insertions(+), 84 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 56f052a..6229434 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -38,7 +38,7 @@ public class QrScanController { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //multipart_form_data 제한 public ResponseEntity uploadQrImage( //반환 http body 타입 제한 - @RequestHeader(value = "guest_uuid", required = false ) String guestUuid, + @RequestHeader(value = "guest_uuid" ) String guestUuid, @RequestParam("image") MultipartFile image) { try { QrScanResponse response = qrScanRedisService.processWithRedis(image, guestUuid); @@ -159,10 +159,17 @@ public ResponseEntity analysisProgressCallback( try { // 1. 보안 검증 (기존 로직과 동일하게 유지) - if (providedSecret == null || !providedSecret.equals(mlServerSecret)) { + if (providedSecret == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("접근 권한이 없습니다."); } + byte[] providedBytes = providedSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] expectedBytes = mlServerSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8); + +// 길이가 다르거나, 내부 바이트가 하나라도 다르면 거절 (비교 시간은 항상 일정함) + if (providedBytes.length != expectedBytes.length || !java.security.MessageDigest.isEqual(providedBytes, expectedBytes)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("접근 권한이 없습니다."); + } // 2. 파이썬이 보내준 메시지 추출 String ssePayload = objectMapper.writeValueAsString(request); diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 01e5d40..20046a0 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -11,4 +11,6 @@ public class QrScanResponse { private SchemeType schemeType; private String typeInfo; private String status; + private boolean isUrl; + private String message; } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index f554493..e2e28b0 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -32,7 +32,7 @@ public void sendToPythonServer(String guestUuid, String url) { // 2. 바디 세팅 (파이썬 서버가 요구하는 JSON 규격에 맞춤) 원본 url들어감 Map body = new HashMap<>(); - body.put("url:", url);//url:"https://naver.com" 이 json형태로 들어감 + body.put("url", url);//url:"https://naver.com" 이 json형태로 들어감 // 3. 엔티티 조립 바디랑 헤더 조립 HttpEntity> requestEntity = new HttpEntity<>(body, headers); @@ -40,8 +40,21 @@ public void sendToPythonServer(String guestUuid, String url) { // 4. 파이썬 서버로 POST 요청 발사 restTemplate.postForObject(mlServerUrl, requestEntity, String.class); + //로그에 guestuuid 정보와 url정보는 일부만 남긴다 - log.info("[ML 분석 요청 발송 성공] User: {}, URL: {}", guestUuid, url); + String maskedUuid = (guestUuid != null && guestUuid.length() > 8) + ? guestUuid.substring(0, 8) + "****" + : "UNKNOWN"; + + String safeHost = "UNKNOWN_HOST"; + try { + java.net.URI uri = new java.net.URI(url); + safeHost = uri.getHost(); // https://naver.com/token=123 -> naver.com 만 추출 + } catch (Exception ignored) { + // URL 형식이 아닐 경우 무시 + } + + log.info("[ML 분석 요청 발송 성공] User: {}, Host: {}", maskedUuid, safeHost); } catch (Exception e) { log.error("[ML 분석 요청 발송 실패] User: {}, URL: {}", guestUuid, url, e); diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index dec5932..6de5d6d 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -37,8 +37,8 @@ public class QrScanRedisService { private final MlRequestService mlRequestService; private final Map emitters = new ConcurrentHashMap<>(); - // ⭐ 고근 님이 작업하실 클래스 (나중에 고근님이 구현하시면 주석 해제) - // private final ProcessQrScan processQrScan; + + private static final int MAX_HISTORY_SIZE = 50; private static final Duration HISTORY_TTL = Duration.ofDays(7); @@ -48,98 +48,66 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th String url = qrDecoder.decode(image); // 2. 디코딩 성공 시, 프론트로 실시간 텍스트만 쏘기! - if (url != null && !url.isEmpty()) { - String decodingMsg; - //url이 비 url인지 그냥 url인지 구별하는 함수: schemeClassifier - SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); - - if (result.toBe2()) { - // URL일 때 문구 - decodingMsg = "QR 코드 디코딩이 완료되었습니다."; - } else { - // 비 URL일 때 문구 - decodingMsg = "QR 코드 내용을 확인한 결과 URL 형식이 아니었습니다."; - } - - - - this.sendSseEvent(guestUuid, decodingMsg); - - log.info("[SSE] QR 디코딩 완료 알림 전송 - User: {}", guestUuid); + // [방어 로직] 디코딩 실패 시 (QR이 아니거나 깨진 이미지) 즉시 에러 던지기 + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("QR 코드 디코딩에 실패했습니다."); } + // 2. URL 분류 SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); - QrScanResponse response; - - if (!result.toBe2()) { - // [비 URL] - try { - // 1. [UI 업데이트] 분석 완료 (준비 중) 쏘기 -> progress 채널 - ProgressRequest prepProgress = new ProgressRequest( - guestUuid, - "분석 완료", - "IN_PROGRESS", - "분석 결과를 정리하고 결과 페이지로 이동할 준비를 하고 있습니다" - ); - this.sendSseEvent(guestUuid, objectMapper.writeValueAsString(prepProgress)); - - // 비 URL은 너무 빨리 끝나버려서 UI가 바뀌는 걸 사용자가 볼 수 없으므로, - // 0.5초 정도 살짝 딜레이를 주면 UX가 훨씬 부드러워집니다. - Thread.sleep(500); - - // 2. [UI 업데이트] 분석 완료 (완료됨) 쏘기 -> progress 채널 - ProgressRequest doneProgress = new ProgressRequest( - guestUuid, - "분석 완료", - "COMPLETED", - "분석이 완료되었습니다. 결과 페이지로 이동합니다." - ); - this.sendSseEvent(guestUuid, objectMapper.writeValueAsString(doneProgress)); - - // 3. [데이터 전달] 실제 비 URL 데이터 포장 - Map nonUrlData = new HashMap<>(); - nonUrlData.put("isUrl", false); - nonUrlData.put("schemeType", result.type().name()); - nonUrlData.put("typeInfo", result.typeInfo()); - - String jsonPayload = objectMapper.writeValueAsString(nonUrlData); - - // 4. [종료 신호] 데이터 통째로 전송 후 파이프 닫기 -> COMPLETE 채널 - this.sendSseCompleteEvent(guestUuid, jsonPayload); - - } catch (Exception e) { - log.error("비 URL 처리 중 에러 발생", e); - } - + // 3. 프론트로 실시간 텍스트 쏘기 (삼항 연산자로 깔끔하게 정리) + // 1. URL 여부와 메시지를 명확한 변수로 빼둡니다. + boolean isUrl = result.toBe2(); + String decodingMsg = isUrl + ? "QR 코드 디코딩이 완료되었습니다." + : "QR 코드 내용을 확인한 결과 URL 형식이 아니었습니다."; - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status("COMPLETED").build(); - - }else { + log.info("[Decode] {}", decodingMsg); +// 2. [비 URL] 처리 로직 + if (!isUrl) { + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("COMPLETED") + .isUrl(false) + .message(decodingMsg) + .build(); + } - response = QrScanResponse.builder() - .guestUuid(guestUuid).schemeType(result.type()).typeInfo(result.typeInfo()) - .status("PROCESSING") // 프론트에게 "이제 로딩바 띄우고 기다려" 라고 알림 +// 3. [URL] 처리 로직 +// 3-1. 캐시(Redis) 확인 로직 (Fast-Path) + AnalysisResponse cachedResult = getUrlDetail(result.typeInfo()); + if (cachedResult != null) { + log.info("[Fast-Path] 캐시랑 db에 존재 - URL: {}", result.typeInfo()); + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("COMPLETED") + .isUrl(true) // 🌟 가흔 님이 쓸 변수 1 (URL 로딩창 띄우기용) + .message(decodingMsg) // 🌟 가흔 님이 쓸 변수 2 .build(); + } - // 파이썬 서버로 던지는 로직 + // 5-2. 캐시에 없으면 기존대로 분석 요청 + try { + mlRequestService.sendToPythonServer(guestUuid, result.typeInfo()); + log.info("파이썬 분석 요청 완료."); // (이전에 피드백받은 로그 마스킹은 MlRequestService 쪽에 잘 하셨죠? ㅎㅎ) - try { - mlRequestService.sendToPythonServer(guestUuid, result.typeInfo()); - log.info("파이썬 서버로 분석 요청 완료 - User: {}, URL: {}", guestUuid, result.typeInfo()); - } catch (Exception e) { - log.error("파이썬 서버 요청 중 에러 발생", e); - } + return QrScanResponse.builder() + .guestUuid(guestUuid) + .status("PROCESSING") + .build(); + } catch (Exception e) { + log.error("분석 요청 실패", e); + throw new RuntimeException("ML 서버 통신 실패", e); + } - } - return response; } // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index c0fec0a..1fa089d 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -34,7 +34,7 @@ ml: # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽음 secret: ${ML_SECRET_KEY} api : - url: "http://veriq-analysis:8000/api/v1/scan/upload"#재조정 + url: "http://veriq-analysis:8000/api/v1/scan/upload" #재조정 # 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) From 7de4ff9b3cdea369f5dbf089f55c1d99216b30b6 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 15 Apr 2026 10:02:19 +0900 Subject: [PATCH 066/136] =?UTF-8?q?refactor-feat/kd-16:=20QR=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/config/RestTemplateConfig.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java index 61d26d2..04ae6c7 100644 --- a/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java +++ b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java @@ -1,16 +1,14 @@ + package com.veriq.veriqbe3.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; -import java.time.Duration; import java.time.Duration; - @Configuration public class RestTemplateConfig { @Bean - public RestTemplate restTemplate() { + public org.springframework.web.client.RestTemplate restTemplate() { // 1. 타임아웃 설정을 담당하는 공장(Factory) 생성 SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); @@ -19,7 +17,7 @@ public RestTemplate restTemplate() { factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 대기 10초 제한 // 3. 설정된 공장을 RestTemplate에 넣어서 완성 - return new RestTemplate(factory); + return new org.springframework.web.client.RestTemplate(factory); } -} +} From 9ce860a4f05cc709efac178c98221a07b210793a Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 15 Apr 2026 11:17:27 +0900 Subject: [PATCH 067/136] =?UTF-8?q?refactor-feat/kd-16:=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EC=9E=90=20=EC=84=A4=EC=A0=95=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../veriq/veriqbe3/VeriQBe3Application.java | 4 ++-- .../veriq/veriqbe3/config/AsyncConfig.java | 22 +++++++++++++++++++ .../veriqbe3/controller/QrScanController.java | 7 ++++-- .../veriq/veriqbe3/dto/ProgressRequest.java | 3 ++- .../veriq/veriqbe3/dto/QrScanResponse.java | 2 ++ .../veriqbe3/dto/RedisScanHistoryDto.java | 3 ++- .../veriqbe3/service/QrScanRedisService.java | 5 +++-- 8 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java diff --git a/build.gradle b/build.gradle index e604d63..94f0f9e 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java index 5e5848d..3b1b8a4 100644 --- a/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java +++ b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java @@ -2,10 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableAsync; + @SpringBootApplication -@EnableAsync + public class VeriQBe3Application { public static void main(String[] args) { diff --git a/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java b/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java new file mode 100644 index 0000000..c068821 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java @@ -0,0 +1,22 @@ +package com.veriq.veriqbe3.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; // 🌟 여기로 이사 왔습니다! +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.concurrent.Executor; +@Configuration +@EnableAsync + +public class AsyncConfig { + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); //기본 스레드 수 + executor.setMaxPoolSize(20); //최대 스레드 수 + executor.setQueueCapacity(50); //대기열 크기 + executor.setThreadNamePrefix("VeriQ-Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 6229434..4e6f370 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -16,6 +16,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.beans.factory.annotation.Value; +import jakarta.validation.Valid; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -45,7 +46,8 @@ public ResponseEntity uploadQrImage( //반환 http body 타 return ResponseEntity.ok(response); } catch (Exception e) { - e.printStackTrace(); + + log.error("QR 스캔 요청 처리 중 에러 발생", e); return ResponseEntity.badRequest().build(); } } @@ -155,7 +157,7 @@ public ResponseEntity analysisCallback( public ResponseEntity analysisProgressCallback( // 누구의 파이프인지 식별자 필수! @RequestHeader(value = "X-ML-Secret", required = false) String providedSecret, - @RequestBody ProgressRequest request) { // { "step": "DOMAIN_CHECK", "message": "도메인 사칭 분석 완료" } + @Valid @RequestBody ProgressRequest request) { // { "step": "DOMAIN_CHECK", "message": "도메인 사칭 분석 완료" } try { // 1. 보안 검증 (기존 로직과 동일하게 유지) @@ -182,6 +184,7 @@ public ResponseEntity analysisProgressCallback( return ResponseEntity.ok("상태 업데이트 성공!"); } catch (Exception e) { + log.error("진행 상태 콜백 처리 중 에러 발생 - guestUuid: {}", request.guestUuid(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("에러 발생"); } } diff --git a/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java index 5b16813..117c32e 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java +++ b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java @@ -1,7 +1,8 @@ package com.veriq.veriqbe3.dto; +import jakarta.validation.constraints.NotBlank; public record ProgressRequest( - String guestUuid, // 식별자 (바디) + @NotBlank String guestUuid, // 식별자 (바디) String step, // UI 매핑용 키값 (예: "내부 db조회 ", "AI 위험 분석") String status, // 상태 (예: "IN_PROGRESS", "COMPLETED") String message // 프론트에 띄울 실제 문구 diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 20046a0..99294a8 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -1,6 +1,7 @@ package com.veriq.veriqbe3.dto; import com.veriq.veriqbe3.domain.SchemeType; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Getter; @@ -11,6 +12,7 @@ public class QrScanResponse { private SchemeType schemeType; private String typeInfo; private String status; + @JsonProperty("isUrl") private boolean isUrl; private String message; } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 1db152c..3e28ff9 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -3,7 +3,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - +import java.util.Objects; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -22,6 +22,7 @@ public class RedisScanHistoryDto { // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. public static RedisScanHistoryDto from(AnalysisResponse mlResponse) { + Objects.requireNonNull(mlResponse, "mlResponse가 null일 수 없습니다!"); return RedisScanHistoryDto.builder() .schemeType("URL") // 분석서버에서 callback해서 온것이므로 무조건 url .typeInfo(mlResponse.originalUrl()) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 6de5d6d..51c935a 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -294,10 +294,11 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // 5. InternalDbInfo 빌드 .internalDb(ScanHistory.InternalDbInfo.builder() .dbExists(responseDto.internalDb() != null && responseDto.internalDb().exists()) - .dbReportCount(responseDto.internalDb() != null ? responseDto.internalDb().reportCount() : 0) - .dbBlockCount(responseDto.internalDb() != null ? responseDto.internalDb().blockCount() : 0) + .dbReportCount(responseDto.internalDb() != null && responseDto.internalDb().reportCount() != null ? responseDto.internalDb().reportCount() : 0) + .dbBlockCount(responseDto.internalDb() != null && responseDto.internalDb().blockCount() != null ? responseDto.internalDb().blockCount() : 0) .build()) + // 6. RedirectInfo 빌드 .redirect(ScanHistory.RedirectInfo.builder() .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) From d4f2294a2a9d0a1853f7fbeac8125d1b8da4a3b9 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 15 Apr 2026 12:40:28 +0900 Subject: [PATCH 068/136] =?UTF-8?q?refactor-feat/kd-16:=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B3=84=EC=B8=B5=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20DB=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 21 ++++++++++++++++--- .../veriq/veriqbe3/entity/ScanHistory.java | 5 ++++- .../veriqbe3/service/QrScanRedisService.java | 6 ++++-- .../controller/GatewayController.java | 6 ++++-- .../veriq/veriqgateway/dto/ScanResponse.java | 3 +++ 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 4e6f370..5501db7 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -43,13 +43,28 @@ public ResponseEntity uploadQrImage( //반환 http body 타 @RequestParam("image") MultipartFile image) { try { QrScanResponse response = qrScanRedisService.processWithRedis(image, guestUuid); - return ResponseEntity.ok(response); + return ResponseEntity.ok(response); - } catch (Exception e) { - log.error("QR 스캔 요청 처리 중 에러 발생", e); + // 프런트가 이상한 데이터를 보냈을 때 (사용자 잘못 -> 400) + } catch (IllegalArgumentException e) { + log.warn("잘못된 QR 업로드 요청 - guestUuid: {}", guestUuid, e); return ResponseEntity.badRequest().build(); + +// 파이썬 서버가 죽었거나, DB가 터졌을 때 (분석 서버 잘못 -> 502) + } catch (org.springframework.web.client.RestClientException e) { + log.error("QR 스캔 요청 처리 중 서버(다운스트림) 에러 발생 - guestUuid: {}", guestUuid, e); + // 파이썬 서버 통신 실패 시 (502 반환) + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + } + /// 스프링 서버 내부에서 뭔가 터졌을 때 (스프링 서버잘못 -> 500) + catch (Exception e) { + log.error("QR 스캔 요청 처리 중 서버 내부 에러 발생 - guestUuid: {}", guestUuid, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } + + + } // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 @GetMapping("/detail") diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index bbe4342..3ad70a1 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -9,7 +9,10 @@ @Entity @Table(name = "scan_history",indexes={ @Index(name = "idx_final_url", columnList = "finalUrl"), - @Index(name = "idx_original_url_scanned_at", columnList = "originalUrl, scannedAt DESC") + // 추가 스캔 보고서 제공시 url을 매게로 하기떄문에 idndexing에 추가, + // URL과 스캔 시간을 분리해서 인덱스를 걸어줍니다. + @Index(name = "idx_original_url", columnList = "originalUrl"), + @Index(name = "idx_scanned_at", columnList = "scannedAt") }) @Getter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 51c935a..2501480 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -86,8 +86,8 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th .guestUuid(guestUuid) .typeInfo(result.typeInfo()) .status("COMPLETED") - .isUrl(true) // 🌟 가흔 님이 쓸 변수 1 (URL 로딩창 띄우기용) - .message(decodingMsg) // 🌟 가흔 님이 쓸 변수 2 + .isUrl(true) // + .message(decodingMsg) // .build(); } @@ -402,6 +402,8 @@ public void sendSseCompleteEvent(String guestUuid, String url) { emitter.complete(); emitters.remove(guestUuid); } catch (Exception e) { + // 추가: 누가 끊겼는지, 왜 끊겼는지 경고 로그를 남겨줍니다. + log.warn("SSE COMPLETE 이벤트 전송 실패 - guestUuid: {}", guestUuid, e); emitters.remove(guestUuid); } } diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index df368c9..6a2e52c 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -122,8 +122,10 @@ public String getFilename() { ScanResponse finalResponse = ScanResponse.builder() .guestUuid(be3Data.getGuestUuid()) .status(be3Data.getStatus()) - // BE3의 핵심 데이터(URL 등)를 프론트가 읽기 편하게 message에 담아줍니다. - .message("스캔 완료. 타입: " + be3Data.getSchemeType() + ", 정보: " + be3Data.getTypeInfo()) + .message(be3Data.getMessage()) + .isUrl(be3Data.isUrl()) + .schemeType(be3Data.getSchemeType() != null ? be3Data.getSchemeType().name() : null) + .typeInfo(be3Data.getTypeInfo()) .build(); return ResponseEntity.status(responseFromBe3.getStatusCode()).body(finalResponse); diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index 64c221a..a708ed4 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -10,4 +10,7 @@ public class ScanResponse { private String status; // PENDING 등 private String error_code; // REQUIRE_CAPTCHA private String message; + private boolean isUrl; + private String schemeType; + private String typeInfo; } From 3735a5db448ecbbea4f85ef265eba300463d5110 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 15 Apr 2026 14:22:15 +0900 Subject: [PATCH 069/136] =?UTF-8?q?refactor-feat/kd-16:=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=A0=84=ED=8C=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 15 +++++++++++++++ .../veriqbe3/service/QrScanRedisService.java | 10 +++++++--- .../com/veriq/veriqgateway/dto/ScanResponse.java | 2 ++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 5501db7..f771ce4 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,6 +35,7 @@ public class QrScanController { //private final ProcessQrScan processQrScan; //의존성 주입 private final QrScanRedisService qrScanRedisService; // 서비스 의존성 주입 private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; @@ -124,6 +127,18 @@ public ResponseEntity analysisCallback( // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! // 2. 비밀번호가 맞을 때만 DB 저장 및 캐싱을 진행 + String idempotencyKey = "callbackdone:" + guestUuid; + +// 2. Redis에 방명록 쓰기 시도 (setIfAbsent = 없으면 쓰고 true, 있으면 안 쓰고 false 반환) +// TTL은 파이썬이 재시도할 만한 넉넉한 시간인 10분 정도로 줍니다. + Boolean isFirst = redisTemplate.opsForValue() + .setIfAbsent(idempotencyKey, "PROCESSED", Duration.ofMinutes(10)); + +// 3. 이미 방명록에 이름이 있다면? (중복 콜백이면) 바로 돌려보냄! + if (Boolean.FALSE.equals(isFirst)) { + log.info("이미 처리된 중복 콜백 요청입니다. 무시합니다. - guestUuid: {}", guestUuid); + return ResponseEntity.ok("이미 처리되었습니다."); + } qrScanRedisService.saveAndCacheAnalysisResult(resultDto, guestUuid); // 3.스캔 히스토리 저장 qrScanRedisService.saveHistoryToRedis(guestUuid, resultDto); diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 2501480..3598b96 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -100,15 +100,19 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th .guestUuid(guestUuid) .status("PROCESSING") .build(); + } //파이썬 통신 에러(502) + catch (org.springframework.web.client.RestClientException e) { + log.error("ML 서버 통신 실패 (RestClientException) - guestUuid: {}", guestUuid, e); + throw e; // 이렇게 던져야 컨트롤러가 잡아서 502 Bad Gateway를 내려줍니다. + +// 나머지 진짜 알 수 없는 런타임 에러들만 감싸서 던집니다.(500에러-서버에러) } catch (Exception e) { - log.error("분석 요청 실패", e); + log.error("분석 요청 중 알 수 없는 에러", e); throw new RuntimeException("ML 서버 통신 실패", e); } - - } // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API public AnalysisResponse getUrlDetail(String url) throws Exception { diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index a708ed4..4d3cc3a 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,6 +1,7 @@ package com.veriq.veriqgateway.dto; import lombok.Builder; import lombok.Getter; +import com.fasterxml.jackson.annotation.JsonProperty; @Getter @Builder @@ -10,6 +11,7 @@ public class ScanResponse { private String status; // PENDING 등 private String error_code; // REQUIRE_CAPTCHA private String message; + @JsonProperty("isUrl") private boolean isUrl; private String schemeType; private String typeInfo; From 39758508cbedaf5a56689607c954ba6886a4c1c8 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 16 Apr 2026 21:25:57 +0900 Subject: [PATCH 070/136] =?UTF-8?q?fix-feat/kd-16:=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=ED=82=A4=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20Null-Safe=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/config/AsyncConfig.java | 10 ++++++++++ .../veriq/veriqbe3/controller/QrScanController.java | 8 +++++--- .../com/veriq/veriqbe3/service/QrScanRedisService.java | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java b/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java index c068821..7b6e358 100644 --- a/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java +++ b/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java @@ -5,6 +5,8 @@ import org.springframework.scheduling.annotation.EnableAsync; // 🌟 여기로 이사 왔습니다! import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + @Configuration @EnableAsync @@ -16,6 +18,14 @@ public Executor taskExecutor() { executor.setMaxPoolSize(20); //최대 스레드 수 executor.setQueueCapacity(50); //대기열 크기 executor.setThreadNamePrefix("VeriQ-Async-"); + // 🌟큐가 꽉 차면, 에러 내지 말고 요청한 스레드가 직접 처리하게 함 (데이터 유실 방지) + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + // 서버 종료 시, 진행 중인 작업이(파이썬 분석)끝날 때까지 기다려줌,분석 결과 서버꺼지기전 db에 저장케함 + executor.setWaitForTasksToCompleteOnShutdown(true); + + // 단, 최대 30초까지만 기다려주고 강제 종료함 (서버 무한 대기 방지) + executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index f771ce4..fa96b07 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -127,8 +127,8 @@ public ResponseEntity analysisCallback( // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! // 2. 비밀번호가 맞을 때만 DB 저장 및 캐싱을 진행 - String idempotencyKey = "callbackdone:" + guestUuid; - + String safeUuid = (guestUuid != null) ? guestUuid : "unknown"; + String idempotencyKey = "callbackdone:" + safeUuid + ":" + resultDto.originalUrl(); // 2. Redis에 방명록 쓰기 시도 (setIfAbsent = 없으면 쓰고 true, 있으면 안 쓰고 false 반환) // TTL은 파이썬이 재시도할 만한 넉넉한 시간인 10분 정도로 줍니다. Boolean isFirst = redisTemplate.opsForValue() @@ -214,7 +214,9 @@ public ResponseEntity analysisProgressCallback( return ResponseEntity.ok("상태 업데이트 성공!"); } catch (Exception e) { - log.error("진행 상태 콜백 처리 중 에러 발생 - guestUuid: {}", request.guestUuid(), e); + String uuid = (request != null && request.guestUuid() != null) ? request.guestUuid() : "unknown"; + + log.error("진행 상태 콜백 처리 중 에러 발생 - guestUuid: {}", uuid, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("에러 발생"); } } diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 3598b96..bb7ae8b 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -78,7 +78,7 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th } // 3. [URL] 처리 로직 -// 3-1. 캐시(Redis) 확인 로직 (Fast-Path) +// 3-1. 캐시(Redis) 확인 로직 AnalysisResponse cachedResult = getUrlDetail(result.typeInfo()); if (cachedResult != null) { log.info("[Fast-Path] 캐시랑 db에 존재 - URL: {}", result.typeInfo()); From 6d9a20da55bc8b5ddcb6e845e6c1be3bc0dd20b6 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 16 Apr 2026 22:23:13 +0900 Subject: [PATCH 071/136] =?UTF-8?q?fix-feat/kd-16:=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=ED=82=A4=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20Null-Safe=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/service/MlRequestService.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index e2e28b0..0b7a1a5 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -57,8 +57,24 @@ public void sendToPythonServer(String guestUuid, String url) { log.info("[ML 분석 요청 발송 성공] User: {}, Host: {}", maskedUuid, safeHost); } catch (Exception e) { - log.error("[ML 분석 요청 발송 실패] User: {}, URL: {}", guestUuid, url, e); - throw new RuntimeException("ML 서버 연동 실패"); + // [STEP 1] 실패 로그용 마스킹 처리 (성공 로직과 동일하게!) + String maskedUuid = (guestUuid != null && guestUuid.length() > 8) + ? guestUuid.substring(0, 8) + "****" + : "UNKNOWN"; + + String safeHost = "UNKNOWN_HOST"; + try { + java.net.URI uri = new java.net.URI(url); + safeHost = uri.getHost(); // 민감한 파라미터를 제외한 도메인만 추출 + } catch (Exception ignored) { } + + // [STEP 2] 마스킹된 정보로 에러 로그 출력 + // 마지막에 e를 넣어줘야 어떤 통신 에러가 났는지 스택 트레이스가 남습니다. + log.error("[ML 분석 요청 발송 실패] User: {}, Host: {}", maskedUuid, safeHost, e); + + // [STEP 3] 원본 예외(e)를 포함해서 던지기 (상위 컨트롤러의 502 처리를 위해!) + // e를 인자로 넘겨야 'Root Cause'가 보존되어 나중에 추적이 가능합니다. + throw new RuntimeException("ML 서버 연동 실패", e); } } From e154f6ff96e9ad1a1c703eaaaa64d88b633bf811 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 01:11:01 +0900 Subject: [PATCH 072/136] =?UTF-8?q?fix-feat/kd-16:=20be3=EC=AA=BD=20redisc?= =?UTF-8?q?onfig=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/config/RedisConfig.java | 31 +++++++++++++++++++ .../veriq/veriqbe3/entity/ScanHistory.java | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/veriq/veriqbe3/config/RedisConfig.java diff --git a/src/main/java/com/veriq/veriqbe3/config/RedisConfig.java b/src/main/java/com/veriq/veriqbe3/config/RedisConfig.java new file mode 100644 index 0000000..a14f1b9 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/RedisConfig.java @@ -0,0 +1,31 @@ +package com.veriq.veriqbe3.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +@Configuration +public class RedisConfig { + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // [핵심] 빨간 줄이 뜨는 Jackson 시리얼라이저 대신 + // 최신 버전에서 권장하는 json 시리얼라이저 자동 할당 방식을 씁니다. + RedisSerializer jsonSerializer = RedisSerializer.json(); + + // Key는 문자열로, Value는 JSON으로! + template.setKeySerializer(RedisSerializer.string()); + template.setValueSerializer(jsonSerializer); + + template.setHashKeySerializer(RedisSerializer.string()); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } + } + + diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 3ad70a1..9eea2d8 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -23,7 +23,7 @@ public class ScanHistory { private Long id; private String guestUuid; - @Column(length = 1000) + @Column(length = 700) private String originalUrl; private String typeInfo; From c0d4d0f9377e74a335d2170dc84ad8e6b18255d6 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 02:27:12 +0900 Subject: [PATCH 073/136] =?UTF-8?q?fix-feat/kd-16:=20=EB=B9=84=20url=20be1?= =?UTF-8?q?,be3=20dto=20=EB=B6=88=EC=9D=BC=EC=B9=98=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java | 4 ++++ src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 99294a8..9870b54 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -2,11 +2,15 @@ import com.veriq.veriqbe3.domain.SchemeType; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder +@NoArgsConstructor +@AllArgsConstructor public class QrScanResponse { private String guestUuid; private SchemeType schemeType; diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index 4d3cc3a..f6541b2 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,9 +1,14 @@ package com.veriq.veriqgateway.dto; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.NoArgsConstructor; + @Getter @Builder +@NoArgsConstructor +@AllArgsConstructor public class ScanResponse { From be2cd10d7124da67959f0491c8211f3605614bff Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 10:02:37 +0900 Subject: [PATCH 074/136] =?UTF-8?q?fix-develop:=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=A0=84=EC=86=A1=EC=8B=9C=20url=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-be3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 1fa089d..57fedf9 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -34,7 +34,7 @@ ml: # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽음 secret: ${ML_SECRET_KEY} api : - url: "http://veriq-analysis:8000/api/v1/scan/upload" #재조정 + url: "http://veriq-analysis:8000/analyze" #재조정 # 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) From 4c7fc994c8211686e27fb3a0ab77336b64153618 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 10:32:48 +0900 Subject: [PATCH 075/136] =?UTF-8?q?fix-develop:=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=A0=84=EB=8B=AC=EC=8B=9C=20guestUuid=20body?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EC=86=A1x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/MlRequestService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index 0b7a1a5..471827a 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -33,6 +33,7 @@ public void sendToPythonServer(String guestUuid, String url) { // 2. 바디 세팅 (파이썬 서버가 요구하는 JSON 규격에 맞춤) 원본 url들어감 Map body = new HashMap<>(); body.put("url", url);//url:"https://naver.com" 이 json형태로 들어감 + body.put("guestUuid", guestUuid); // 3. 엔티티 조립 바디랑 헤더 조립 HttpEntity> requestEntity = new HttpEntity<>(body, headers); From 93ac92223eda10a5775fb27b9a762de0aeee5d32 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 11:24:02 +0900 Subject: [PATCH 076/136] =?UTF-8?q?fix-develop:docker-compse.yml=20env=20c?= =?UTF-8?q?allbackurl=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index f06ff4a..980fd67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,8 @@ services: environment: ML_SECRET_KEY: ${ML_SECRET_KEY} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + BE3_CALLBACK_FINAL_URL: "http://veriq-be3:8083/api/v1/scan/callback" + BE3_CALLBACK_PROGRESS_URL: "http://veriq-be3:8083/api/v1/scan/callback/progress" depends_on: From 602461738bd7a5e71c97184e231e66e3b0ffb7a1 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 12:04:29 +0900 Subject: [PATCH 077/136] =?UTF-8?q?fix-develop:=EC=8A=A4=EC=BA=94=EB=B0=98?= =?UTF-8?q?=EC=9D=91=20dto=20=EB=B3=80=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 9e195da..a0541f5 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -1,11 +1,12 @@ package com.veriq.veriqbe3.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.time.LocalDateTime; import java.util.List; - +@JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime analysisTime, String originalUrl, HttpsInfo https, From ddc3f9a9329b1500b9815968207991e8d30cde6b Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 12:43:38 +0900 Subject: [PATCH 078/136] =?UTF-8?q?fix-develop:processRequest=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java index 117c32e..c85c6cc 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java +++ b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java @@ -1,6 +1,8 @@ package com.veriq.veriqbe3.dto; import jakarta.validation.constraints.NotBlank; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +@JsonIgnoreProperties(ignoreUnknown = true) public record ProgressRequest( @NotBlank String guestUuid, // 식별자 (바디) String step, // UI 매핑용 키값 (예: "내부 db조회 ", "AI 위험 분석") From 2cd71e5451973f379e0a0e742a3637a31fb75a62 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 13:09:10 +0900 Subject: [PATCH 079/136] =?UTF-8?q?fix-develop:sse=20progress=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EA=B7=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/controller/QrScanController.java | 3 +++ src/main/java/com/veriq/veriqbe3/service/MlRequestService.java | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index fa96b07..8cf86b2 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -188,10 +188,12 @@ public ResponseEntity analysisProgressCallback( // 누구의 파이프인지 식별자 필수! @RequestHeader(value = "X-ML-Secret", required = false) String providedSecret, @Valid @RequestBody ProgressRequest request) { // { "step": "DOMAIN_CHECK", "message": "도메인 사칭 분석 완료" } + log.info("🔔 [Progress 요청 도착] UUID: {}, 받은 Secret: {}", request.guestUuid(), providedSecret); try { // 1. 보안 검증 (기존 로직과 동일하게 유지) if (providedSecret == null) { + log.warn("❌ [보안 실패] 파이썬이 Secret을 안 보냈습니다!"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("접근 권한이 없습니다."); } @@ -200,6 +202,7 @@ public ResponseEntity analysisProgressCallback( // 길이가 다르거나, 내부 바이트가 하나라도 다르면 거절 (비교 시간은 항상 일정함) if (providedBytes.length != expectedBytes.length || !java.security.MessageDigest.isEqual(providedBytes, expectedBytes)) { + log.warn("❌ [보안 실패] Secret 불일치! 기대값: [{}], 받은값: [{}]", mlServerSecret, providedSecret); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("접근 권한이 없습니다."); } // 2. 파이썬이 보내준 메시지 추출 diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index 471827a..953d9a4 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -34,7 +34,6 @@ public void sendToPythonServer(String guestUuid, String url) { Map body = new HashMap<>(); body.put("url", url);//url:"https://naver.com" 이 json형태로 들어감 body.put("guestUuid", guestUuid); - // 3. 엔티티 조립 바디랑 헤더 조립 HttpEntity> requestEntity = new HttpEntity<>(body, headers); From db31e301c91002a8da890486ac7f175d18ef1501 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 14:00:13 +0900 Subject: [PATCH 080/136] =?UTF-8?q?fix-develop:=EC=8B=9C=ED=81=AC=EB=A6=BF?= =?UTF-8?q?=ED=82=A4=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/controller/QrScanController.java | 2 +- src/main/java/com/veriq/veriqbe3/service/MlRequestService.java | 2 +- src/main/resources/application-be3.yml | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 8cf86b2..4a737f5 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -99,7 +99,7 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { // ML 서버가 분석을 마치고 분석최종결과를 던져주는 콜백 API - @Value("${ml.server.secret}") + @Value("${ML_SECRET_KEY}") private String mlServerSecret; @PostMapping("/callback") public ResponseEntity analysisCallback( diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index 953d9a4..5b41ebc 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -17,7 +17,7 @@ public class MlRequestService { @Value("${ml.api.url}") // application.yml에 정의된 파이썬 서버 URL (예: http://.../analyze) private String mlServerUrl; - @Value("${ml.server.secret}") // 파이썬 서버랑 맞춘 비밀키 + @Value("${ML_SECRET_KEY}")// 파이썬 서버랑 맞춘 비밀키 private String mlServerSecret; private final RestTemplate restTemplate; diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 57fedf9..2237291 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -30,9 +30,6 @@ spring: port: 6379 ml: - server: - # 도커가 넘겨준 ML_SECRET_KEY 환경변수를 읽음 - secret: ${ML_SECRET_KEY} api : url: "http://veriq-analysis:8000/analyze" #재조정 From 17bb2fa5c66efab9151acf2599a94c6472da71af Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 17 Apr 2026 15:12:04 +0900 Subject: [PATCH 081/136] =?UTF-8?q?fix-develop:=ED=95=A8=EC=88=98=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GlobalExceptionHandler.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/com/veriq/veriqbe3/controller/GlobalExceptionHandler.java diff --git a/src/main/java/com/veriq/veriqbe3/controller/GlobalExceptionHandler.java b/src/main/java/com/veriq/veriqbe3/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..9724c06 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/controller/GlobalExceptionHandler.java @@ -0,0 +1,31 @@ +package com.veriq.veriqbe3.controller; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + // 🚨 1. JSON 형태가 깨졌거나, 날짜/타입 포맷이 안 맞을 때 (파싱 에러) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException e) { + log.error("=================================================="); + log.error("🔥 [JSON 파싱 에러] 파이썬이 보낸 데이터를 자바 DTO로 바꿀 수 없습니다!"); + log.error("범인(상세 이유): {}", e.getMessage()); // 👈 여기서 어떤 필드가 문제인지 알려줍니다! + log.error("=================================================="); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("JSON 형식 오류"); + } + + // 🚨 2. @Valid 검증 실패 (예: @NotBlank 필드인 guestUuid가 안 넘어왔을 때) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + log.error("=================================================="); + log.error("🔥 [DTO 검증 에러] 필수 값이 누락되었거나 조건에 맞지 않습니다!"); + log.error("범인(상세 이유): {}", e.getBindingResult().getAllErrors().get(0).getDefaultMessage()); + log.error("=================================================="); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("필수 값 누락"); + } +} From 5c96f9caeab3411106471a3f66f32985fef0ea50 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 18 Apr 2026 15:10:43 +0900 Subject: [PATCH 082/136] =?UTF-8?q?fix-develop:=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/controller/QrScanController.java | 5 +++-- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 6 +++--- src/main/resources/application-be3.yml | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 4a737f5..c08585d 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -36,6 +36,8 @@ public class QrScanController { private final QrScanRedisService qrScanRedisService; // 서비스 의존성 주입 private final ObjectMapper objectMapper; private final RedisTemplate redisTemplate; + @Value("${ML_SECRET_KEY}") + private String mlServerSecret; @@ -99,8 +101,7 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { // ML 서버가 분석을 마치고 분석최종결과를 던져주는 콜백 API - @Value("${ML_SECRET_KEY}") - private String mlServerSecret; + @PostMapping("/callback") public ResponseEntity analysisCallback( @RequestHeader(value = "guest_uuid", required = false) String guestUuid, diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index a0541f5..cb4daf0 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -6,7 +6,7 @@ import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS]") LocalDateTime analysisTime, String originalUrl, HttpsInfo https, @@ -58,9 +58,9 @@ public record ServerInfo( public record CertificateInfo( boolean valid, String issuer, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS][X]") LocalDateTime validFrom, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS][X]") LocalDateTime validTo ) {} } \ No newline at end of file diff --git a/src/main/resources/application-be3.yml b/src/main/resources/application-be3.yml index 2237291..806d866 100644 --- a/src/main/resources/application-be3.yml +++ b/src/main/resources/application-be3.yml @@ -34,6 +34,7 @@ ml: url: "http://veriq-analysis:8000/analyze" #재조정 + # 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) frontend: url: ${FRONTEND_CORS_URL:http://localhost:3000} \ No newline at end of file From 924af97e22e56979c2d656deb28fec558b93a3a0 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 18 Apr 2026 17:56:06 +0900 Subject: [PATCH 083/136] =?UTF-8?q?fix-develop:=EC=B5=9C=EC=A2=85-sse?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EA=BA=A0=EC=A7=90=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=EC=9B=A8=EA=B1=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../veriq/veriqbe3/config/SwaggerConfig.java | 17 +++++++++++++++++ .../veriqbe3/controller/QrScanController.java | 8 +++++++- .../veriqgateway/config/SwaggerConfig.java | 17 +++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java create mode 100644 src/main/java/com/veriq/veriqgateway/config/SwaggerConfig.java diff --git a/build.gradle b/build.gradle index 94f0f9e..9d5cb36 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } tasks.named('test') { diff --git a/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java new file mode 100644 index 0000000..bab7b26 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java @@ -0,0 +1,17 @@ +package com.veriq.veriqbe3.config; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI veriQOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Veri-Q API Server") + .description("스캔 리스트 ,상세보고서 제공,sse통로 구축 관련 api 명세서입니다.") + .version("v1.0.0")); + } +} diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index c08585d..71745b7 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -109,6 +109,12 @@ public ResponseEntity analysisCallback( @RequestBody AnalysisResponse resultDto) { try { + // 🚨 1. 가장 먼저 UUID부터 검사합니다! (입구 컷) + if (guestUuid == null || guestUuid.isBlank()) { + log.error("❌ [콜백 실패] 파이썬이 헤더에 guest_uuid를 안 보냈습니다! URL: {}", resultDto.originalUrl()); + // UUID가 없으면 DB 저장도, SSE 통신도 할 필요 없이 바로 반송! + return ResponseEntity.badRequest().body("guest_uuid is missing"); + } // [보안 로직] 상수 시간 비교(Constant-time comparison)를 통한 타이밍 공격 방어 boolean isSecretValid = false; if (providedSecret != null && mlServerSecret != null) { @@ -227,7 +233,7 @@ public ResponseEntity analysisProgressCallback( // 프론트엔드가 파이프를 꽂으러 오는 곳 @CrossOrigin(origins = "${frontend.url}")//프론트의 주소에 따라 유동, 해당 주소만 파이프 받아들임 - @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @GetMapping(value = "/subscribe", produces = "text/event-stream;charset=UTF-8") public ResponseEntity subscribe(@RequestParam("guest_uuid") String guestUuid) { try { diff --git a/src/main/java/com/veriq/veriqgateway/config/SwaggerConfig.java b/src/main/java/com/veriq/veriqgateway/config/SwaggerConfig.java new file mode 100644 index 0000000..6ee43aa --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/config/SwaggerConfig.java @@ -0,0 +1,17 @@ +package com.veriq.veriqgateway.config; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI gatewayOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Veri-Q Gateway (BE1) API") + .description("qr 이미지 서버에 전송하는 api 명세서입니다") + .version("v1.0.0")); + } +} From ac8fcf00a57bf792d20394a9c586df227c9a7fa9 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 18 Apr 2026 19:08:13 +0900 Subject: [PATCH 084/136] =?UTF-8?q?fix-develop:=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B9=8C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9d5cb36..aafbe6a 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,7 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' } tasks.named('test') { From e7c8837f53a053476bc21f206ac175302089ed39 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 21 Apr 2026 00:00:43 +0900 Subject: [PATCH 085/136] =?UTF-8?q?fix-develop:=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20object=20=EB=AF=B8=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 71745b7..6e038f5 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -73,18 +73,18 @@ public ResponseEntity uploadQrImage( //반환 http body 타 } // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 @GetMapping("/detail") - public ResponseEntity getUrlDetail(@RequestParam("url") String url) { + public ResponseEntity getUrlDetail(@RequestParam("url") String url) { try { log.info("상세 분석 결과 요청 들어옴 - URL: {}", url); - // Look-Aside 캐시 로직 호출 + // Look-Aside 캐시 로직 호출 AnalysisResponse response = qrScanRedisService.getUrlDetail(url); if (response == null) { - // 캐시에도 없고 DB에도 없는 경우 (분석 중이거나 아예 없는 URL) + // 캐시에도 없고 DB에도 없는 경우 log.warn("URL 분석 결과를 찾을 수 없습니다: {}", url); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("해당 URL의 분석 결과를 찾을 수 없습니다."); + // ❌ 기존: .body("문자열") -> ✅ 수정: .build()로 본문 없이 404 상태코드만 반환 + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } // 성공 시 200 OK와 함께 데이터 쫙 뿌려주기 @@ -92,8 +92,8 @@ public ResponseEntity getUrlDetail(@RequestParam("url") String url) { } catch (Exception e) { log.error("상세 조회 중 서버 에러 발생 - URL: {}", url, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("서버 내부 오류가 발생했습니다."); + // ❌ 기존: .body("문자열") -> ✅ 수정: .build()로 본문 없이 500 상태코드만 반환 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } From 2bce1dad3ac3cad1f703dbd03512776b4fd9c1e7 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 24 Apr 2026 00:51:15 +0900 Subject: [PATCH 086/136] =?UTF-8?q?feat/kd-18:=20db=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?url=20risklevel=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 39 ++++--- .../veriq/veriqbe3/entity/ScanHistory.java | 9 +- .../veriqbe3/service/QrScanRedisService.java | 101 ++++++++++++------ 3 files changed, 97 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 6e038f5..26940ef 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -43,33 +43,38 @@ public class QrScanController { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //multipart_form_data 제한 - public ResponseEntity uploadQrImage( //반환 http body 타입 제한 - @RequestHeader(value = "guest_uuid" ) String guestUuid, - @RequestParam("image") MultipartFile image) { + // 🚨 1. 반환 타입을 에서 로 변경합니다! + public ResponseEntity uploadQrImage( + @RequestHeader(value = "guest_uuid" ) String guestUuid, + @RequestParam("image") MultipartFile image) { try { - QrScanResponse response = qrScanRedisService.processWithRedis(image, guestUuid); - return ResponseEntity.ok(response); - + // 🚨 2. 서비스가 주는 결과물을 Object로 받습니다! + Object response = qrScanRedisService.processWithRedis(image, guestUuid); + + // 🚨 3. 결과물이 어떤 타입인지(완성본인지, 로딩표인지)에 따라 응답을 다르게 줍니다! + if (response instanceof AnalysisResponse) { + // [Fast-Path] 캐시/DB에 있던 신선한 상세 데이터인 경우 (200 OK) + log.info("✅ 신선한 데이터 적중! 분석 결과를 즉시 반환합니다."); + return ResponseEntity.ok(response); + } else { + // [ML-Trigger] 파이썬 분석이 시작되어 진행 상태(QrScanResponse)를 받은 경우 (202 Accepted) + log.info("🔄 파이썬 서버로 분석을 지시했습니다. 프론트엔드에 대기 신호를 보냅니다."); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } - // 프런트가 이상한 데이터를 보냈을 때 (사용자 잘못 -> 400) - } catch (IllegalArgumentException e) { + // --- 아래 예외 처리는 상민님 코드 그대로 유지 --- + } catch (IllegalArgumentException e) { // 사용자 잘못 -> 400 log.warn("잘못된 QR 업로드 요청 - guestUuid: {}", guestUuid, e); return ResponseEntity.badRequest().build(); -// 파이썬 서버가 죽었거나, DB가 터졌을 때 (분석 서버 잘못 -> 502) - } catch (org.springframework.web.client.RestClientException e) { + } catch (org.springframework.web.client.RestClientException e) { // 파이썬 통신 에러 -> 502 log.error("QR 스캔 요청 처리 중 서버(다운스트림) 에러 발생 - guestUuid: {}", guestUuid, e); - // 파이썬 서버 통신 실패 시 (502 반환) return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); - } - /// 스프링 서버 내부에서 뭔가 터졌을 때 (스프링 서버잘못 -> 500) - catch (Exception e) { + + } catch (Exception e) { // 스프링 서버 내부 에러 -> 500 log.error("QR 스캔 요청 처리 중 서버 내부 에러 발생 - guestUuid: {}", guestUuid, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } - - - } // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 @GetMapping("/detail") diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 9eea2d8..21a198e 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -12,7 +12,8 @@ // 추가 스캔 보고서 제공시 url을 매게로 하기떄문에 idndexing에 추가, // URL과 스캔 시간을 분리해서 인덱스를 걸어줍니다. @Index(name = "idx_original_url", columnList = "originalUrl"), - @Index(name = "idx_scanned_at", columnList = "scannedAt") + @Index(name = "idx_scanned_at", columnList = "scannedAt"), + @Index(name = "idx_last_analyzed_at", columnList = "lastAnalyzedAt") }) @Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -30,15 +31,17 @@ public class ScanHistory { @Enumerated(EnumType.STRING) private SchemeType schemeType; - +//유재 스캔한 시간 private LocalDateTime scannedAt; @PrePersist public void prePersist() { this.scannedAt = LocalDateTime.now(); } - +//ML서버가 분석하는데 걸린 시간 private LocalDateTime analysisTime; + // 실제 ML 분석이 완료된 시각 (데이터 신선도 체크의 기준) + private LocalDateTime lastAnalyzedAt; private int totalScore; private String riskLevel; diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index bb7ae8b..b277070 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -18,6 +18,7 @@ import java.io.IOException; +import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -42,9 +43,9 @@ public class QrScanRedisService { private static final int MAX_HISTORY_SIZE = 50; private static final Duration HISTORY_TTL = Duration.ofDays(7); - private static final Duration URL_CACHE_TTL = HISTORY_TTL; - public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) throws Exception { + + public Object processWithRedis(MultipartFile image, String guestUuid) throws Exception { String url = qrDecoder.decode(image); // 2. 디코딩 성공 시, 프론트로 실시간 텍스트만 쏘기! @@ -78,41 +79,41 @@ public QrScanResponse processWithRedis(MultipartFile image, String guestUuid) th } // 3. [URL] 처리 로직 -// 3-1. 캐시(Redis) 확인 로직 - AnalysisResponse cachedResult = getUrlDetail(result.typeInfo()); - if (cachedResult != null) { - log.info("[Fast-Path] 캐시랑 db에 존재 - URL: {}", result.typeInfo()); - return QrScanResponse.builder() - .guestUuid(guestUuid) - .typeInfo(result.typeInfo()) - .status("COMPLETED") - .isUrl(true) // - .message(decodingMsg) // - .build(); + // 4-1. 캐시 및 DB에서 '신선한' 데이터 가져오기 시도 + AnalysisResponse freshResult = getUrlDetail(result.typeInfo()); + + if (freshResult != null) { + // 🚨 [핵심 A] 신선한 데이터가 있으면 파이썬 분석 스킵! AnalysisResponse 객체를 통째로 리턴! + log.info("[Fast-Path] 신선한 캐시/DB 데이터 적중! 파이썬 분석 생략 - URL: {}", result.typeInfo()); + return freshResult; } - // 5-2. 캐시에 없으면 기존대로 분석 요청 + // 4-2. 데이터가 아예 없거나 너무 낡았다면? (null이 반환된 경우) try { + log.info("데이터가 없거나 오래되었습니다. 파이썬 ML 서버에 분석을 지시합니다. URL: {}", result.typeInfo()); + + // 여기서 파이썬 서버로 분석 지시 핑(Ping)을 날립니다. mlRequestService.sendToPythonServer(guestUuid, result.typeInfo()); - log.info("파이썬 분석 요청 완료."); // (이전에 피드백받은 로그 마스킹은 MlRequestService 쪽에 잘 하셨죠? ㅎㅎ) + // 🚨 [핵심 B] 파이썬이 일하러 갔으니, 프론트에게는 QrScanResponse(로딩 상태)를 리턴! return QrScanResponse.builder() .guestUuid(guestUuid) - .status("PROCESSING") + .typeInfo(result.typeInfo()) + .status("PROCESSING") // 프론트는 이걸 보고 SSE 대기 화면을 띄웁니다. + .isUrl(true) + .message("분석이 시작되었습니다.") .build(); - } //파이썬 통신 에러(502) - catch (org.springframework.web.client.RestClientException e) { + + } catch (org.springframework.web.client.RestClientException e) { log.error("ML 서버 통신 실패 (RestClientException) - guestUuid: {}", guestUuid, e); - throw e; // 이렇게 던져야 컨트롤러가 잡아서 502 Bad Gateway를 내려줍니다. + throw e; // 컨트롤러가 잡아서 502 Bad Gateway로 처리 -// 나머지 진짜 알 수 없는 런타임 에러들만 감싸서 던집니다.(500에러-서버에러) } catch (Exception e) { log.error("분석 요청 중 알 수 없는 에러", e); throw new RuntimeException("ML 서버 통신 실패", e); } - } // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API public AnalysisResponse getUrlDetail(String url) throws Exception { @@ -137,26 +138,46 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { if (dbHistory.isEmpty()) { return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL } + // 🚨 여기서 history 변수를 명확하게 뽑아냅니다! (에러 해결 핵심) + ScanHistory history = dbHistory.get(); - AnalysisResponse dbResponse = convertToAnalysisResponse(dbHistory.get()); - // 다시 요청될 수 있으니 Redis에 캐싱 - redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(dbResponse), URL_CACHE_TTL); - log.info("DB 조회 후 Redis 캐싱 및 반환: {}", url); + // 3. 신선도 체크 + if (isStale(history)) { + return null; // 상했으면 null (processWithRedis에서 캐치해서 파이썬 호출함) + } + // 4. 신선하면 DTO로 변환 후 캐싱 및 반환 + AnalysisResponse dbResponse = convertToAnalysisResponse(history); + Duration dynamicTtl = calculateDynamicTtl(history.getRiskLevel()); + redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(dbResponse), dynamicTtl); + log.info("DB 조회 결과가 신선하여 Redis 캐싱 후 반환 (TTL: {}일) : {}", dynamicTtl.toDays(), url); return dbResponse; } + /** + * 데이터가 유효 기간을 넘었는지 확인 + */ + private boolean isStale(ScanHistory history) { + if (history.getLastAnalyzedAt() == null) return true; + + long daysPassed = java.time.Duration.between(history.getLastAnalyzedAt(), java.time.LocalDateTime.now()).toDays(); + // SAFE는 3일, 나머지는 7일을 임계치로 설정 + long threshold = "SAFE".equals(history.getRiskLevel()) ? 3 : 7; + + return daysPassed >= threshold; + } /** */ - public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { + public void cacheAnalysisResult(String url, AnalysisResponse resultDto,Duration ttl) { try { // 1. 상세 리포트 캐싱 (AnalysisResponse 객체를 통째로 JSON으로 변환하여 저장) String urlKey = buildUrlCacheKey(url); String jsonCache = objectMapper.writeValueAsString(resultDto); - redisTemplate.opsForValue().set(urlKey, jsonCache, URL_CACHE_TTL); + // 🚨 핵심: 고정된 URL_CACHE_TTL 대신, 밖에서 받아온 동적 ttl을 꽂아줍니다! + redisTemplate.opsForValue().set(urlKey, jsonCache, ttl); - log.info("URL 상세 분석 결과 캐싱 완료: {}", url); + log.info("URL 상세 분석 결과 캐싱 완료: {} (적용된 TTL: {}일)", url, ttl.toDays()); } catch (Exception e) { log.error("Redis 캐싱 실패 - URL: {}", url, e); @@ -249,6 +270,18 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { entity.getRiskLevel() ); } + /** + * [추가된 핵심 로직] 위험도에 따른 차등 TTL 계산 메서드 + */ + private Duration calculateDynamicTtl(String riskLevel) { + if (riskLevel == null) return Duration.ofDays(1); // 안전장치 (기본값) + + return switch (riskLevel.toUpperCase()) { + case "SAFE" -> Duration.ofDays(3); // 안전하면 3일마다 재검증 + case "MALICIOUS", "CAUTION" -> Duration.ofDays(7); // 위험/의심은 7일간 길게 유지 + default -> Duration.ofDays(1); // 기타 상태는 하루 + }; + } @Transactional // DB 저장하다 에러 나면 롤백!, @@ -268,6 +301,7 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .typeInfo(url) // 보통 원본 URL을 넣습니다 .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 .analysisTime(responseDto.analysisTime()) + .lastAnalyzedAt(LocalDateTime.now()) // 🔥 현재 시간 기록 .totalScore(responseDto.score()) .riskLevel(responseDto.riskLevel()) @@ -331,12 +365,15 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues log.info("DB 저장 완료: {}", url); // ========================================== - // 3. Redis 캐시 워밍 + // 3. 차등 TTL 적용하여 Redis 캐시 워밍 (수정된 부분!) // ========================================== - cacheAnalysisResult(url, responseDto); + Duration dynamicTtl = calculateDynamicTtl(responseDto.riskLevel()); + cacheAnalysisResult(url, responseDto, dynamicTtl); + + log.info("모든 저장 및 동적 캐싱 프로세스 완료"); + // 프론트엔드로 프로세스 완료 알림 진행... + - log.info("DB 저장 완료"); - // 프론트엔드로 프로세스 완료 알림 } catch (Exception e) { From 8be53a77125cb3afbe97b54bb05ba83bcd2e51b8 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 24 Apr 2026 11:40:16 +0900 Subject: [PATCH 087/136] =?UTF-8?q?feat/kd-18:=20db=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?url=20risklevel=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index cb4daf0..304d3a9 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -16,7 +16,7 @@ public record AnalysisResponse( InternalDbInfo internalDb, RedirectInfo redirect, ServerInfo serverInfo, - int score, + Integer score, String riskLevel ) { public record HttpsInfo( @@ -29,7 +29,7 @@ public record ShortUrlInfo( public record MlInfo( List threats, - int score + Integer score ) {} public record ExternalApiInfo( @@ -46,7 +46,7 @@ public record InternalDbInfo( public record RedirectInfo( String finalUrl, - int redirectCount + Integer redirectCount ) {} public record ServerInfo( From cc5af6bc63c9de08545feb42a1a1395813a0e7d6 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 24 Apr 2026 12:03:19 +0900 Subject: [PATCH 088/136] =?UTF-8?q?feat/kd-18:=20db=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?url=20risklevel=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=841?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 304d3a9..20bdc4f 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -58,9 +58,12 @@ public record ServerInfo( public record CertificateInfo( boolean valid, String issuer, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS][X]") + // 🚨 [여기가 핵심 수정 포인트!] + // 파이썬의 "Mar 10 00:00:00 2026 GMT" 형식을 읽기 위한 포맷입니다. + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en") LocalDateTime validFrom, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS][X]") + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en") LocalDateTime validTo ) {} } \ No newline at end of file From 0dbd3b56f8e55d001d994b293af0498ad4bb5ce2 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 24 Apr 2026 12:20:26 +0900 Subject: [PATCH 089/136] =?UTF-8?q?feat/kd-18:=20db=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?url=20risklevel=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=842?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/service/QrScanRedisService.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index bb7ae8b..e5e22d6 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -268,7 +268,7 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .typeInfo(url) // 보통 원본 URL을 넣습니다 .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 .analysisTime(responseDto.analysisTime()) - .totalScore(responseDto.score()) + .totalScore(responseDto.score() != null ? responseDto.score() : 0) .riskLevel(responseDto.riskLevel()) // 1. HttpsInfo 빌드 @@ -285,7 +285,8 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .ml(ScanHistory.MlInfo.builder() .threats(responseDto.ml() != null && responseDto.ml().threats() != null ? String.join(", ", responseDto.ml().threats()) : null) - .mlScore(responseDto.ml() != null ? responseDto.ml().score() : 0) + // 🚨 2. mlScore 방어 (null이 아닐 때만 값을 넣고, null이면 0) + .mlScore(responseDto.ml() != null && responseDto.ml().score() != null ? responseDto.ml().score() : 0) .build()) // 4. ExternalApiInfo 빌드 @@ -306,7 +307,8 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // 6. RedirectInfo 빌드 .redirect(ScanHistory.RedirectInfo.builder() .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) - .redirectCount(responseDto.redirect() != null ? responseDto.redirect().redirectCount() : 0) + // 🚨 3. redirectCount 방어 (null이 아닐 때만 값을 넣고, null이면 0) + .redirectCount(responseDto.redirect() != null && responseDto.redirect().redirectCount() != null ? responseDto.redirect().redirectCount() : 0) .build()) // 7. ServerInfo 및 CertificateInfo From 727ff145390190a2480b16aca445e50d17af7233 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 24 Apr 2026 12:33:55 +0900 Subject: [PATCH 090/136] =?UTF-8?q?feat/kd-18:=20db=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?url=20risklevel=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=843?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 20bdc4f..3d3135b 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -60,10 +60,10 @@ public record CertificateInfo( String issuer, // 🚨 [여기가 핵심 수정 포인트!] // 파이썬의 "Mar 10 00:00:00 2026 GMT" 형식을 읽기 위한 포맷입니다. - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en",timezone = "GMT") LocalDateTime validFrom, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en",timezone = "GMT") LocalDateTime validTo ) {} } \ No newline at end of file From 21e7634c0ac792f13ccfc127fcc3f3d41f5f5f36 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 14:46:16 +0900 Subject: [PATCH 091/136] =?UTF-8?q?kd-18=20=EC=9E=91=EC=97=85=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=9E=84=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/entity/ScanHistory.java | 6 +- .../veriqbe3/service/QrScanRedisService.java | 95 +++++++++++++++++-- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 21a198e..21058c8 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -13,7 +13,7 @@ // URL과 스캔 시간을 분리해서 인덱스를 걸어줍니다. @Index(name = "idx_original_url", columnList = "originalUrl"), @Index(name = "idx_scanned_at", columnList = "scannedAt"), - @Index(name = "idx_last_analyzed_at", columnList = "lastAnalyzedAt") + @Index(name = "idx_last_analyzed_at", columnList = "analysisTime") }) @Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -38,10 +38,8 @@ public class ScanHistory { public void prePersist() { this.scannedAt = LocalDateTime.now(); } -//ML서버가 분석하는데 걸린 시간 +//ML서버가 분석한 시간 private LocalDateTime analysisTime; - // 실제 ML 분석이 완료된 시각 (데이터 신선도 체크의 기준) - private LocalDateTime lastAnalyzedAt; private int totalScore; private String riskLevel; diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index b277070..fbd71b5 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -114,6 +114,90 @@ public Object processWithRedis(MultipartFile image, String guestUuid) throws Exc } + } + /** + * [Fast-Path 전용] 캐시/DB 적중 시, 분석 없이 '방문 기록'만 새로 남기는 메서드 + */ + public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { + try { + String url = responseDto.originalUrl(); + String safeGuestUuid = (guestUuid != null && !guestUuid.isBlank()) ? guestUuid : "ANONYMOUS"; + + // 기존 분석 결과(responseDto)를 복사하되, '새로운 엔티티'를 만듭니다. + ScanHistory hitHistory = ScanHistory.builder() + .guestUuid(safeGuestUuid) + .originalUrl(url) + .typeInfo(url) + .schemeType(SchemeType.WEB) + + // 🚨 핵심 1: ML 분석 시간은 기존 캐시에 있던 '과거 시간'을 그대로 유지! + .analysisTime(responseDto.analysisTime()) + + // 🚨 핵심 2: null 방어 로직 (이전에 수정한 것과 동일하게 적용) + .totalScore(responseDto.score() != null ? responseDto.score() : 0) + .riskLevel(responseDto.riskLevel()) + + // 1. HttpsInfo + .https(ScanHistory.HttpsInfo.builder() + .isSecure(responseDto.https() != null && responseDto.https().isSecure()) + .build()) + + // 2. ShortUrlInfo + .shortUrl(ScanHistory.ShortUrlInfo.builder() + .isShortened(responseDto.shortUrl() != null && responseDto.shortUrl().isShortened()) + .build()) + + // 3. MlInfo (null 방어) + .ml(ScanHistory.MlInfo.builder() + .threats(responseDto.ml() != null && responseDto.ml().threats() != null + ? String.join(", ", responseDto.ml().threats()) : null) + .mlScore(responseDto.ml() != null && responseDto.ml().score() != null ? responseDto.ml().score() : 0) + .build()) + + // 4. ExternalApiInfo + .externalApi(ScanHistory.ExternalApiInfo.builder() + .apiChecked(responseDto.externalApi() != null && responseDto.externalApi().checked()) + .apiProvider(responseDto.externalApi() != null ? responseDto.externalApi().provider() : null) + .apiResult(responseDto.externalApi() != null ? responseDto.externalApi().result() : null) + .build()) + + // 5. InternalDbInfo + .internalDb(ScanHistory.InternalDbInfo.builder() + .dbExists(responseDto.internalDb() != null && responseDto.internalDb().exists()) + .dbReportCount(responseDto.internalDb() != null && responseDto.internalDb().reportCount() != null ? responseDto.internalDb().reportCount() : 0) + .dbBlockCount(responseDto.internalDb() != null && responseDto.internalDb().blockCount() != null ? responseDto.internalDb().blockCount() : 0) + .build()) + + // 6. RedirectInfo (null 방어) + .redirect(ScanHistory.RedirectInfo.builder() + .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) + .redirectCount(responseDto.redirect() != null && responseDto.redirect().redirectCount() != null ? responseDto.redirect().redirectCount() : 0) + .build()) + + // 7. ServerInfo 및 CertificateInfo + .serverInfo(ScanHistory.ServerInfo.builder() + .serverType(responseDto.serverInfo() != null ? responseDto.serverInfo().type() : null) + .serverLocation(responseDto.serverInfo() != null ? responseDto.serverInfo().location() : null) + .certificate(responseDto.serverInfo() != null && responseDto.serverInfo().certificate() != null + ? ScanHistory.CertificateInfo.builder() + .certValid(responseDto.serverInfo().certificate().valid()) + .certIssuer(responseDto.serverInfo().certificate().issuer()) + .certValidFrom(responseDto.serverInfo().certificate().validFrom()) + .certValidTo(responseDto.serverInfo().certificate().validTo()) + .build() + : null) + .build()) + .build(); + + // 🚨 핵심 3: 새로운 엔티티로 DB에 Insert! + // 여기서 @PrePersist가 발동해서 hitHistory.scannedAt 에 '현재 스캔 시간'이 자동으로 들어갑니다! + scanHistoryRepository.save(hitHistory); + + log.info("캐시 적중 - 새로운 스캔 히스토리(방문 기록) DB 저장 완료: {}", url); + + } catch (Exception e) { + log.error("캐시 적중 히스토리 저장 중 에러 발생", e); + } } // 스캔 내역 URL 클릭 시 상세 보고서 내려주는 API public AnalysisResponse getUrlDetail(String url) throws Exception { @@ -158,9 +242,9 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { * 데이터가 유효 기간을 넘었는지 확인 */ private boolean isStale(ScanHistory history) { - if (history.getLastAnalyzedAt() == null) return true; + if (history.getAnalysisTime() == null) return true; - long daysPassed = java.time.Duration.between(history.getLastAnalyzedAt(), java.time.LocalDateTime.now()).toDays(); + long daysPassed = java.time.Duration.between(history.getAnalysisTime(), java.time.LocalDateTime.now()).toDays(); // SAFE는 3일, 나머지는 7일을 임계치로 설정 long threshold = "SAFE".equals(history.getRiskLevel()) ? 3 : 7; @@ -274,11 +358,11 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { * [추가된 핵심 로직] 위험도에 따른 차등 TTL 계산 메서드 */ private Duration calculateDynamicTtl(String riskLevel) { - if (riskLevel == null) return Duration.ofDays(1); // 안전장치 (기본값) + if (riskLevel == null) return Duration.ofDays(2); // 안전장치 (기본값) return switch (riskLevel.toUpperCase()) { - case "SAFE" -> Duration.ofDays(3); // 안전하면 3일마다 재검증 - case "MALICIOUS", "CAUTION" -> Duration.ofDays(7); // 위험/의심은 7일간 길게 유지 + case "SAFE" -> Duration.ofDays(1); // 안전하면 3일마다 재검증 + case "SUSPICIOUS", " DANGER" -> Duration.ofDays(3); // 위험/의심은 7일간 길게 유지 default -> Duration.ofDays(1); // 기타 상태는 하루 }; } @@ -301,7 +385,6 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .typeInfo(url) // 보통 원본 URL을 넣습니다 .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 .analysisTime(responseDto.analysisTime()) - .lastAnalyzedAt(LocalDateTime.now()) // 🔥 현재 시간 기록 .totalScore(responseDto.score()) .riskLevel(responseDto.riskLevel()) From e4cc6af60a8aa7a70ceafc3e4a7ed9d356956b2d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 16:06:18 +0900 Subject: [PATCH 092/136] =?UTF-8?q?fix-feat/kd-18=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 3 - .../com/veriq/veriqbe3/domain/RiskLevel.java | 9 +- .../veriq/veriqbe3/dto/AnalysisResponse.java | 4 +- .../veriq/veriqbe3/service/ProcessQrScan.java | 127 ------------------ .../veriqbe3/service/QrScanRedisService.java | 38 +++--- .../dto/GoogleCaptchaResponse.java | 3 + 6 files changed, 34 insertions(+), 150 deletions(-) delete mode 100644 src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 26940ef..3a41309 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -2,9 +2,6 @@ import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.ProgressRequest; -import com.veriq.veriqbe3.dto.QrScanResponse; -import com.veriq.veriqbe3.service.MlRequestService; -import com.veriq.veriqbe3.service.ProcessQrScan; import com.veriq.veriqbe3.service.QrScanRedisService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java b/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java index 4481a7b..8958912 100644 --- a/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java +++ b/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java @@ -21,11 +21,14 @@ public String getValue() { @JsonCreator // JSON의 문자열을 Enum으로 매핑할 때 사용함 public static RiskLevel from(String value) { + if (value == null || value.isBlank()) return RiskLevel.SUSPICIOUS; // null 방어 + for (RiskLevel level : RiskLevel.values()) { - if (level.value.equalsIgnoreCase(value)) { + if (level.value.equalsIgnoreCase(value) || level.name().equalsIgnoreCase(value)) { return level; } } - return null; // 혹은 기본값 설정 + // 매칭되는 게 없을 때 null 대신 의심(SUSPICIOUS) 상태로 기본값 설정 + return RiskLevel.SUSPICIOUS; } -} \ No newline at end of file +} diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 3d3135b..7fcd27d 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.veriq.veriqbe3.domain.RiskLevel; + import java.time.LocalDateTime; import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) @@ -17,7 +19,7 @@ public record AnalysisResponse( RedirectInfo redirect, ServerInfo serverInfo, Integer score, - String riskLevel + RiskLevel riskLevel ) { public record HttpsInfo( boolean isSecure diff --git a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java b/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java deleted file mode 100644 index 58b3ad3..0000000 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.veriq.veriqbe3.service; - -import com.veriq.veriqbe3.domain.SchemeType; -import com.veriq.veriqbe3.dto.AnalysisResponse; -import com.veriq.veriqbe3.dto.QrScanResponse; -import com.veriq.veriqbe3.entity.ScanHistory; -import com.veriq.veriqbe3.repository.ScanHistoryRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.time.LocalDateTime; - -@Service -@RequiredArgsConstructor -public class ProcessQrScan { - - private final QrDecoder qrDecoder; - private final SchemeClassifier schemeClassifier; - private final ScanHistoryRepository scanHistoryRepository; - - @Transactional // DB 저장 중 에러 발생 시 롤백하기 위함 - public QrScanResponse process(MultipartFile image, String guestUuid) throws Exception { - - String url = qrDecoder.decode(image); //이미지 디코드 - - SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); //scheme 분류 - - String status = "COMPLETED"; - - if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { - - // BE2 response 테스트값 - AnalysisResponse response=new AnalysisResponse( - LocalDateTime.now(), // analysisTime - url, // originalUrl - new AnalysisResponse.HttpsInfo(true), - new AnalysisResponse.ShortUrlInfo(false), - new AnalysisResponse.MlInfo(java.util.List.of("safe"), 10), - new AnalysisResponse.ExternalApiInfo(true, "Google", "Clean"), - new AnalysisResponse.InternalDbInfo(false, 0, 0), - new AnalysisResponse.RedirectInfo(url, 0), - new AnalysisResponse.ServerInfo("Nginx", "KR", - new AnalysisResponse.CertificateInfo(true, "DigiCert", LocalDateTime.now(), LocalDateTime.now().plusYears(1))), - 95, // score - "SAFE" // riskLevel - ); - - ScanHistory scanHistory = ScanHistory.builder() - .guestUuid(guestUuid) - .originalUrl(url) - .typeInfo(result.typeInfo()) - .schemeType(result.type()) - .analysisTime(response.analysisTime()) - .totalScore(response.score()) - .riskLevel(response.riskLevel()) - - // 1. HttpsInfo 빌드 - .https(ScanHistory.HttpsInfo.builder() - .isSecure(response.https() != null && response.https().isSecure()) - .build()) - - // 2. ShortUrlInfo 빌드 - .shortUrl(ScanHistory.ShortUrlInfo.builder() - .isShortened(response.shortUrl() != null && response.shortUrl().isShortened()) - .build()) - - // 3. MlInfo 빌드 - .ml(ScanHistory.MlInfo.builder() - .threats(response.ml() != null && response.ml().threats() != null - ? String.join(", ", response.ml().threats()) : null) - .mlScore(response.ml() != null ? response.ml().score() : 0) - .build()) - - // 4. ExternalApiInfo 빌드 - .externalApi(ScanHistory.ExternalApiInfo.builder() - .apiChecked(response.externalApi() != null && response.externalApi().checked()) - .apiProvider(response.externalApi() != null ? response.externalApi().provider() : null) - .apiResult(response.externalApi() != null ? response.externalApi().result() : null) - .build()) - - // 5. InternalDbInfo 빌드 - .internalDb(ScanHistory.InternalDbInfo.builder() - .dbExists(response.internalDb() != null && response.internalDb().exists()) - .dbReportCount(response.internalDb() != null ? response.internalDb().reportCount() : 0) - .dbBlockCount(response.internalDb() != null ? response.internalDb().blockCount() : 0) - .build()) - - // 6. RedirectInfo 빌드 - .redirect(ScanHistory.RedirectInfo.builder() - .finalUrl(response.redirect() != null ? response.redirect().finalUrl() : url) - .redirectCount(response.redirect() != null ? response.redirect().redirectCount() : 0) - .build()) - - // 7. ServerInfo 및 CertificateInfo - .serverInfo(ScanHistory.ServerInfo.builder() - .serverType(response.serverInfo() != null ? response.serverInfo().type() : null) - .serverLocation(response.serverInfo() != null ? response.serverInfo().location() : null) - .certificate(response.serverInfo() != null && response.serverInfo().certificate() != null - ? ScanHistory.CertificateInfo.builder() - .certValid(response.serverInfo().certificate().valid()) - .certIssuer(response.serverInfo().certificate().issuer()) - .certValidFrom(response.serverInfo().certificate().validFrom()) - .certValidTo(response.serverInfo().certificate().validTo()) - .build() - : null) - .build()) - .build(); - - scanHistoryRepository.save(scanHistory); //repository에 저장 - - return QrScanResponse.builder() - .guestUuid(guestUuid) - .schemeType(result.type()) - .typeInfo(result.typeInfo()) - .status("ANALYSIS_COMPLETED") // BE2 연동 후 실제 결과 상태로 변경 - .build(); - } - return QrScanResponse.builder() - .guestUuid(guestUuid) - .schemeType(result.type()) - .typeInfo(result.typeInfo()) - .status(status) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 27d34f7..3fb43ed 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -1,5 +1,6 @@ package com.veriq.veriqbe3.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.veriq.veriqbe3.domain.RiskLevel; import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.ProgressRequest; import com.veriq.veriqbe3.dto.QrScanResponse; @@ -84,7 +85,9 @@ public Object processWithRedis(MultipartFile image, String guestUuid) throws Exc if (freshResult != null) { // 🚨 [핵심 A] 신선한 데이터가 있으면 파이썬 분석 스킵! AnalysisResponse 객체를 통째로 리턴! + log.info("[Fast-Path] 신선한 캐시/DB 데이터 적중! 파이썬 분석 생략 - URL: {}", result.typeInfo()); + saveHitHistory(freshResult, guestUuid); return freshResult; } @@ -135,7 +138,7 @@ public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { // 🚨 핵심 2: null 방어 로직 (이전에 수정한 것과 동일하게 적용) .totalScore(responseDto.score() != null ? responseDto.score() : 0) - .riskLevel(responseDto.riskLevel()) + .riskLevel(responseDto.riskLevel() != null ? responseDto.riskLevel().name() : "SUSPICIOUS") // 1. HttpsInfo .https(ScanHistory.HttpsInfo.builder() @@ -194,6 +197,8 @@ public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { scanHistoryRepository.save(hitHistory); log.info("캐시 적중 - 새로운 스캔 히스토리(방문 기록) DB 저장 완료: {}", url); + // 유저 개인의 Redis 히스토리 리스트에도 추가 + saveHistoryToRedis(guestUuid, responseDto); } catch (Exception e) { log.error("캐시 적중 히스토리 저장 중 에러 발생", e); @@ -218,6 +223,7 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { } } // Redis에 없으면 DB에서 조회 + //스캔한 내역중 최신 내역을 db에서 뽑아옴 Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(url); if (dbHistory.isEmpty()) { return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL @@ -233,20 +239,20 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { // 4. 신선하면 DTO로 변환 후 캐싱 및 반환 AnalysisResponse dbResponse = convertToAnalysisResponse(history); - Duration dynamicTtl = calculateDynamicTtl(history.getRiskLevel()); + Duration dynamicTtl = calculateDynamicTtl(RiskLevel.from(history.getRiskLevel())); redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(dbResponse), dynamicTtl); log.info("DB 조회 결과가 신선하여 Redis 캐싱 후 반환 (TTL: {}일) : {}", dynamicTtl.toDays(), url); return dbResponse; } /** - * 데이터가 유효 기간을 넘었는지 확인 + * 데이터가 유효 기간을 넘었는지 확인//db 데이터 갱신(분석시간 위주) */ private boolean isStale(ScanHistory history) { if (history.getAnalysisTime() == null) return true; long daysPassed = java.time.Duration.between(history.getAnalysisTime(), java.time.LocalDateTime.now()).toDays(); - // SAFE는 3일, 나머지는 7일을 임계치로 설정 - long threshold = "SAFE".equals(history.getRiskLevel()) ? 3 : 7; + // SAFE는 1일, 나머지는 3일을 임계치로 설정 + long threshold = "SAFE".equalsIgnoreCase(history.getRiskLevel()) ? 1 : 3; return daysPassed >= threshold; } @@ -351,19 +357,18 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { ) : null ) : null, entity.getTotalScore(), - entity.getRiskLevel() + RiskLevel.from(entity.getRiskLevel()) ); } /** - * [추가된 핵심 로직] 위험도에 따른 차등 TTL 계산 메서드 + * [추가된 핵심 로직] 위험도에 따른 차등 TTL 계산 메서드//redis갱신 */ - private Duration calculateDynamicTtl(String riskLevel) { + private Duration calculateDynamicTtl(RiskLevel riskLevel) { if (riskLevel == null) return Duration.ofDays(2); // 안전장치 (기본값) - return switch (riskLevel.toUpperCase()) { - case "SAFE" -> Duration.ofDays(1); // 안전하면 3일마다 재검증 - case "SUSPICIOUS", " DANGER" -> Duration.ofDays(3); // 위험/의심은 7일간 길게 유지 - default -> Duration.ofDays(1); // 기타 상태는 하루 + return switch (riskLevel) { + case SAFE -> Duration.ofDays(1); + case SUSPICIOUS, DANGER -> Duration.ofDays(3); }; } @@ -386,7 +391,7 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 .analysisTime(responseDto.analysisTime()) .totalScore(responseDto.score() != null ? responseDto.score() : 0) - .riskLevel(responseDto.riskLevel()) + .riskLevel(responseDto.riskLevel() != null ? responseDto.riskLevel().name() : "SUSPICIOUS") // 1. HttpsInfo 빌드 .https(ScanHistory.HttpsInfo.builder() @@ -454,6 +459,8 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // ========================================== Duration dynamicTtl = calculateDynamicTtl(responseDto.riskLevel()); cacheAnalysisResult(url, responseDto, dynamicTtl); + // 분석 완료 후 유저 개인의 Redis 히스토리 리스트에도 추가! + saveHistoryToRedis(guestUuid, responseDto); log.info("모든 저장 및 동적 캐싱 프로세스 완료"); // 프론트엔드로 프로세스 완료 알림 진행... @@ -483,9 +490,8 @@ public SseEmitter createEmitter(String guestUuid) { } // 파이프 연결이 끊기면 명부에서 삭제 - emitter.onCompletion(() -> emitters.remove(guestUuid)); - emitter.onTimeout(() -> emitters.remove(guestUuid)); - + emitter.onCompletion(() -> emitters.remove(guestUuid, emitter)); + emitter.onTimeout(() -> emitters.remove(guestUuid, emitter)); return emitter; } diff --git a/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java b/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java index 7e68c5b..a85a0aa 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java @@ -2,12 +2,15 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import java.util.List; @Getter +@Setter @NoArgsConstructor public class GoogleCaptchaResponse { private boolean success; // 진짜 사람이면 true + @JsonProperty("challenge_ts") private String challenge_ts; // 인증 시간 private String hostname; // 요청된 도메인 From 41fce4954f528d83b97e9db9330a43d0ec57e692 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 16:36:17 +0900 Subject: [PATCH 093/136] =?UTF-8?q?fix-feat/kd-18=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/dto/AnalysisResponse.java | 2 ++ .../veriq/veriqbe3/dto/RedisScanHistoryDto.java | 2 +- .../com/veriq/veriqbe3/entity/ScanHistory.java | 14 +++++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 7fcd27d..38ed97f 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -1,5 +1,6 @@ package com.veriq.veriqbe3.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.veriq.veriqbe3.domain.RiskLevel; @@ -31,6 +32,7 @@ public record ShortUrlInfo( public record MlInfo( List threats, + @JsonAlias({"mlScore", "score"}) Integer score ) {} diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 3e28ff9..6e05f41 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -28,7 +28,7 @@ public static RedisScanHistoryDto from(AnalysisResponse mlResponse) { .typeInfo(mlResponse.originalUrl()) //.status(response.getStatus()) .scannedAt(LocalDateTime.now().format(FORMATTER)) - .riskLevel(mlResponse.riskLevel()) + .riskLevel(mlResponse.riskLevel() != null ? mlResponse.riskLevel().name() : "SUSPICIOUS") .build(); } } diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 21058c8..1975584 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -8,12 +8,12 @@ @Entity @Table(name = "scan_history",indexes={ - @Index(name = "idx_final_url", columnList = "finalUrl"), + @Index(name = "idx_final_url", columnList = "redirect_final_url"), // 추가 스캔 보고서 제공시 url을 매게로 하기떄문에 idndexing에 추가, // URL과 스캔 시간을 분리해서 인덱스를 걸어줍니다. - @Index(name = "idx_original_url", columnList = "originalUrl"), - @Index(name = "idx_scanned_at", columnList = "scannedAt"), - @Index(name = "idx_last_analyzed_at", columnList = "analysisTime") + @Index(name = "idx_original_url", columnList = "original_url"), + @Index(name = "idx_scanned_at", columnList = "scanned_at"), + @Index(name = "idx_analysis_time", columnList = "analysis_time") }) @Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -40,7 +40,7 @@ public void prePersist() { } //ML서버가 분석한 시간 private LocalDateTime analysisTime; - private int totalScore; + private Integer totalScore; private String riskLevel; @Embedded private HttpsInfo https; @@ -60,7 +60,7 @@ public static class ShortUrlInfo { private boolean isShortened; } @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor public static class MlInfo { private String threats; - private int mlScore; + private Integer mlScore; } @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -80,7 +80,7 @@ public static class InternalDbInfo { @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor public static class RedirectInfo { private String finalUrl; - private int redirectCount; + private Integer redirectCount; } @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor From a4edf4a5fb74aac210768d6bf819d575604e3b20 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 18:31:59 +0900 Subject: [PATCH 094/136] =?UTF-8?q?fix-develop:=20ml=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94=EB=82=B4=EC=97=AD=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 24 +++++++++- .../veriqbe3/dto/RedisScanHistoryDto.java | 7 ++- .../veriqbe3/service/QrScanRedisService.java | 47 +++++++++++++++---- .../veriq/veriqgateway/dto/ScanResponse.java | 16 +++++-- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 3d3135b..cd36bec 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -1,5 +1,5 @@ package com.veriq.veriqbe3.dto; - +import io.swagger.v3.oas.annotations.media.Schema; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.time.LocalDateTime; @@ -28,7 +28,29 @@ public record ShortUrlInfo( ) {} public record MlInfo( + @Schema( + // 1. description에 카테고리별로 예쁘게 정리해서 넣어줍니다. + description = "### 탐지 위협 카테고리 상세\n" + + "**1. 리다이렉트 관련**\n" + + "- `too_many_redirects`, `loop_detected`, `invalid_location`\n\n" + + "**2. URL 구조 관련**\n" + + "- `shortened_url`, `embedded_url`, `percent_encoding_detected`, `double_encoding_suspected`\n\n" + + "**3. 구글 세이프 브라우징(GSB)**\n" + + "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`\n\n" + + "**4. 기타 탐지**\n" + + "- `suspicious_param_detected`, `suspicious_keyword_detected`", + + // 2. 실제 값 리스트는 가흔님 가이드대로 플래그들만 나열합니다. + allowableValues = { + "too_many_redirects", "loop_detected", "invalid_location", + "shortened_url", "embedded_url", "percent_encoding_detected", "double_encoding_suspected", + "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION", + "suspicious_param_detected", "suspicious_keyword_detected" + }, + example = "['too_many_redirects', 'MALWARE']" + ) List threats, + @Schema(description = "ML 분석 점수", example = "85") Integer score ) {} diff --git a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java index 3e28ff9..9523685 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -1,4 +1,5 @@ package com.veriq.veriqbe3.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,12 +12,16 @@ @Builder @NoArgsConstructor @AllArgsConstructor - +@Schema(description = "QR 스캔 이력 상세 데이터") public class RedisScanHistoryDto { + @Schema(description = "URL 스키마 타입", example = "URL") private String schemeType; + @Schema(description = "원본 URL 정보 또는 텍스트 내용", example = "http://naver-login-check.xyz") private String typeInfo;// 프론트에서 보여줄 원본 URL (예: https://www.naver.com) //private String status; + @Schema(description = "스캔 일시", example = "2026-04-26T18:15:30") private String scannedAt; // 스캔시간 + @Schema(description = "위험도 레벨 (SAFE, SUSPICIOUS, DANGER)", example = "DANGER") private String riskLevel; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index e5e22d6..59483c1 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -164,25 +164,52 @@ public void cacheAnalysisResult(String url, AnalysisResponse resultDto) { } public void saveHistoryToRedis(String guestUuid, AnalysisResponse mlResponse) { - // 방어 로직: UUID가 없거나 비어있거나 익명이면, 저장하지 않고 그냥 반환 + // 1. 방어 로직: 저장할 필요가 없는 경우 즉시 반환 if (guestUuid == null || guestUuid.isBlank() || guestUuid.equals("ANONYMOUS")) { return; } + try { String historyKey = "history:" + guestUuid; - // QR 원본 정보와 ML 분석 결과를 합쳐서 히스토리 DTO 생성! - RedisScanHistoryDto item = RedisScanHistoryDto.from(mlResponse); - String jsonItem = objectMapper.writeValueAsString(item); - // Redis 리스트의 맨 앞(최신)에 밀어 넣기 - redisTemplate.opsForList().leftPush(historyKey, jsonItem); - // 항상 50개만 남기고 옛날 데이터는 잘라버림 + RedisScanHistoryDto newItem = RedisScanHistoryDto.from(mlResponse); + + // 2. 기존 히스토리 전체 조회 (0부터 끝까지) + List rawHistory = redisTemplate.opsForList().range(historyKey, 0, -1); + List currentHistory = new ArrayList<>(); + + if (rawHistory != null) { + for (String json : rawHistory) { + currentHistory.add(objectMapper.readValue(json, RedisScanHistoryDto.class)); + } + + // 3. 중복 제거: 동일한 URL(typeInfo)이 있다면 리스트에서 삭제 + currentHistory.removeIf(oldItem -> + oldItem.getTypeInfo().equals(newItem.getTypeInfo()) + ); + } + + // 4. Redis 데이터 갱신 (전체 삭제 후 정제된 데이터 재삽입) + redisTemplate.delete(historyKey); + + // 새 항목(최신)을 리스트에 추가 + String jsonNewItem = objectMapper.writeValueAsString(newItem); + redisTemplate.opsForList().leftPush(historyKey, jsonNewItem); + + // 기존 데이터들 중복 제거된 상태로 다시 추가 + if (!currentHistory.isEmpty()) { + for (RedisScanHistoryDto oldItem : currentHistory) { + redisTemplate.opsForList().rightPush(historyKey, objectMapper.writeValueAsString(oldItem)); + } + } + + // 5. 최신 데이터 50개 유지 및 유효기간 설정 redisTemplate.opsForList().trim(historyKey, 0, MAX_HISTORY_SIZE - 1); redisTemplate.expire(historyKey, HISTORY_TTL); + } catch (Exception e) { - log.error("Redis 히스토리 저장 실패 - guestUuid: {}", guestUuid, e); + log.error("Redis 히스토리 저장 중 중복 제거 실패 - guestUuid: {}", guestUuid, e); } - - } + } /** * URL 캐시 키를 안전하게 정규화하고 해싱하는 헬퍼 메서드 */ diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index f6541b2..c96fb12 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,4 +1,5 @@ package com.veriq.veriqgateway.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,14 +11,21 @@ @NoArgsConstructor @AllArgsConstructor - +@Schema(description = "QR 스캔 최종 결과 응답 객체") public class ScanResponse { - String guestUuid; // 사용자 식별용 - private String status; // PENDING 등 + @Schema(description = "사용자 식별용 UUID", example = "sangmin-test-008") + String guestUuid;// 사용자 식별용 + + private String status; + @Schema(description = "에러 코드 (캡차 필요 시: REQUIRE_CAPTCHA)", example = "null") private String error_code; // REQUIRE_CAPTCHA + @Schema(description = "사용자용 안내 메시지", example = "위험한 URL이 탐지되었습니다. 접속에 주의하세요.") private String message; @JsonProperty("isUrl") - private boolean isUrl; + @Schema(description = "URL 여부 확인", example = "true") + private Boolean isUrl; + @Schema(description = "URL 스키마 타입 (URL, TEXT, MARKET 등)", example = "URL") private String schemeType; + @Schema(description = "원본 URL 정보 또는 텍스트 내용", example = "http://naver-login-check.xyz") private String typeInfo; } From df8df923fc7acbdddfb18f6a17c31407b0865bca Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 19:03:11 +0900 Subject: [PATCH 095/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 9870b54..1e1e9da 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -17,6 +17,6 @@ public class QrScanResponse { private String typeInfo; private String status; @JsonProperty("isUrl") - private boolean isUrl; + private Boolean isUrl; private String message; } \ No newline at end of file From 17564d04f7f3bc4557342cdc9b71a1deb8ae0457 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 19:14:02 +0900 Subject: [PATCH 096/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqgateway/controller/GatewayController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 6a2e52c..7f412c8 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -123,7 +123,7 @@ public String getFilename() { .guestUuid(be3Data.getGuestUuid()) .status(be3Data.getStatus()) .message(be3Data.getMessage()) - .isUrl(be3Data.isUrl()) + .isUrl(be3Data.getIsUrl()) .schemeType(be3Data.getSchemeType() != null ? be3Data.getSchemeType().name() : null) .typeInfo(be3Data.getTypeInfo()) .build(); From 907543aacdb8bc2bf3cfa692a29b1d5df0596ce0 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 19:26:14 +0900 Subject: [PATCH 097/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=953?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java | 6 ++---- src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 1e1e9da..8dcb1f7 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -2,12 +2,10 @@ import com.veriq.veriqbe3.domain.SchemeType; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index c96fb12..d2664c8 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,12 +1,10 @@ package com.veriq.veriqgateway.dto; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; +import lombok.*; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.NoArgsConstructor; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor From 706e6db71e6a411c70f3e3095398674cde9e70e0 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 19:53:52 +0900 Subject: [PATCH 098/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqbe3/service/QrScanRedisService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 29e703f..3c427a6 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -88,7 +88,15 @@ public Object processWithRedis(MultipartFile image, String guestUuid) throws Exc log.info("[Fast-Path] 신선한 캐시/DB 데이터 적중! 파이썬 분석 생략 - URL: {}", result.typeInfo()); saveHitHistory(freshResult, guestUuid); - return freshResult; + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("COMPLETED") // 이미 끝났으니 완료 상태 + .isUrl(true) + .message("안전하게 분석된 기존 이력이 있습니다.") + // 필요하다면 QrScanResponse 안에 AnalysisResponse를 담을 수 있는 + // 필드(예: private AnalysisResponse detailResult;)를 하나 만들어서 넣어주는 것이 가장 좋습니다. + .build(); } // 4-2. 데이터가 아예 없거나 너무 낡았다면? (null이 반환된 경우) From 9c31a7e3704f3be9490ac104902d79ecade4ee7d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 22:20:48 +0900 Subject: [PATCH 099/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=955?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 7 ++----- .../veriq/veriqbe3/entity/ScanHistory.java | 4 ++-- .../veriqbe3/service/QrScanRedisService.java | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index ad52d3f..05bfb02 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -85,10 +85,7 @@ public record CertificateInfo( String issuer, // 🚨 [여기가 핵심 수정 포인트!] // 파이썬의 "Mar 10 00:00:00 2026 GMT" 형식을 읽기 위한 포맷입니다. - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en",timezone = "GMT") - LocalDateTime validFrom, - - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MMM dd HH:mm:ss yyyy z", locale = "en",timezone = "GMT") - LocalDateTime validTo + String validFrom, + String validTo ) {} } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 1975584..99a57e7 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -94,7 +94,7 @@ public static class ServerInfo { public static class CertificateInfo { private boolean certValid; private String certIssuer; - private LocalDateTime certValidFrom; - private LocalDateTime certValidTo; + private String certValidFrom; + private String certValidTo; } } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 3c427a6..2225db9 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -125,6 +125,16 @@ public Object processWithRedis(MultipartFile image, String guestUuid) throws Exc } + } + /** + * [추가] 파이썬이 보낸 가변 공백 날짜 문자열(예: "Jun 8")을 깔끔하게(공백 1개) 정제하는 헬퍼 메서드 + */ + private String cleanDateString(String rawDate) { + if (rawDate == null || rawDate.isBlank()) { + return null; + } + // 공백이 2개 이상 연속된 것을 공백 1개로 치환합니다. + return rawDate.replaceAll("\\s{2,}", " "); } /** * [Fast-Path 전용] 캐시/DB 적중 시, 분석 없이 '방문 기록'만 새로 남기는 메서드 @@ -193,8 +203,8 @@ public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { ? ScanHistory.CertificateInfo.builder() .certValid(responseDto.serverInfo().certificate().valid()) .certIssuer(responseDto.serverInfo().certificate().issuer()) - .certValidFrom(responseDto.serverInfo().certificate().validFrom()) - .certValidTo(responseDto.serverInfo().certificate().validTo()) + .certValidFrom(cleanDateString(responseDto.serverInfo().certificate().validFrom())) + .certValidTo(cleanDateString(responseDto.serverInfo().certificate().validTo())) .build() : null) .build()) @@ -476,8 +486,8 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues ? ScanHistory.CertificateInfo.builder() .certValid(responseDto.serverInfo().certificate().valid()) .certIssuer(responseDto.serverInfo().certificate().issuer()) - .certValidFrom(responseDto.serverInfo().certificate().validFrom()) - .certValidTo(responseDto.serverInfo().certificate().validTo()) + .certValidFrom(cleanDateString(responseDto.serverInfo().certificate().validFrom())) + .certValidTo(cleanDateString(responseDto.serverInfo().certificate().validTo())) .build() : null) .build()) @@ -608,3 +618,4 @@ public List getHistoryFromRedis(String guestUuid) { } + From 325329c0428549050158845dd19a592bdfbb61a5 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 22:54:23 +0900 Subject: [PATCH 100/136] =?UTF-8?q?fix-develop:=20url=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=956?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 3 +-- .../veriq/veriqbe3/entity/ScanHistory.java | 2 +- .../veriqbe3/service/QrScanRedisService.java | 26 ++++++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 05bfb02..53db309 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -9,8 +9,7 @@ import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSSSSS]") - LocalDateTime analysisTime, + String analysisTime, String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 99a57e7..5c02067 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -39,7 +39,7 @@ public void prePersist() { this.scannedAt = LocalDateTime.now(); } //ML서버가 분석한 시간 - private LocalDateTime analysisTime; + private String analysisTime; private Integer totalScore; private String riskLevel; diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 2225db9..bccd39e 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -3,6 +3,7 @@ import com.veriq.veriqbe3.domain.RiskLevel; import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.ProgressRequest; +import java.time.ZonedDateTime; import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqbe3.dto.RedisScanHistoryDto; import com.veriq.veriqbe3.entity.ScanHistory; @@ -19,7 +20,9 @@ import java.io.IOException; + import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -152,7 +155,7 @@ public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { .schemeType(SchemeType.WEB) // 🚨 핵심 1: ML 분석 시간은 기존 캐시에 있던 '과거 시간'을 그대로 유지! - .analysisTime(responseDto.analysisTime()) + .analysisTime(cleanDateString(responseDto.analysisTime())) // 🚨 핵심 2: null 방어 로직 (이전에 수정한 것과 동일하게 적용) .totalScore(responseDto.score() != null ? responseDto.score() : 0) @@ -268,11 +271,22 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { private boolean isStale(ScanHistory history) { if (history.getAnalysisTime() == null) return true; - long daysPassed = java.time.Duration.between(history.getAnalysisTime(), java.time.LocalDateTime.now()).toDays(); - // SAFE는 1일, 나머지는 3일을 임계치로 설정 - long threshold = "SAFE".equalsIgnoreCase(history.getRiskLevel()) ? 1 : 3; + try { + String rawDate = history.getAnalysisTime().replaceAll("\\s{2,}", " "); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d HH:mm:ss yyyy z", Locale.ENGLISH); + + // 🚨 LocalDateTime 대신 ZonedDateTime 사용 (GMT 시간대까지 완벽하게 소화) + ZonedDateTime analysisDateTime = ZonedDateTime.parse(rawDate, formatter); - return daysPassed >= threshold; + // 🚨 현재 시간도 ZonedDateTime.now()로 맞춰서 비교 + long daysPassed = java.time.Duration.between(analysisDateTime, ZonedDateTime.now()).toDays(); + long threshold = "SAFE".equalsIgnoreCase(history.getRiskLevel()) ? 1 : 3; + + return daysPassed >= threshold; + } catch (Exception e) { + log.warn("날짜 파싱 실패로 재분석 유도: {}", history.getAnalysisTime()); + return true; + } } /** @@ -434,7 +448,7 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .originalUrl(url) .typeInfo(url) // 보통 원본 URL을 넣습니다 .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 - .analysisTime(responseDto.analysisTime()) + .analysisTime(cleanDateString(responseDto.analysisTime())) .totalScore(responseDto.score() != null ? responseDto.score() : 0) .riskLevel(responseDto.riskLevel() != null ? responseDto.riskLevel().name() : "SUSPICIOUS") From 87a9f45f0b867ca945be3d7723b86b858764a39e Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 26 Apr 2026 23:37:14 +0900 Subject: [PATCH 101/136] =?UTF-8?q?fix-develop:=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 980fd67..409074b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: veriq_db + TZ: Asia/Seoul volumes: - ./mysql_data:/var/lib/mysql @@ -21,14 +22,16 @@ services: restart: always ports: - "127.0.0.1:6379:6379" + environment: + TZ: Asia/Seoul # 👈 Redis 타임존 설정 - # 3. Spring BE1 (Gateway - 박상민 ) + # 3. Spring BE1 (Gateway) veriq-gateway: build: context: . dockerfile: Dockerfile args: - - MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication # 👈 Gateway 메인 지정 + - MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication container_name: veriq-be1 restart: always ports: @@ -36,14 +39,12 @@ services: environment: SPRING_PROFILES_ACTIVE: be1 SERVER_PORT: 8081 + TZ: Asia/Seoul # 👈 자바 앱 타임존 설정 GOOGLE_RECAPTCHA_SECRET_KEY: ${GOOGLE_RECAPTCHA_SECRET_KEY} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ML_SECRET_KEY: ${ML_SECRET_KEY} FRONTEND_CORS_URL: http://34.64.182.89:3000 - - BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" - depends_on: - veriq-db - veriq-redis @@ -62,10 +63,12 @@ services: environment: SPRING_PROFILES_ACTIVE: be3 SERVER_PORT: 8083 + TZ: Asia/Seoul # 👈 자바 앱 타임존 설정 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ML_SECRET_KEY: ${ML_SECRET_KEY} FRONTEND_CORS_URL: http://34.64.182.89:3000 - + # DB 연결 시 타임존 파라미터를 명시적으로 추가하는 것이 좋습니다. + # 예: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul depends_on: - veriq-db - veriq-redis @@ -78,13 +81,12 @@ services: container_name: veriq-analysis restart: always ports: - - "8000:8000" + - "8000:8000" environment: + TZ: Asia/Seoul # 👈 파이썬 분석 엔진 타임존 설정 ML_SECRET_KEY: ${ML_SECRET_KEY} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} BE3_CALLBACK_FINAL_URL: "http://veriq-be3:8083/api/v1/scan/callback" BE3_CALLBACK_PROGRESS_URL: "http://veriq-be3:8083/api/v1/scan/callback/progress" - - depends_on: - veriq-db From 20a79ce97abbaea30d46088079e7e73bee8b4429 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 17:10:34 +0900 Subject: [PATCH 102/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/config/SwaggerConfig.java | 38 +++++++--- .../veriqbe3/controller/QrScanController.java | 74 +++++++++++++++++-- 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java index bab7b26..cec060c 100644 --- a/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java +++ b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java @@ -1,17 +1,33 @@ package com.veriq.veriqbe3.config; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import org.springframework.context.annotation.Bean; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; import org.springframework.context.annotation.Configuration; @Configuration +@OpenAPIDefinition( + info = @Info( + title = "Veri-Q API 분석 명세서", + version = "v1.0", + description = """ + ## 🚀 Veri-Q QR 분석 전체 시퀀스 (프론트엔드 필독) + + 분석의 신뢰성과 실시간성 보장을 위해 아래 **10단계 순서**를 반드시 준수해야 합니다. + + 1. **SSE 연결 (Subscribe)**: 프론트엔드가 BE3의 `/subscribe`에 연결합니다. **(반드시 분석 요청보다 먼저 수행!)** + 2. **INIT 수신**: BE3로부터 `INIT` 채널을 통해 연결 성공 신호를 받습니다. + 3. **분석 요청**: 프론트엔드가 BE1(Gateway)으로 QR 이미지를 전송합니다. + 4. **내부 전달**: BE1이 검증 후 BE3로 이미지를 전달합니다. + 5. **분석 트리거**: BE3가 FastAPI로 분석을 요청합니다. + 6. **중간보고 수신**: FastAPI가 BE3로 분석 단계(Progress)를 전달합니다. + 7. **진행 상황 중계**: BE3가 프론트엔드로 `progress` 채널을 통해 현재 단계를 실시간 전송합니다. + 8. **분석 완료**: FastAPI가 최종 결과를 BE3로 전달합니다. + 9. **완료 알림**: BE3가 DB/Redis 저장 후 `COMPLETE` 채널로 분석 완료 및 **원본 URL**을 전송합니다. + 10. **상세 결과 조회**: 프론트엔드가 `COMPLETE`에서 받은 URL을 사용하여 `/detail` API를 호출합니다. + """ + ) +) public class SwaggerConfig { - @Bean - public OpenAPI veriQOpenAPI() { - return new OpenAPI() - .info(new Info() - .title("Veri-Q API Server") - .description("스캔 리스트 ,상세보고서 제공,sse통로 구축 관련 api 명세서입니다.") - .version("v1.0.0")); - } + // @OpenAPIDefinition을 통해 API 메타데이터가 정의되었으므로 + // 클래스 내부에 추가적인 Bean 설정 없이도 스웨거 페이지에 반영됩니다. } diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 3a41309..40777f4 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -2,8 +2,10 @@ import com.veriq.veriqbe3.dto.AnalysisResponse; import com.veriq.veriqbe3.dto.ProgressRequest; +import com.veriq.veriqbe3.dto.RedisScanHistoryDto; import com.veriq.veriqbe3.service.QrScanRedisService; import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.ArraySchema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; @@ -15,6 +17,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.beans.factory.annotation.Value; import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -35,12 +42,35 @@ public class QrScanController { private final RedisTemplate redisTemplate; @Value("${ML_SECRET_KEY}") private String mlServerSecret; - - - - + @Operation( + summary = "QR 코드 업로드 및 분석 시작", + description = """ + ### 로딩 UI 및 SSE 연결 가이드 + + 이 API의 응답 결과(`status`)에 따라 프론트엔드 흐름을 제어해야 합니다. + + **1. 공통 사항** + - 응답을 받으면 즉시 "QR 코드 디코딩이 완료되었습니다" 메시지와 함께 로딩 UI를 준비합니다. + + **2. 상태(`status`)별 분기 처리** + + - 🟢 **`status == "COMPLETED"` (기존 데이터 존재 또는 비 URL)** + - **SSE 연결을 하지 마세요.** + - **`isUrl == false`**: 비 URL 전용 로딩창을 띄우고 빠르게 로딩창을 순차 진행(스킵 타입 반영) 후 완료 처리합니다. + - **`isUrl == true`**: 기존 DB/캐시 적중 사례입니다. URL 전용 로딩창을 빠르게 실행하고, 응답에 포함된 `originalUrl`을 사용하여 `/detail` API를 즉시 호출합니다. + + - 🟡 **`status == "PROCESSING"` (신규 분석 필요)** + - **반드시 `/subscribe` 엔드포인트로 SSE 파이프를 연결해야 합니다.** + - SSE로 전달되는 `step`, `status` 정보를 실시간으로 로딩 UI에 바인딩하세요. + + **3. 스킴 타입(`schemeType`) 처리** + - `PROCESSING` 상태일 때는 값이 들어와도 **무시**합니다. + - `COMPLETED` 상태가 되었을 때: + - `isUrl == true`이면 **무시**합니다. + - `isUrl == false`이면 해당 값(예: `tel`, `smsto`)을 UI에 **반영**합니다. + """ + ) @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //multipart_form_data 제한 - // 🚨 1. 반환 타입을 에서 로 변경합니다! public ResponseEntity uploadQrImage( @RequestHeader(value = "guest_uuid" ) String guestUuid, @RequestParam("image") MultipartFile image) { @@ -74,6 +104,17 @@ public ResponseEntity uploadQrImage( } } // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 + @Operation( + summary = "분석 결과 상세 조회", + description = """ + ### 호출 시점: + SSE의 `COMPLETE` 이벤트를 수신한 직후 호출합니다. + + ### 요청 방법: + - **URL 파라미터**: `COMPLETE` 이벤트 메시지에 포함된 `originalUrl`을 `url` 쿼리 파라미터로 전달합니다. + - **헤더**: 요청 시 `guest_uuid`를 반드시 포함해야 합니다. + """ + ) @GetMapping("/detail") public ResponseEntity getUrlDetail(@RequestParam("url") String url) { try { @@ -235,6 +276,18 @@ public ResponseEntity analysisProgressCallback( // 프론트엔드가 파이프를 꽂으러 오는 곳 @CrossOrigin(origins = "${frontend.url}")//프론트의 주소에 따라 유동, 해당 주소만 파이프 받아들임 + @Operation( + summary = "실시간 분석 상태 구독 (SSE)", + description = """ + ### 주의사항: 분석 요청(Upload) 전 반드시 이 파이프라인이 먼저 연결되어야 합니다. + 연결되지 않은 상태에서 분석 요청 시, 중간 과정을 수신할 수 없습니다. + + **수신 이벤트 채널 안내:** + - `INIT`: SSE 연결 성공 시 전송 + - `progress`: 분석 단계별 상태 전송 (Step, Status 포함) + - `COMPLETE`: 분석 최종 완료 시 전송 (**이벤트 데이터에 포함된 URL로 상세조회 수행**) + """ + ) @GetMapping(value = "/subscribe", produces = "text/event-stream;charset=UTF-8") public ResponseEntity subscribe(@RequestParam("guest_uuid") String guestUuid) { @@ -250,7 +303,18 @@ public ResponseEntity subscribe(@RequestParam("guest_uuid") String g } } //스캔 리스트 제공 api(프런트) + @Operation(summary = "스캔 리스트 제공 API", description = "사용자의 최근 QR 스캔 이력을 반환합니다.") @GetMapping("/history") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공적으로 이력을 조회함", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = RedisScanHistoryDto.class)) + ) + ) + }) public ResponseEntity> getScanHistory ( @RequestHeader(value = "guest_uuid", required = true) String guestUuid) { From eb0d9cc0e468c4c1834e000e508c89e987f1d1a4 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 17:40:17 +0900 Subject: [PATCH 103/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=812?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 7f412c8..e8bd00f 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -2,6 +2,7 @@ import com.veriq.veriqbe3.dto.QrScanResponse; import com.veriq.veriqgateway.dto.ScanResponse; import com.veriq.veriqgateway.service.SecurityService; +import io.swagger.v3.oas.annotations.Operation; import org.springframework.context.annotation.Profile; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -15,7 +16,17 @@ import org.springframework.web.multipart.MultipartFile; import com.veriq.veriqgateway.dto.CaptchaRequest; import org.springframework.beans.factory.annotation.Value; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + + import java.util.UUID; +@Tag(name = "Gateway API", description = "보안 검사 및 분석 요청 위임 컨트롤러") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -61,6 +72,34 @@ private String getClientIp(HttpServletRequest request) { // 헤더에 정보가 없으면 마지막 수단으로 직접 접속 IP 반환 return request.getRemoteAddr(); } + @Operation( + summary = "QR 코드 업로드 및 분석 시작", + description = """ + ### 🚨 로딩 UI 및 SSE 연결 가이드 + + 이 API의 응답 결과(`status`)에 따라 프론트엔드 흐름을 제어해야 합니다. + + **1. 공통 사항** + - 응답을 받으면 즉시 "QR 코드 디코딩이 완료되었습니다" 메시지와 함께 로딩 UI를 준비합니다. + + **2. 상태(`status`)별 분기 처리** + + - 🟢 **`status == "COMPLETED"` (기존 데이터 존재 또는 비 URL)** + - **SSE 연결을 하지 마세요.** + - `isUrl == false`: 비 URL 전용 로딩창을 띄우고 빠르게 순차 진행(스킵 타입 반영) 후 완료 처리합니다. + - `isUrl == true`: 기존 DB/캐시 적중 사례입니다. URL 전용 로딩창을 빠르게 실행하고, 응답에 포함된 `originalUrl`을 사용하여 `/detail` API를 즉시 호출합니다. + + - 🟡 **`status == "PROCESSING"` (신규 분석 필요)** + - **반드시 `/subscribe` 엔드포인트로 SSE 파이프를 연결해야 합니다.** + - SSE로 전달되는 `step`, `status` 정보를 실시간으로 로딩 UI에 바인딩하세요. + + **3. 스킴 타입(`schemeType`) 처리** + - `PROCESSING` 상태일 때는 값이 들어와도 **무시**합니다. + - `COMPLETED` 상태가 되었을 때: + - `isUrl == true`이면 **무시**합니다. + - `isUrl == false`이면 해당 값(예: `tel`, `smsto`)을 UI에 **반영**합니다. + """ + ) @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( @RequestHeader("guest_uuid") String guestUuid, @@ -154,7 +193,14 @@ public String getFilename() { * [POST] /api/v1/auth/captcha/verify * 캡차 성공 시 해당 사용자의 Redis 제한 카운트를 초기화합니다. */ - + @Operation( + summary = "구글 reCAPTCHA 검증 및 제한 초기화", + description = "프론트엔드에서 획득한 캡차 토큰을 검증합니다. 성공 시 해당 IP의 Redis 카운트가 초기화되어 다시 스캔이 가능해집니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "캡차 인증 성공. 이제 다시 /scan API를 호출할 수 있습니다."), + @ApiResponse(responseCode = "400", description = "캡차 인증 실패. 토큰이 만료되었거나 봇으로 판명되었습니다.") + }) @PostMapping("/auth/captcha/verify") public ResponseEntity verifyCaptcha( @RequestBody CaptchaRequest captchaReq, From c3999b56529de824316c7e8a43cfe281dfc8db70 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 18:08:40 +0900 Subject: [PATCH 104/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=813?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqgateway/controller/GatewayController.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index e8bd00f..49a6486 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -100,6 +100,15 @@ private String getClientIp(HttpServletRequest request) { - `isUrl == false`이면 해당 값(예: `tel`, `smsto`)을 UI에 **반영**합니다. """ ) + @ApiResponses(value = { + // 🚨 핵심: implementation에 ScanResponse.class를 넣어주면 하얀 빈 칸이 사라집니다! + @ApiResponse(responseCode = "200", description = "기존 분석 결과 즉시 반환 (Success)", + content = @Content(schema = @Schema(implementation = ScanResponse.class))), + @ApiResponse(responseCode = "202", description = "신규 분석 프로세스 시작 (Accepted)", + content = @Content(schema = @Schema(implementation = ScanResponse.class))), + @ApiResponse(responseCode = "429", description = "보안 정책에 의한 요청 차단 (Captcha Required)", + content = @Content(schema = @Schema(implementation = ScanResponse.class))) + }) @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( @RequestHeader("guest_uuid") String guestUuid, From b161c0097674a41f07159b58f2da7a08ad7af754 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 18:36:22 +0900 Subject: [PATCH 105/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=814?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 2 +- .../veriqgateway/dto/QrScanResponse.java | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 49a6486..674fd03 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -1,5 +1,5 @@ package com.veriq.veriqgateway.controller; -import com.veriq.veriqbe3.dto.QrScanResponse; +import com.veriq.veriqgateway.dto.QrScanResponse; import com.veriq.veriqgateway.dto.ScanResponse; import com.veriq.veriqgateway.service.SecurityService; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java new file mode 100644 index 0000000..97c2caf --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java @@ -0,0 +1,19 @@ +package com.veriq.veriqgateway.dto; +import com.veriq.veriqbe3.domain.SchemeType; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor + +public class QrScanResponse { + private String guestUuid; + private SchemeType schemeType; + private String typeInfo; + private String status; + @JsonProperty("isUrl") + private Boolean isUrl; + private String message; +} From af50c3a902db5ee4df7c6eb16f5a4aeecc997910 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 18:45:07 +0900 Subject: [PATCH 106/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=815?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqgateway/domain/SchemeType.java | 16 ++++++++++++++++ .../veriq/veriqgateway/dto/QrScanResponse.java | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/veriq/veriqgateway/domain/SchemeType.java diff --git a/src/main/java/com/veriq/veriqgateway/domain/SchemeType.java b/src/main/java/com/veriq/veriqgateway/domain/SchemeType.java new file mode 100644 index 0000000..1dce675 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/domain/SchemeType.java @@ -0,0 +1,16 @@ +package com.veriq.veriqgateway.domain; + +public enum SchemeType { + WEB, + SHORT_URL, + OTP, + CRYPTO, + SMS, + WIFI, + CONTACT, + DEEP_LINK, + TEL, // 전화번호 (tel:) + EMAIL, // 이메일 (mailto:) + APP_STORE, // 앱 설치 페이지 (market:, itms-apps:) + OTHER // 기타 단순 텍스트 등 +} diff --git a/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java index 97c2caf..017198e 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java @@ -1,5 +1,5 @@ package com.veriq.veriqgateway.dto; -import com.veriq.veriqbe3.domain.SchemeType; +import com.veriq.veriqgateway.domain.SchemeType; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; @Getter From 542d92ddb322dfc8cddc8249c50d6bf9fdfc0402 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 19:04:44 +0900 Subject: [PATCH 107/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=816?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 674fd03..0691ad3 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -3,6 +3,7 @@ import com.veriq.veriqgateway.dto.ScanResponse; import com.veriq.veriqgateway.service.SecurityService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ExampleObject; import org.springframework.context.annotation.Profile; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -101,13 +102,63 @@ private String getClientIp(HttpServletRequest request) { """ ) @ApiResponses(value = { - // 🚨 핵심: implementation에 ScanResponse.class를 넣어주면 하얀 빈 칸이 사라집니다! - @ApiResponse(responseCode = "200", description = "기존 분석 결과 즉시 반환 (Success)", - content = @Content(schema = @Schema(implementation = ScanResponse.class))), - @ApiResponse(responseCode = "202", description = "신규 분석 프로세스 시작 (Accepted)", - content = @Content(schema = @Schema(implementation = ScanResponse.class))), - @ApiResponse(responseCode = "429", description = "보안 정책에 의한 요청 차단 (Captcha Required)", - content = @Content(schema = @Schema(implementation = ScanResponse.class))) + // --- 1 & 2. COMPLETED 케이스 (기존 데이터 존재 또는 비 URL) --- + @ApiResponse(responseCode = "200", description = "기존 분석 결과 즉시 반환 (SSE 연결 불필요)", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ScanResponse.class), + examples = { + @ExampleObject( + name = "1. COMPLETED (URL 타입)", + summary = "기존 URL 분석 결과가 있는 경우", + value = """ + { + "guestUuid": "sangmin-uuid-123", + "status": "COMPLETED", + "isUrl": true, + "message": "기존 분석 결과가 존재합니다. 바로 /detail을 호출하세요.", + "typeInfo": "https://naver-login-check.xyz", + "schemeType": "URL" + } + """ + ), + @ExampleObject( + name = "2. COMPLETED (비 URL 타입)", + summary = "텍스트, 전화번호 등 비 URL QR인 경우", + value = """ + { + "guestUuid": "sangmin-uuid-123", + "status": "COMPLETED", + "isUrl": false, + "message": "비 URL QR 코드입니다. schemeType을 UI에 반영하세요.", + "typeInfo": "010-1234-5678", + "schemeType": "TEL" + } + """ + ) + } + ) + ), + // --- 3. PROCESSING 케이스 (신규 분석 필요) --- + @ApiResponse(responseCode = "202", description = "신규 분석 프로세스 시작 (SSE 연결 필수)", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ScanResponse.class), + examples = @ExampleObject( + name = "3. PROCESSING (신규 분석)", + summary = "처음 분석하는 QR인 경우", + value = """ + { + "guestUuid": "sangmin-uuid-123", + "status": "PROCESSING", + "isUrl": true, + "message": "분석을 시작합니다. 즉시 /subscribe SSE를 연결하세요.", + "typeInfo": "https://unknown-qr-link.com", + "schemeType": null + } + """ + ) + ) + ), + @ApiResponse(responseCode = "429", description = "캡차 인증 필요") }) @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( @@ -206,10 +257,6 @@ public String getFilename() { summary = "구글 reCAPTCHA 검증 및 제한 초기화", description = "프론트엔드에서 획득한 캡차 토큰을 검증합니다. 성공 시 해당 IP의 Redis 카운트가 초기화되어 다시 스캔이 가능해집니다." ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "캡차 인증 성공. 이제 다시 /scan API를 호출할 수 있습니다."), - @ApiResponse(responseCode = "400", description = "캡차 인증 실패. 토큰이 만료되었거나 봇으로 판명되었습니다.") - }) @PostMapping("/auth/captcha/verify") public ResponseEntity verifyCaptcha( @RequestBody CaptchaRequest captchaReq, From 3fec21d0d91976ebc6042e71318bbdd7970406e1 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 21:22:55 +0900 Subject: [PATCH 108/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=817?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GatewayController.java | 38 +++++++++++-------- .../veriq/veriqgateway/dto/ScanResponse.java | 27 +++++++------ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 0691ad3..7896e82 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -115,9 +115,8 @@ private String getClientIp(HttpServletRequest request) { "guestUuid": "sangmin-uuid-123", "status": "COMPLETED", "isUrl": true, - "message": "기존 분석 결과가 존재합니다. 바로 /detail을 호출하세요.", - "typeInfo": "https://naver-login-check.xyz", - "schemeType": "URL" + "schemeType": "URL", + "typeInfo": "https://naver-login-check.xyz" } """ ), @@ -129,9 +128,8 @@ private String getClientIp(HttpServletRequest request) { "guestUuid": "sangmin-uuid-123", "status": "COMPLETED", "isUrl": false, - "message": "비 URL QR 코드입니다. schemeType을 UI에 반영하세요.", - "typeInfo": "010-1234-5678", - "schemeType": "TEL" + "schemeType": "TEL", + "typeInfo": "010-1234-5678" } """ ) @@ -150,15 +148,29 @@ private String getClientIp(HttpServletRequest request) { "guestUuid": "sangmin-uuid-123", "status": "PROCESSING", "isUrl": true, - "message": "분석을 시작합니다. 즉시 /subscribe SSE를 연결하세요.", - "typeInfo": "https://unknown-qr-link.com", - "schemeType": null + "schemeType": null, + "typeInfo": "https://unknown-qr-link.com" } """ ) ) ), - @ApiResponse(responseCode = "429", description = "캡차 인증 필요") + // 🚨 429 에러일 때도 스키마 형태를 맞춰줍니다. + @ApiResponse(responseCode = "429", description = "캡차 인증 필요", + content = @Content(mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "guestUuid": "sangmin-uuid-123", + "status": "REJECTED", + "isUrl": null, + "schemeType": null, + "typeInfo": "REQUIRE_CAPTCHA" + } + """ + ) + ) + ) }) @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity processScan( @@ -174,8 +186,6 @@ public ResponseEntity processScan( .body(ScanResponse.builder() .guestUuid(guestUuid) .status("REJECTED") - .error_code("REQUIRE_CAPTCHA") - .message("요청 횟수 초과. 안전한 이용을 위해 캡차 인증이 필요합니다.") .build()); } @@ -221,7 +231,6 @@ public String getFilename() { ScanResponse finalResponse = ScanResponse.builder() .guestUuid(be3Data.getGuestUuid()) .status(be3Data.getStatus()) - .message(be3Data.getMessage()) .isUrl(be3Data.getIsUrl()) .schemeType(be3Data.getSchemeType() != null ? be3Data.getSchemeType().name() : null) .typeInfo(be3Data.getTypeInfo()) @@ -244,7 +253,6 @@ public String getFilename() { .body(ScanResponse.builder() .guestUuid(guestUuid) .status("ERROR") - .message("게이트웨이 통신 중 알 수 없는 오류 발생") .build()); } } @@ -273,7 +281,6 @@ public ResponseEntity verifyCaptcha( .body(ScanResponse.builder() .guestUuid(currentUuid) .status("REJECTED") - .message("캡차 검증에 실패했습니다. 다시 시도해 주세요.") .build()); } @@ -284,6 +291,5 @@ public ResponseEntity verifyCaptcha( return ResponseEntity.ok(ScanResponse.builder() .guestUuid(currentUuid) .status("SUCCESS") - .message("캡차 인증 성공! 이제 다시 스캔을 이용하실 수 있습니다.") .build());} } diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index d2664c8..6aa91d6 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,29 +1,34 @@ package com.veriq.veriqgateway.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; -import com.fasterxml.jackson.annotation.JsonProperty; @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor - @Schema(description = "QR 스캔 최종 결과 응답 객체") public class ScanResponse { - @Schema(description = "사용자 식별용 UUID", example = "sangmin-test-008") - String guestUuid;// 사용자 식별용 + @Schema(description = "사용자 식별용 UUID", example = "sangmin-uuid-123") + private String guestUuid; + + @Schema(description = "분석 진행 상태 (COMPLETED 또는 PROCESSING)", example = "COMPLETED") private String status; - @Schema(description = "에러 코드 (캡차 필요 시: REQUIRE_CAPTCHA)", example = "null") - private String error_code; // REQUIRE_CAPTCHA - @Schema(description = "사용자용 안내 메시지", example = "위험한 URL이 탐지되었습니다. 접속에 주의하세요.") - private String message; + @JsonProperty("isUrl") @Schema(description = "URL 여부 확인", example = "true") private Boolean isUrl; - @Schema(description = "URL 스키마 타입 (URL, TEXT, MARKET 등)", example = "URL") + + // 🚨 핵심 포인트: allowableValues를 통해 Enum 종류를 명시합니다. + @Schema(description = "URL 스키마 타입", example = "URL", + allowableValues = {"URL", "TEL", "SMSTO", "MAILTO", "GEO", "MARKET", "OTHER"}) private String schemeType; - @Schema(description = "원본 URL 정보 또는 텍스트 내용", example = "http://naver-login-check.xyz") + + @Schema(description = "원본 URL 정보 또는 텍스트 데이터", example = "https://naver-login-check.xyz") private String typeInfo; -} + + // 🗑️ message와 error_code 필드는 삭제되었습니다. +} \ No newline at end of file From b5b48d844ad603811eb25b24e58137ee26d03485 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 21:36:34 +0900 Subject: [PATCH 109/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=818?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index 6aa91d6..54f8fcf 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -1,6 +1,7 @@ package com.veriq.veriqgateway.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -9,6 +10,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonPropertyOrder({"guestUuid", "status", "isUrl", "schemeType", "typeInfo"}) @Schema(description = "QR 스캔 최종 결과 응답 객체") public class ScanResponse { From 443ed5c9e760df71df717de42a3065424e916f81 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 21:49:55 +0900 Subject: [PATCH 110/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=819?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index 54f8fcf..681d8a5 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -24,9 +24,8 @@ public class ScanResponse { @Schema(description = "URL 여부 확인", example = "true") private Boolean isUrl; - // 🚨 핵심 포인트: allowableValues를 통해 Enum 종류를 명시합니다. - @Schema(description = "URL 스키마 타입", example = "URL", - allowableValues = {"URL", "TEL", "SMSTO", "MAILTO", "GEO", "MARKET", "OTHER"}) + @Schema(description = "URL 스키마 타입", example = "WEB", + allowableValues = {"WEB", "SHORT_URL", "OTP", "CRYPTO", "SMS", "WIFI", "CONTACT", "DEEP_LINK", "TEL", "EMAIL", "APP_STORE", "OTHER"}) private String schemeType; @Schema(description = "원본 URL 정보 또는 텍스트 데이터", example = "https://naver-login-check.xyz") From 19459e5c0ef75e4a5354c11218e460df7bde722c Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 28 Apr 2026 21:59:51 +0900 Subject: [PATCH 111/136] =?UTF-8?q?fix-=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=8110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqgateway/dto/ScanResponse.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java index 681d8a5..b7bb762 100644 --- a/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -24,8 +24,31 @@ public class ScanResponse { @Schema(description = "URL 여부 확인", example = "true") private Boolean isUrl; - @Schema(description = "URL 스키마 타입", example = "WEB", - allowableValues = {"WEB", "SHORT_URL", "OTP", "CRYPTO", "SMS", "WIFI", "CONTACT", "DEEP_LINK", "TEL", "EMAIL", "APP_STORE", "OTHER"}) + @Schema( + // 1. 실제 Enum 클래스를 연결해서 스웨거가 목록을 인식하게 합니다. + implementation = com.veriq.veriqgateway.domain.SchemeType.class, + description = """ + URL 스키마 타입 상세 가이드: + - **WEB**: 일반적인 웹사이트 주소 (http, https) + - **SHORT_URL**: bit.ly 등 단축 URL 서비스 + - **OTP**: 2단계 인증용 일회용 비밀번호 생성 + - **CRYPTO**: 가상자산 지갑 주소 및 송금 요청 + - **SMS**: 문자 메시지 발송 (smsto:) + - **WIFI**: 와이파이 네트워크 자동 접속 정보 + - **CONTACT**: 연락처(vCard) 자동 저장 정보 + - **DEEP_LINK**: 특정 앱의 특정 화면 실행 링크,instagram://kakaotalk://kakaopay/money/remit (카카오페이 송금 화면) + - **TEL**: 전화 걸기 (tel:) + - **EMAIL**: 이메일 작성 (mailto:) + - **APP_STORE**: 앱 마켓(PlayStore, AppStore) 이동, 앱 설치 및 업데이트 페이지 + - **OTHER**: 기타 텍스트 및 미분류 타입 + """, + example = "WEB", + // 2. 명시적으로 순서나 허용 값을 한 번 더 잡아줍니다. + allowableValues = { + "WEB", "SHORT_URL", "OTP", "CRYPTO", "SMS", "WIFI", + "CONTACT", "DEEP_LINK", "TEL", "EMAIL", "APP_STORE", "OTHER" + } + ) private String schemeType; @Schema(description = "원본 URL 정보 또는 텍스트 데이터", example = "https://naver-login-check.xyz") From b622258b8e719cbb3d55278332ce0287430bdf7f Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 02:52:34 +0900 Subject: [PATCH 112/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 17 +++++++++----- .../veriqbe3/service/QrScanRedisService.java | 23 ++++++++++--------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 53db309..a4fc507 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -1,5 +1,6 @@ package com.veriq.veriqbe3.dto; import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -14,8 +15,16 @@ public record AnalysisResponse( HttpsInfo https, ShortUrlInfo shortUrl, MlInfo ml, + @JsonAlias({"externalApi", "safe_browsing"}) ExternalApiInfo externalApi, - InternalDbInfo internalDb, + @JsonProperty("report_count") + Integer reportCount, + @JsonProperty("block_count") + Integer blockCount, + @JsonProperty("domain_age") + String domainAge, + + RedirectInfo redirect, ServerInfo serverInfo, Integer score, @@ -62,11 +71,7 @@ public record ExternalApiInfo( String result // 실패/결과 없을 시 null 대응을 위해 String 유지 ) {} - public record InternalDbInfo( - boolean exists, - Integer reportCount, // null 대응을 위해 래퍼 클래스 Integer 사용 - Integer blockCount - ) {} + public record RedirectInfo( String finalUrl, diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index bccd39e..a0c75c8 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -187,9 +187,10 @@ public void saveHitHistory(AnalysisResponse responseDto, String guestUuid) { // 5. InternalDbInfo .internalDb(ScanHistory.InternalDbInfo.builder() - .dbExists(responseDto.internalDb() != null && responseDto.internalDb().exists()) - .dbReportCount(responseDto.internalDb() != null && responseDto.internalDb().reportCount() != null ? responseDto.internalDb().reportCount() : 0) - .dbBlockCount(responseDto.internalDb() != null && responseDto.internalDb().blockCount() != null ? responseDto.internalDb().blockCount() : 0) + // 🚨 껍데기 없이 직접 호출! exists는 신고 건수가 1 이상일 때 true로 판단 + .dbExists(responseDto.reportCount() != null && responseDto.reportCount() > 0) + .dbReportCount(responseDto.reportCount()) + .dbBlockCount(responseDto.blockCount()) .build()) // 6. RedirectInfo (null 방어) @@ -396,11 +397,11 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { entity.getExternalApi().getApiProvider(), entity.getExternalApi().getApiResult() ) : null, - entity.getInternalDb() != null ? new AnalysisResponse.InternalDbInfo( - entity.getInternalDb().isDbExists(), - entity.getInternalDb().getDbReportCount(), - entity.getInternalDb().getDbBlockCount() - ) : null, + // ✅ 새로 들어갈 3줄 (reportCount, blockCount, domainAge) + entity.getInternalDb() != null ? entity.getInternalDb().getDbReportCount() : null, + entity.getInternalDb() != null ? entity.getInternalDb().getDbBlockCount() : null, + null, // domainAge는 아직 DB에 없으므로 null 처리 + // ✅ entity.getRedirect() != null ? new AnalysisResponse.RedirectInfo( entity.getRedirect().getFinalUrl(), entity.getRedirect().getRedirectCount() @@ -479,9 +480,9 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // 5. InternalDbInfo 빌드 .internalDb(ScanHistory.InternalDbInfo.builder() - .dbExists(responseDto.internalDb() != null && responseDto.internalDb().exists()) - .dbReportCount(responseDto.internalDb() != null && responseDto.internalDb().reportCount() != null ? responseDto.internalDb().reportCount() : 0) - .dbBlockCount(responseDto.internalDb() != null && responseDto.internalDb().blockCount() != null ? responseDto.internalDb().blockCount() : 0) + .dbExists(responseDto.reportCount() != null && responseDto.reportCount() > 0) + .dbReportCount(responseDto.reportCount()) + .dbBlockCount(responseDto.blockCount()) .build()) From 277dd503987b6cbc708c8e75ce40aab7909aaf10 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 03:13:38 +0900 Subject: [PATCH 113/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/service/QrScanRedisService.java | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index a0c75c8..fc7172d 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -274,16 +274,30 @@ private boolean isStale(ScanHistory history) { try { String rawDate = history.getAnalysisTime().replaceAll("\\s{2,}", " "); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d HH:mm:ss yyyy z", Locale.ENGLISH); - - // 🚨 LocalDateTime 대신 ZonedDateTime 사용 (GMT 시간대까지 완벽하게 소화) - ZonedDateTime analysisDateTime = ZonedDateTime.parse(rawDate, formatter); + LocalDateTime analysisDateTime; + + // 🚨 1. 파이썬이 새롭게 보내는 "2026-04-29T12:56:45" (ISO 포맷) 처리 + if (rawDate.contains("T")) { + // 뒤에 Z나 GMT 같은 타임존 정보가 붙어있을 경우를 대비해 가장 안전하게 파싱 + if (rawDate.endsWith("Z")) { + analysisDateTime = ZonedDateTime.parse(rawDate).toLocalDateTime(); + } else { + // 순수 "2026-04-29T12:56:45" 형태일 때 + analysisDateTime = LocalDateTime.parse(rawDate); + } + } + // 🚨 2. 기존에 저장되어 있던 과거 데이터 ("Apr 29 12:56:45 2026 GMT") 호환성 유지 + else { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d HH:mm:ss yyyy z", Locale.ENGLISH); + analysisDateTime = ZonedDateTime.parse(rawDate, formatter).toLocalDateTime(); + } - // 🚨 현재 시간도 ZonedDateTime.now()로 맞춰서 비교 - long daysPassed = java.time.Duration.between(analysisDateTime, ZonedDateTime.now()).toDays(); + // 현재 시간과 비교하여 며칠이 지났는지 계산 + long daysPassed = java.time.Duration.between(analysisDateTime, LocalDateTime.now()).toDays(); long threshold = "SAFE".equalsIgnoreCase(history.getRiskLevel()) ? 1 : 3; return daysPassed >= threshold; + } catch (Exception e) { log.warn("날짜 파싱 실패로 재분석 유도: {}", history.getAnalysisTime()); return true; From 60a9b9330188f2791034e1b498d62efe2dca4b9b Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 03:22:03 +0900 Subject: [PATCH 114/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=953?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index a4fc507..b50b9f3 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -11,6 +11,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( String analysisTime, + @JsonAlias({"original_url", "url", "originalUrl"}) String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, From 6e4f512c241becec9c8d0515d65966ec0a2c287f Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 03:35:33 +0900 Subject: [PATCH 115/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index b50b9f3..3074c9a 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -11,7 +11,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( String analysisTime, - @JsonAlias({"original_url", "url", "originalUrl"}) + @JsonProperty("originalUrl") + @JsonAlias({"original_url", "url"}) String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, From 612946337e41b0bb500ea2ddbb68e01d5825a038 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 03:55:56 +0900 Subject: [PATCH 116/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 3074c9a..3999a9a 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -1,37 +1,62 @@ package com.veriq.veriqbe3.dto; + import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; import io.swagger.v3.oas.annotations.media.Schema; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.veriq.veriqbe3.domain.RiskLevel; -import java.time.LocalDateTime; import java.util.List; + @JsonIgnoreProperties(ignoreUnknown = true) public record AnalysisResponse( String analysisTime, - @JsonProperty("originalUrl") - @JsonAlias({"original_url", "url"}) String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, MlInfo ml, - @JsonAlias({"externalApi", "safe_browsing"}) ExternalApiInfo externalApi, - @JsonProperty("report_count") Integer reportCount, - @JsonProperty("block_count") Integer blockCount, - @JsonProperty("domain_age") String domainAge, - - RedirectInfo redirect, ServerInfo serverInfo, Integer score, RiskLevel riskLevel ) { + // 🚨 [무식하지만 100% 확실한 방법] Jackson 멱살 잡고 매핑 강제 지시 + @JsonCreator + public AnalysisResponse( + @JsonProperty("analysisTime") @JsonAlias({"analysis_time"}) String analysisTime, + @JsonProperty("originalUrl") @JsonAlias({"original_url", "url"}) String originalUrl, + @JsonProperty("https") HttpsInfo https, + @JsonProperty("shortUrl") ShortUrlInfo shortUrl, + @JsonProperty("ml") MlInfo ml, + @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, + @JsonProperty("report_count") Integer reportCount, + @JsonProperty("block_count") Integer blockCount, + @JsonProperty("domain_age") String domainAge, + @JsonProperty("redirect") RedirectInfo redirect, + @JsonProperty("serverInfo") ServerInfo serverInfo, + @JsonProperty("score") Integer score, + @JsonProperty("riskLevel") RiskLevel riskLevel + ) { + this.analysisTime = analysisTime; + this.originalUrl = originalUrl; + this.https = https; + this.shortUrl = shortUrl; + this.ml = ml; + this.externalApi = externalApi; + this.reportCount = reportCount; + this.blockCount = blockCount; + this.domainAge = domainAge; + this.redirect = redirect; + this.serverInfo = serverInfo; + this.score = score; + this.riskLevel = riskLevel; + } + public record HttpsInfo( boolean isSecure ) {} @@ -42,7 +67,6 @@ public record ShortUrlInfo( public record MlInfo( @Schema( - // 1. description에 카테고리별로 예쁘게 정리해서 넣어줍니다. description = "### 탐지 위협 카테고리 상세\n" + "**1. 리다이렉트 관련**\n" + "- `too_many_redirects`, `loop_detected`, `invalid_location`\n\n" + @@ -52,8 +76,6 @@ public record MlInfo( "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`\n\n" + "**4. 기타 탐지**\n" + "- `suspicious_param_detected`, `suspicious_keyword_detected`", - - // 2. 실제 값 리스트는 가흔님 가이드대로 플래그들만 나열합니다. allowableValues = { "too_many_redirects", "loop_detected", "invalid_location", "shortened_url", "embedded_url", "percent_encoding_detected", "double_encoding_suspected", @@ -70,11 +92,9 @@ public record MlInfo( public record ExternalApiInfo( boolean checked, String provider, - String result // 실패/결과 없을 시 null 대응을 위해 String 유지 + String result ) {} - - public record RedirectInfo( String finalUrl, Integer redirectCount @@ -89,8 +109,6 @@ public record ServerInfo( public record CertificateInfo( boolean valid, String issuer, - // 🚨 [여기가 핵심 수정 포인트!] - // 파이썬의 "Mar 10 00:00:00 2026 GMT" 형식을 읽기 위한 포맷입니다. String validFrom, String validTo ) {} From cd331afb7df25f7b924155439f2ad3ca7d75bb09 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 04:09:44 +0900 Subject: [PATCH 117/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/MlRequestService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index 5b41ebc..ae3aa73 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -32,7 +33,7 @@ public void sendToPythonServer(String guestUuid, String url) { // 2. 바디 세팅 (파이썬 서버가 요구하는 JSON 규격에 맞춤) 원본 url들어감 Map body = new HashMap<>(); - body.put("url", url);//url:"https://naver.com" 이 json형태로 들어감 + body.put("original_url", url);//url:"https://naver.com" 이 json형태로 들어감 body.put("guestUuid", guestUuid); // 3. 엔티티 조립 바디랑 헤더 조립 HttpEntity> requestEntity = new HttpEntity<>(body, headers); From bad566829fcb70141640f338da3d22975727053f Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 30 Apr 2026 04:20:10 +0900 Subject: [PATCH 118/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/MlRequestService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java index ae3aa73..2d36d5d 100644 --- a/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -33,7 +33,7 @@ public void sendToPythonServer(String guestUuid, String url) { // 2. 바디 세팅 (파이썬 서버가 요구하는 JSON 규격에 맞춤) 원본 url들어감 Map body = new HashMap<>(); - body.put("original_url", url);//url:"https://naver.com" 이 json형태로 들어감 + body.put("url", url);//url:"https://naver.com" 이 json형태로 들어감 body.put("guestUuid", guestUuid); // 3. 엔티티 조립 바디랑 헤더 조립 HttpEntity> requestEntity = new HttpEntity<>(body, headers); From 0bf9078345f5104660d1e19cf4ce0a3534e96b40 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 3 May 2026 09:47:43 +0900 Subject: [PATCH 119/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 ++ src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 409074b..e1eb7f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,5 +88,7 @@ services: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} BE3_CALLBACK_FINAL_URL: "http://veriq-be3:8083/api/v1/scan/callback" BE3_CALLBACK_PROGRESS_URL: "http://veriq-be3:8083/api/v1/scan/callback/progress" + OTX_API_KEY: ${OTX_API_KEY} + GOOGLE_SAFE_BROWSING_API_KEY: ${GOOGLE_SAFE_BROWSING_API_KEY} depends_on: - veriq-db diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 3999a9a..e9fbfa9 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -34,9 +34,9 @@ public AnalysisResponse( @JsonProperty("shortUrl") ShortUrlInfo shortUrl, @JsonProperty("ml") MlInfo ml, @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, - @JsonProperty("report_count") Integer reportCount, - @JsonProperty("block_count") Integer blockCount, - @JsonProperty("domain_age") String domainAge, + @JsonProperty("report_count")@JsonAlias({"report_count"}) Integer reportCount, + @JsonProperty("block_count") @JsonAlias({"block_count"}) Integer blockCount, + @JsonProperty("domain_age") @JsonAlias({"domain_age"})String domainAge, @JsonProperty("redirect") RedirectInfo redirect, @JsonProperty("serverInfo") ServerInfo serverInfo, @JsonProperty("score") Integer score, From b9a0af565769a0494190562b3b1bb975d5beae7a Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 3 May 2026 13:27:40 +0900 Subject: [PATCH 120/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/config/RestTemplateConfig.java | 2 +- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java index 04ae6c7..92883cc 100644 --- a/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java +++ b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java @@ -14,7 +14,7 @@ public org.springframework.web.client.RestTemplate restTemplate() { // 2. 10초 시한폭탄 장착! factory.setConnectTimeout(Duration.ofSeconds(10)); // 연결 시도 10초 제한 - factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 대기 10초 제한 + factory.setReadTimeout(Duration.ofSeconds(30)); // 응답 대기 30초 제한 // 3. 설정된 공장을 RestTemplate에 넣어서 완성 return new org.springframework.web.client.RestTemplate(factory); diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index e9fbfa9..29482da 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -34,9 +34,9 @@ public AnalysisResponse( @JsonProperty("shortUrl") ShortUrlInfo shortUrl, @JsonProperty("ml") MlInfo ml, @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, - @JsonProperty("report_count")@JsonAlias({"report_count"}) Integer reportCount, - @JsonProperty("block_count") @JsonAlias({"block_count"}) Integer blockCount, - @JsonProperty("domain_age") @JsonAlias({"domain_age"})String domainAge, + @JsonProperty("reportCount") @JsonAlias({"report_count"}) Integer reportCount, + @JsonProperty("blockCount") @JsonAlias({"block_count"}) Integer blockCount, + @JsonProperty("domainAge") @JsonAlias({"domain_age"}) String domainAge, @JsonProperty("redirect") RedirectInfo redirect, @JsonProperty("serverInfo") ServerInfo serverInfo, @JsonProperty("score") Integer score, From f3a9154dda952952e69413a2c457b04de1852442 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 3 May 2026 17:04:06 +0900 Subject: [PATCH 121/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=954?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index fc7172d..9b765a2 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -79,6 +79,7 @@ public Object processWithRedis(MultipartFile image, String guestUuid) throws Exc .status("COMPLETED") .isUrl(false) .message(decodingMsg) + .schemeType(result.type() != null ? result.type() : SchemeType.OTHER) .build(); } From 064ca7b00f38ffc8eb288eebdc7fbeefa3889d4d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Mon, 4 May 2026 09:22:53 +0900 Subject: [PATCH 122/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=955?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqgateway/config/RestTemplateConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java index 4d0761b..e50b3f3 100644 --- a/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java +++ b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java @@ -14,7 +14,7 @@ public RestTemplate restTemplate() { // 2. 10초 시한폭탄 장착! factory.setConnectTimeout(Duration.ofSeconds(10)); // 연결 시도 10초 제한 - factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 대기 10초 제한 + factory.setReadTimeout(Duration.ofSeconds(40)); // 응답 대기 10초 제한 // 3. 설정된 공장을 RestTemplate에 넣어서 완성 return new RestTemplate(factory); From c98755e4b58bdcab77d15c26d1ffde9f5843dac0 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Mon, 4 May 2026 09:31:56 +0900 Subject: [PATCH 123/136] =?UTF-8?q?fix-=EB=B6=84=EC=84=9D=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=AA=BD=20dto=EC=88=98=EC=A0=956(=EC=BA=A1=EC=B0=A8?= =?UTF-8?q?=ED=9A=9F=EC=88=98=20=EC=A6=9D=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqgateway/service/SecurityService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index e9aa3c4..74c903b 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -19,7 +19,7 @@ public class SecurityService { private final StringRedisTemplate redisTemplate; // 사용자(Guest)에게 허용할 최대 스캔 횟수 - private static final int LIMIT = 10; + private static final int LIMIT = 50; private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") private String secretKey; From 509b70a4be11a3be5fd8647677aa783e65bbbe6d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 7 May 2026 09:50:34 +0900 Subject: [PATCH 124/136] =?UTF-8?q?fix-=20=EC=BA=A1=EC=B0=A8=EC=88=98?= =?UTF-8?q?=EC=A0=95(=EC=BA=A1=EC=B0=A8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20X=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqgateway/controller/GatewayController.java | 1 + .../java/com/veriq/veriqgateway/service/SecurityService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 7896e82..0064f53 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -186,6 +186,7 @@ public ResponseEntity processScan( .body(ScanResponse.builder() .guestUuid(guestUuid) .status("REJECTED") + .typeInfo("REQUIRE_CAPTCHA") .build()); } diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index 74c903b..c3cc4d8 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -19,7 +19,7 @@ public class SecurityService { private final StringRedisTemplate redisTemplate; // 사용자(Guest)에게 허용할 최대 스캔 횟수 - private static final int LIMIT = 50; + private static final int LIMIT = 7; private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") private String secretKey; From 24b7d07ac36179ef1c0d397f07e0afff6afcbb8c Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Thu, 7 May 2026 09:50:34 +0900 Subject: [PATCH 125/136] =?UTF-8?q?fix-=20=EC=BA=A1=EC=B0=A8=EC=88=98?= =?UTF-8?q?=EC=A0=95(=EC=BA=A1=EC=B0=A8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20X=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/veriq/veriqgateway/controller/GatewayController.java | 1 + .../java/com/veriq/veriqgateway/service/SecurityService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java index 7896e82..0064f53 100644 --- a/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -186,6 +186,7 @@ public ResponseEntity processScan( .body(ScanResponse.builder() .guestUuid(guestUuid) .status("REJECTED") + .typeInfo("REQUIRE_CAPTCHA") .build()); } diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index 74c903b..c3cc4d8 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -19,7 +19,7 @@ public class SecurityService { private final StringRedisTemplate redisTemplate; // 사용자(Guest)에게 허용할 최대 스캔 횟수 - private static final int LIMIT = 50; + private static final int LIMIT = 7; private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") private String secretKey; From 46784249bf8508ad550ccba94ce54355e08dbf89 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 8 May 2026 09:36:47 +0900 Subject: [PATCH 126/136] =?UTF-8?q?fix-=20=EC=BA=A1=EC=B0=A8=EC=88=98?= =?UTF-8?q?=EC=A0=95(50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqgateway/service/SecurityService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java index c3cc4d8..74c903b 100644 --- a/src/main/java/com/veriq/veriqgateway/service/SecurityService.java +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -19,7 +19,7 @@ public class SecurityService { private final StringRedisTemplate redisTemplate; // 사용자(Guest)에게 허용할 최대 스캔 횟수 - private static final int LIMIT = 7; + private static final int LIMIT = 50; private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") private String secretKey; From 329dbf7a4b4c990269bf1dfa43a6306bbeb9d75f Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 8 May 2026 11:21:47 +0900 Subject: [PATCH 127/136] =?UTF-8?q?fix-=20finalurl(=EC=98=A4=EB=A5=98)?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ScanHistoryRepository.java | 1 + .../veriqbe3/service/QrScanRedisService.java | 35 ++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java b/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java index cfe6e07..473e625 100644 --- a/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java +++ b/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java @@ -8,5 +8,6 @@ @Repository public interface ScanHistoryRepository extends JpaRepository { Optional findFirstByOriginalUrlOrderByScannedAtDesc(String originalUrl); + Optional findFirstByOriginalUrlOrRedirectFinalUrlOrderByScannedAtDesc(String originalUrl, String finalUrl); } \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 9b765a2..aeb5dec 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -247,7 +247,7 @@ public AnalysisResponse getUrlDetail(String url) throws Exception { } // Redis에 없으면 DB에서 조회 //스캔한 내역중 최신 내역을 db에서 뽑아옴 - Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrderByScannedAtDesc(url); + Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrRedirectFinalUrlOrderByScannedAtDesc(url,url); if (dbHistory.isEmpty()) { return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL } @@ -307,18 +307,31 @@ private boolean isStale(ScanHistory history) { /** */ - public void cacheAnalysisResult(String url, AnalysisResponse resultDto,Duration ttl) { + public void cacheAnalysisResult(AnalysisResponse resultDto, Duration ttl) { try { - // 1. 상세 리포트 캐싱 (AnalysisResponse 객체를 통째로 JSON으로 변환하여 저장) - String urlKey = buildUrlCacheKey(url); + String originalUrl = resultDto.originalUrl(); + String finalUrl = resultDto.redirect() != null ? resultDto.redirect().finalUrl() : null; + + // 저장할 데이터는 동일하므로 한 번만 JSON으로 변환 String jsonCache = objectMapper.writeValueAsString(resultDto); - // 🚨 핵심: 고정된 URL_CACHE_TTL 대신, 밖에서 받아온 동적 ttl을 꽂아줍니다! - redisTemplate.opsForValue().set(urlKey, jsonCache, ttl); - log.info("URL 상세 분석 결과 캐싱 완료: {} (적용된 TTL: {}일)", url, ttl.toDays()); + // 1. 원본 URL로 캐싱 + if (originalUrl != null && !originalUrl.isBlank()) { + String originalKey = buildUrlCacheKey(originalUrl); + redisTemplate.opsForValue().set(originalKey, jsonCache, ttl); + } + + // 2. 최종 URL로도 캐싱 (단, 원본 URL과 다를 경우에만!) + if (finalUrl != null && !finalUrl.isBlank() && !finalUrl.equals(originalUrl)) { + String finalKey = buildUrlCacheKey(finalUrl); + redisTemplate.opsForValue().set(finalKey, jsonCache, ttl); + } + + log.info("URL 상세 분석 결과 캐싱 완료 (다중 키 적용) - 원본: {}, 최종: {} (TTL: {}일)", + originalUrl, finalUrl, ttl.toDays()); } catch (Exception e) { - log.error("Redis 캐싱 실패 - URL: {}", url, e); + log.error("Redis 캐싱 실패", e); } } @@ -379,6 +392,10 @@ private String buildUrlCacheKey(String rawUrl) { // 1. 정규화: 앞뒤 공백 제거 String cleanUrl = rawUrl.trim(); + // 프론트가 "https://yes24.com/" 라고 보내든 "https://yes24.com" 이라고 보내든 똑같이 취급합니다. + if (cleanUrl.endsWith("/")) { + cleanUrl = cleanUrl.substring(0, cleanUrl.length() - 1); + } // 2. Fragment(#)만 제거하고, QueryString(?)은 반드시 보존! // 브라우저 내부용인 # 뒤는 잘라도 되지만, ? 뒤는 데이터이므로 남깁니다. @@ -533,7 +550,7 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues // 3. 차등 TTL 적용하여 Redis 캐시 워밍 (수정된 부분!) // ========================================== Duration dynamicTtl = calculateDynamicTtl(responseDto.riskLevel()); - cacheAnalysisResult(url, responseDto, dynamicTtl); + cacheAnalysisResult( responseDto, dynamicTtl); // 분석 완료 후 유저 개인의 Redis 히스토리 리스트에도 추가! saveHistoryToRedis(guestUuid, responseDto); From 76341b308121dd7f1e4b3c73626fa087bff215d6 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sat, 9 May 2026 23:54:28 +0900 Subject: [PATCH 128/136] =?UTF-8?q?fix-=20finalurl(=EC=98=A4=EB=A5=98)?= =?UTF-8?q?=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 4 +++ .../veriq/veriqbe3/entity/ScanHistory.java | 4 +++ .../veriqbe3/service/QrScanRedisService.java | 30 ++++++++++++++----- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 29482da..1ca0903 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -15,6 +15,7 @@ public record AnalysisResponse( String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, + List threats, MlInfo ml, ExternalApiInfo externalApi, Integer reportCount, @@ -32,6 +33,7 @@ public AnalysisResponse( @JsonProperty("originalUrl") @JsonAlias({"original_url", "url"}) String originalUrl, @JsonProperty("https") HttpsInfo https, @JsonProperty("shortUrl") ShortUrlInfo shortUrl, + @JsonProperty("threats") List threats, @JsonProperty("ml") MlInfo ml, @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, @JsonProperty("reportCount") @JsonAlias({"report_count"}) Integer reportCount, @@ -46,6 +48,7 @@ public AnalysisResponse( this.originalUrl = originalUrl; this.https = https; this.shortUrl = shortUrl; + this.threats = threats; this.ml = ml; this.externalApi = externalApi; this.reportCount = reportCount; @@ -84,6 +87,7 @@ public record MlInfo( }, example = "['too_many_redirects', 'MALWARE']" ) + List threats, @JsonAlias({"mlScore", "score"}) Integer score diff --git a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java index 5c02067..6681df7 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -28,6 +28,9 @@ public class ScanHistory { private String originalUrl; private String typeInfo; + @Column(name = "final_threats") + private String threats; + private String domainAge; @Enumerated(EnumType.STRING) private SchemeType schemeType; @@ -59,6 +62,7 @@ public static class ShortUrlInfo { private boolean isShortened; } @Embeddable @Getter @Builder @NoArgsConstructor @AllArgsConstructor public static class MlInfo { + @Column(name = "ml_threats") private String threats; private Integer mlScore; } diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index aeb5dec..34c341c 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -412,10 +412,17 @@ private String buildUrlCacheKey(String rawUrl) { * DB 엔티티(ScanHistory)를 Redis용 캐시/응답 DTO(AnalysisResponse)로 변환하는 헬퍼 메서드 */ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { - // DB의 threats(String)를 List으로 변환 (콤마 분리 가정) - java.util.List threatsList = Collections.emptyList(); + + // 1. 최상단 위협 리스트 변환 (DB의 문자열 -> List) + java.util.List coreThreatsList = java.util.Collections.emptyList(); + if (entity.getThreats() != null && !entity.getThreats().isBlank()) { + coreThreatsList = java.util.Arrays.asList(entity.getThreats().split(",\\s*")); + } + + // 2. ML 위협 리스트 변환 (DB의 문자열 -> List) + java.util.List mlThreatsList = java.util.Collections.emptyList(); if (entity.getMl() != null && entity.getMl().getThreats() != null && !entity.getMl().getThreats().isBlank()) { - threatsList = Arrays.asList(entity.getMl().getThreats().split(",\\s*")); + mlThreatsList = java.util.Arrays.asList(entity.getMl().getThreats().split(",\\s*")); } return new AnalysisResponse( @@ -423,17 +430,26 @@ private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { entity.getOriginalUrl(), entity.getHttps() != null ? new AnalysisResponse.HttpsInfo(entity.getHttps().isSecure()) : null, entity.getShortUrl() != null ? new AnalysisResponse.ShortUrlInfo(entity.getShortUrl().isShortened()) : null, - entity.getMl() != null ? new AnalysisResponse.MlInfo(threatsList, entity.getMl().getMlScore()) : null, + + // 🚨 [추가됨] 새로 만든 최상단 위협 리스트를 5번째 파라미터로 쏙! + coreThreatsList, + + // 🚨 [수정됨] 기존 threatsList 대신 새로 정의한 mlThreatsList 사용 + entity.getMl() != null ? new AnalysisResponse.MlInfo(mlThreatsList, entity.getMl().getMlScore()) : null, + entity.getExternalApi() != null ? new AnalysisResponse.ExternalApiInfo( entity.getExternalApi().isApiChecked(), entity.getExternalApi().getApiProvider(), entity.getExternalApi().getApiResult() ) : null, - // ✅ 새로 들어갈 3줄 (reportCount, blockCount, domainAge) + + // ✅ dbReportCount, dbBlockCount entity.getInternalDb() != null ? entity.getInternalDb().getDbReportCount() : null, entity.getInternalDb() != null ? entity.getInternalDb().getDbBlockCount() : null, - null, // domainAge는 아직 DB에 없으므로 null 처리 - // ✅ + + // 🚨 [수정됨] 이제 DB에 domainAge가 있으므로 null 대신 entity.getDomainAge() 사용 + entity.getDomainAge(), + entity.getRedirect() != null ? new AnalysisResponse.RedirectInfo( entity.getRedirect().getFinalUrl(), entity.getRedirect().getRedirectCount() From f35897c6ff10d3307decea67f8a5d2f37b182ffb Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Sun, 10 May 2026 00:16:24 +0900 Subject: [PATCH 129/136] =?UTF-8?q?fix-=20finalurl(=EC=98=A4=EB=A5=98)?= =?UTF-8?q?=EC=88=98=EC=A0=953?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/service/QrScanRedisService.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java index 34c341c..78fea4a 100644 --- a/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -501,6 +501,13 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .totalScore(responseDto.score() != null ? responseDto.score() : 0) .riskLevel(responseDto.riskLevel() != null ? responseDto.riskLevel().name() : "SUSPICIOUS") + // 🚨 [범인 검거 1] 최상단 위협 리스트 저장 (이게 빠져있었습니다!) + .threats(responseDto.threats() != null && !responseDto.threats().isEmpty() + ? String.join(", ", responseDto.threats()) : null) + + // 🚨 [범인 검거 2] 도메인 연령 저장 (이것도 빠져있었습니다!) + .domainAge(responseDto.domainAge()) + // 1. HttpsInfo 빌드 .https(ScanHistory.HttpsInfo.builder() .isSecure(responseDto.https() != null && responseDto.https().isSecure()) @@ -533,7 +540,6 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues .dbBlockCount(responseDto.blockCount()) .build()) - // 6. RedirectInfo 빌드 .redirect(ScanHistory.RedirectInfo.builder() .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) @@ -563,18 +569,14 @@ public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String gues log.info("DB 저장 완료: {}", url); // ========================================== - // 3. 차등 TTL 적용하여 Redis 캐시 워밍 (수정된 부분!) + // 3. 차등 TTL 적용하여 Redis 캐시 워밍 // ========================================== Duration dynamicTtl = calculateDynamicTtl(responseDto.riskLevel()); - cacheAnalysisResult( responseDto, dynamicTtl); + cacheAnalysisResult(responseDto, dynamicTtl); // 분석 완료 후 유저 개인의 Redis 히스토리 리스트에도 추가! saveHistoryToRedis(guestUuid, responseDto); log.info("모든 저장 및 동적 캐싱 프로세스 완료"); - // 프론트엔드로 프로세스 완료 알림 진행... - - - } catch (Exception e) { log.error("DB 저장 및 캐싱 중 에러 발생: {}", responseDto.originalUrl(), e); From fd6d70331c1f5f0bd229dbc50626e3a1c93f4aeb Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 12 May 2026 23:33:02 +0900 Subject: [PATCH 130/136] =?UTF-8?q?fix-=20swagger=20threats=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 1ca0903..da3964b 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -15,7 +15,27 @@ public record AnalysisResponse( String originalUrl, HttpsInfo https, ShortUrlInfo shortUrl, + + // 🚨 [수정됨] GSB:, OTX: 접두사 제거 및 중복(MALWARE) 통합! + @Schema( + description = "### 🚨 탐지 위협 카테고리 상세\n" + + "**1. URL 구조 및 패턴**\n" + + "- `SHORTENED_URL`, `percent_encoding_detected`, `double_encoding_suspected`, `suspicious_query_param_detected`, `embedded_url`, `suspicious_query_keyword_detected`, `suspicious_path_keyword_detected`, `suspicious_fragment_keyword_detected`\n\n" + + "**2. 외부 위협 인텔리전스 (GSB, OTX 통합)**\n" + + "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`, `PHISHING`, `RANSOMWARE`, `BOTNET`, `SPAM`, `C2`, `SUSPICIOUS`\n\n" + + "**3. 인증서 및 연결 오류**\n" + + "- `hostname_missing`, `certificate_request_timeout`, `invalid certificate response`, `peer certificate not available`", + allowableValues = { + "SHORTENED_URL", "percent_encoding_detected", "double_encoding_suspected", "suspicious_query_param_detected", + "embedded_url", "suspicious_query_keyword_detected", "suspicious_path_keyword_detected", "suspicious_fragment_keyword_detected", + "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION", + "PHISHING", "RANSOMWARE", "BOTNET", "SPAM", "C2", "SUSPICIOUS", + "hostname_missing", "certificate_request_timeout", "invalid certificate response", "peer certificate not available" + }, + example = "['PHISHING', 'SHORTENED_URL']" + ) List threats, + MlInfo ml, ExternalApiInfo externalApi, Integer reportCount, @@ -26,7 +46,6 @@ public record AnalysisResponse( Integer score, RiskLevel riskLevel ) { - // 🚨 [무식하지만 100% 확실한 방법] Jackson 멱살 잡고 매핑 강제 지시 @JsonCreator public AnalysisResponse( @JsonProperty("analysisTime") @JsonAlias({"analysis_time"}) String analysisTime, @@ -60,35 +79,14 @@ public AnalysisResponse( this.riskLevel = riskLevel; } - public record HttpsInfo( - boolean isSecure - ) {} - - public record ShortUrlInfo( - boolean isShortened - ) {} + public record HttpsInfo(boolean isSecure) {} + public record ShortUrlInfo(boolean isShortened) {} public record MlInfo( - @Schema( - description = "### 탐지 위협 카테고리 상세\n" + - "**1. 리다이렉트 관련**\n" + - "- `too_many_redirects`, `loop_detected`, `invalid_location`\n\n" + - "**2. URL 구조 관련**\n" + - "- `shortened_url`, `embedded_url`, `percent_encoding_detected`, `double_encoding_suspected`\n\n" + - "**3. 구글 세이프 브라우징(GSB)**\n" + - "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`\n\n" + - "**4. 기타 탐지**\n" + - "- `suspicious_param_detected`, `suspicious_keyword_detected`", - allowableValues = { - "too_many_redirects", "loop_detected", "invalid_location", - "shortened_url", "embedded_url", "percent_encoding_detected", "double_encoding_suspected", - "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION", - "suspicious_param_detected", "suspicious_keyword_detected" - }, - example = "['too_many_redirects', 'MALWARE']" - ) - + @Schema(hidden = true) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) List threats, + @JsonAlias({"mlScore", "score"}) Integer score ) {} @@ -96,11 +94,22 @@ public record MlInfo( public record ExternalApiInfo( boolean checked, String provider, + + @Schema( + description = "외부 API 통합 검사 결과", + allowableValues = { + "THREAT", "SAFE", "UNKNOWN", + "GSB_FAILED", "OTX_FAILED", "WHOIS_FAILED", "GSB_OTX_WHOIS_FAILED" + } + ) String result ) {} public record RedirectInfo( String finalUrl, + + @Schema(hidden = true) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) Integer redirectCount ) {} From 8b12781af5642e309e4f53b8db54063c3de27a5d Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Tue, 12 May 2026 23:46:59 +0900 Subject: [PATCH 131/136] =?UTF-8?q?fix-=20swagger=20threats=EB=B3=80?= =?UTF-8?q?=EA=B2=BD2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index da3964b..ae2074d 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -36,6 +36,8 @@ public record AnalysisResponse( ) List threats, + @Schema(hidden = true) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) MlInfo ml, ExternalApiInfo externalApi, Integer reportCount, From 51c318a2b78efb718e96a4b59da222491bc0cdf3 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 22 May 2026 10:02:53 +0900 Subject: [PATCH 132/136] =?UTF-8?q?fix-=20swagger=20threats=EB=B3=80?= =?UTF-8?q?=EA=B2=BD3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index ae2074d..ebc13c8 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -24,15 +24,18 @@ public record AnalysisResponse( "**2. 외부 위협 인텔리전스 (GSB, OTX 통합)**\n" + "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`, `PHISHING`, `RANSOMWARE`, `BOTNET`, `SPAM`, `C2`, `SUSPICIOUS`\n\n" + "**3. 인증서 및 연결 오류**\n" + - "- `hostname_missing`, `certificate_request_timeout`, `invalid certificate response`, `peer certificate not available`", + "- `CERT_SELF_SIGNED`, `CERT_UNTRUSTED`, `CERT_EXPIRED`, `CERT_HOSTNAME_MISMATCH`, `CERT_NOT_YET_VALID`, `CERT_REVOKED`, `CERT_SSL_ERROR`, `CERT_INVALID_HOST`, `CERT_CONNECTION_FAILED`, `CERT_LOOKUP_FAILED`, `CERT_TIMEOUT`, `CERT_NO_CERTIFICATE`, `CERT_UNKNOWN_ERROR`", allowableValues = { "SHORTENED_URL", "percent_encoding_detected", "double_encoding_suspected", "suspicious_query_param_detected", "embedded_url", "suspicious_query_keyword_detected", "suspicious_path_keyword_detected", "suspicious_fragment_keyword_detected", "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION", "PHISHING", "RANSOMWARE", "BOTNET", "SPAM", "C2", "SUSPICIOUS", - "hostname_missing", "certificate_request_timeout", "invalid certificate response", "peer certificate not available" + // 👇 기존 소문자 및 원문 에러를 지우고 새로운 규격 상수 추가! + "CERT_SELF_SIGNED", "CERT_UNTRUSTED", "CERT_EXPIRED", "CERT_HOSTNAME_MISMATCH", + "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_SSL_ERROR", "CERT_INVALID_HOST", + "CERT_CONNECTION_FAILED", "CERT_LOOKUP_FAILED", "CERT_TIMEOUT", "CERT_NO_CERTIFICATE", "CERT_UNKNOWN_ERROR" }, - example = "['PHISHING', 'SHORTENED_URL']" + example = "['PHISHING', 'CERT_SELF_SIGNED']" ) List threats, From bde301dfe663de88a5f73d53d0230e1508f56370 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 22 May 2026 11:15:04 +0900 Subject: [PATCH 133/136] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=EC=97=90=20=EB=94=B0=EB=9D=BC=20/te?= =?UTF-8?q?xt=20request=EB=A5=BC=20=EA=B8=B0=EC=A1=B4=20post=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=97=90=EC=84=9C=20get=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriqbe3/controller/QrScanController.java | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index 09aac47..524f8eb 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -103,13 +103,15 @@ public ResponseEntity uploadQrImage( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - @Operation( summary = "URL 텍스트 직접 입력 분석 시작", description = """ ### 텍스트 기반 URL 분석 요청 QR 이미지 대신, 사용자가 텍스트 창에 직접 입력하거나 복사해 넣은 URL을 검사합니다. - 동작 방식과 응답 포맷(status)은 기존 `/upload` API와 **100% 동일**합니다. + + **[요청 방법]** + - `url`: 검사할 문자열을 쿼리 파라미터로 전송합니다. (예: `?url=https://...`) + - `guest_uuid`: 헤더에 반드시 포함해야 합니다. **[참고사항]** - 프론트엔드는 이 API를 호출하기 전 반드시 `/subscribe`로 SSE 파이프를 먼저 연결해야 합니다. @@ -121,17 +123,16 @@ public ResponseEntity uploadQrImage( @ApiResponse(responseCode = "400", description = "입력값이 비어있거나 잘못된 요청 형식"), @ApiResponse(responseCode = "500", description = "서버 내부 에러 발생") }) - @PostMapping(value = "/text", consumes = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/text") public ResponseEntity uploadTextUrl( @RequestHeader(value = "guest_uuid") String guestUuid, - @Valid @RequestBody TextInputRequest request) { + @RequestParam("url") String url) { // 🚨 Body 대신 RequestParam(쿼리 파라미터)으로 받습니다. try { - log.info("텍스트 직접 입력 분석 요청 - URL: {}, guestUuid: {}", request.url(), guestUuid); + log.info("텍스트 직접 입력 분석 요청 (GET) - URL: {}, guestUuid: {}", url, guestUuid); - // 🚨 QR 디코더를 거치지 않고 바로 문자열을 서비스로 넘깁니다. (서비스 계층에 해당 메서드 추가 필요) - Object response = qrScanRedisService.processTextWithRedis(request.url(), guestUuid); + // 🚨 request.url() 대신 파라미터로 받은 url을 바로 넘깁니다. + Object response = qrScanRedisService.processTextWithRedis(url, guestUuid); - // 로직은 기존 QR 업로드와 완벽히 동일하게 처리 if (response instanceof AnalysisResponse) { log.info("✅ 신선한 데이터 적중! 분석 결과를 즉시 반환합니다."); return ResponseEntity.ok(response); @@ -148,15 +149,6 @@ public ResponseEntity uploadTextUrl( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - - public record TextInputRequest( - @Schema( - description = "검사할 URL 또는 딥링크 텍스트", - example = "https://www.google.com", - requiredMode = Schema.RequiredMode.REQUIRED - ) - String url - ) {} // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 @Operation( summary = "분석 결과 상세 조회", From 0323d96ba5348c257c1d4d5631fa9741246fb996 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 29 May 2026 10:12:12 +0900 Subject: [PATCH 134/136] =?UTF-8?q?1.=20=EB=8B=A8=EC=B6=95=20url=20?= =?UTF-8?q?=EB=92=A4=EC=97=90=20=ED=8F=AC=ED=8A=B8=20=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B2=BD=EC=9A=B0=EB=8F=84=20=ED=83=90?= =?UTF-8?q?=EC=A7=80=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4=EC=99=84=202.?= =?UTF-8?q?=20/=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EB=8F=84=20=ED=83=90=EC=A7=80=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD([/=3F?= =?UTF-8?q?#.*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../veriq/veriqbe3/service/SchemeClassifier.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java index c687efb..e8bdceb 100644 --- a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java +++ b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java @@ -16,12 +16,17 @@ public class SchemeClassifier { Pattern.CASE_INSENSITIVE ); - // 단축 URL 확인을 위한 패턴 (정확히 호스트명에 해당하는지 검사하기 위함) private static final List SHORTENER_PATTERNS = List.of( - Pattern.compile("^(https?://)?(www\\.)?bit\\.ly(/.*)?$", Pattern.CASE_INSENSITIVE), - Pattern.compile("^(https?://)?(www\\.)?t\\.co(/.*)?$", Pattern.CASE_INSENSITIVE), - Pattern.compile("^(https?://)?(www\\.)?goo\\.gl(/.*)?$", Pattern.CASE_INSENSITIVE), - Pattern.compile("^(https?://)?(www\\.)?tinyurl\\.com(/.*)?$", Pattern.CASE_INSENSITIVE) + Pattern.compile("^(https?://)?(www\\.)?bit\\.ly(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?t\\.co(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?goo\\.gl(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?tinyurl\\.com(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?is\\.gd(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?ow\\.ly(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?c11\\.kr(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?vvd\\.bz(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?url\\.kr(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE), + Pattern.compile("^(https?://)?(www\\.)?vo\\.la(:\\d+)?([/?#].*)?$", Pattern.CASE_INSENSITIVE) ); // 2. 2차 인증 (OTP) From a0623be5258a37b53cf6fbfea86412c7548ba789 Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Fri, 29 May 2026 11:11:25 +0900 Subject: [PATCH 135/136] fix-final3 --- .../veriq/veriqbe3/dto/AnalysisResponse.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index ebc13c8..2a976b6 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -18,24 +18,33 @@ public record AnalysisResponse( // 🚨 [수정됨] GSB:, OTX: 접두사 제거 및 중복(MALWARE) 통합! @Schema( - description = "### 🚨 탐지 위협 카테고리 상세\n" + + description = "### 🚨 탐지 위협 및 내부 분석 에러 카테고리 상세\n" + "**1. URL 구조 및 패턴**\n" + "- `SHORTENED_URL`, `percent_encoding_detected`, `double_encoding_suspected`, `suspicious_query_param_detected`, `embedded_url`, `suspicious_query_keyword_detected`, `suspicious_path_keyword_detected`, `suspicious_fragment_keyword_detected`\n\n" + "**2. 외부 위협 인텔리전스 (GSB, OTX 통합)**\n" + "- `MALWARE`, `SOCIAL_ENGINEERING`, `UNWANTED_SOFTWARE`, `POTENTIALLY_HARMFUL_APPLICATION`, `PHISHING`, `RANSOMWARE`, `BOTNET`, `SPAM`, `C2`, `SUSPICIOUS`\n\n" + "**3. 인증서 및 연결 오류**\n" + - "- `CERT_SELF_SIGNED`, `CERT_UNTRUSTED`, `CERT_EXPIRED`, `CERT_HOSTNAME_MISMATCH`, `CERT_NOT_YET_VALID`, `CERT_REVOKED`, `CERT_SSL_ERROR`, `CERT_INVALID_HOST`, `CERT_CONNECTION_FAILED`, `CERT_LOOKUP_FAILED`, `CERT_TIMEOUT`, `CERT_NO_CERTIFICATE`, `CERT_UNKNOWN_ERROR`", + "- `CERT_SELF_SIGNED`, `CERT_UNTRUSTED`, `CERT_EXPIRED`, `CERT_HOSTNAME_MISMATCH`, `CERT_NOT_YET_VALID`, `CERT_REVOKED`, `CERT_SSL_ERROR`, `CERT_INVALID_HOST`, `CERT_CONNECTION_FAILED`, `CERT_LOOKUP_FAILED`, `CERT_TIMEOUT`, `CERT_NO_CERTIFICATE`, `CERT_UNKNOWN_ERROR`\n\n" + + "**4. 분석 엔진 모듈별 실패 플래그 (Failure Flags)**\n" + + "- **외부 API 조회 실패**: `GSB_FAILED`, `OTX_FAILED`, `WHOIS_FAILED`\n" + + "- **리다이렉트 추적 실패**: `REDIRECT_FAILED`, `REDIRECT_REQUEST_FAILED`, `REDIRECT_CLIENT_ERROR`, `REDIRECT_LOOP_DETECTED`, `REDIRECT_TOO_MANY_REDIRECTS`, `REDIRECT_INVALID_LOCATION`\n" + + "- **인프라 및 분석 모델 실패**: `SERVER_INFO_FAILED`, `CERTIFICATE_FAILED`, `CHARCNN_FAILED`, `XGB_FAILED`, `ML_FAILED`, `SCORING_FAILED`", allowableValues = { "SHORTENED_URL", "percent_encoding_detected", "double_encoding_suspected", "suspicious_query_param_detected", "embedded_url", "suspicious_query_keyword_detected", "suspicious_path_keyword_detected", "suspicious_fragment_keyword_detected", "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION", "PHISHING", "RANSOMWARE", "BOTNET", "SPAM", "C2", "SUSPICIOUS", - // 👇 기존 소문자 및 원문 에러를 지우고 새로운 규격 상수 추가! "CERT_SELF_SIGNED", "CERT_UNTRUSTED", "CERT_EXPIRED", "CERT_HOSTNAME_MISMATCH", "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_SSL_ERROR", "CERT_INVALID_HOST", - "CERT_CONNECTION_FAILED", "CERT_LOOKUP_FAILED", "CERT_TIMEOUT", "CERT_NO_CERTIFICATE", "CERT_UNKNOWN_ERROR" + "CERT_CONNECTION_FAILED", "CERT_LOOKUP_FAILED", "CERT_TIMEOUT", "CERT_NO_CERTIFICATE", "CERT_UNKNOWN_ERROR", + "GSB_FAILED", "OTX_FAILED", "WHOIS_FAILED", + "REDIRECT_FAILED", "REDIRECT_REQUEST_FAILED", "REDIRECT_CLIENT_ERROR", + "REDIRECT_LOOP_DETECTED", "REDIRECT_TOO_MANY_REDIRECTS", "REDIRECT_INVALID_LOCATION", + "SERVER_INFO_FAILED", "CERTIFICATE_FAILED", + "CHARCNN_FAILED", "XGB_FAILED", "ML_FAILED", + "SCORING_FAILED" }, - example = "['PHISHING', 'CERT_SELF_SIGNED']" + example = "['PHISHING', 'XGB_FAILED']" ) List threats, @@ -60,7 +69,7 @@ public AnalysisResponse( @JsonProperty("threats") List threats, @JsonProperty("ml") MlInfo ml, @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, - @JsonProperty("reportCount") @JsonAlias({"report_count"}) Integer reportCount, + @JsonProperty("reportCount") @JsonAlias({"report_count","pulse_count"}) Integer reportCount, @JsonProperty("blockCount") @JsonAlias({"block_count"}) Integer blockCount, @JsonProperty("domainAge") @JsonAlias({"domain_age"}) String domainAge, @JsonProperty("redirect") RedirectInfo redirect, From 361d22aa2d25dda839c00dab6175c61c4c86338c Mon Sep 17 00:00:00 2001 From: sangmin1571 Date: Wed, 3 Jun 2026 14:56:05 +0900 Subject: [PATCH 136/136] =?UTF-8?q?fix-threat=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/veriq/veriqbe3/dto/AnalysisResponse.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java index 2a976b6..50a273b 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -28,7 +28,10 @@ public record AnalysisResponse( "**4. 분석 엔진 모듈별 실패 플래그 (Failure Flags)**\n" + "- **외부 API 조회 실패**: `GSB_FAILED`, `OTX_FAILED`, `WHOIS_FAILED`\n" + "- **리다이렉트 추적 실패**: `REDIRECT_FAILED`, `REDIRECT_REQUEST_FAILED`, `REDIRECT_CLIENT_ERROR`, `REDIRECT_LOOP_DETECTED`, `REDIRECT_TOO_MANY_REDIRECTS`, `REDIRECT_INVALID_LOCATION`\n" + - "- **인프라 및 분석 모델 실패**: `SERVER_INFO_FAILED`, `CERTIFICATE_FAILED`, `CHARCNN_FAILED`, `XGB_FAILED`, `ML_FAILED`, `SCORING_FAILED`", + "- **인프라 및 분석 모델 실패**: `SERVER_INFO_FAILED`, `CERTIFICATE_FAILED`, `CHARCNN_FAILED`, `XGB_FAILED`, `ML_FAILED`, `SCORING_FAILED`\n\n" + + "**5. 내부 인공지능 모델별 탐지 세부 플래그 (AI Detection Flags) 🚨 [신설됨]**\n" + + "- **XGBoost 머신러닝 엔진**: `XGB_SUSPICIOUS_URL_FEATURES`, `XGB_HIGH_RISK_URL`\n" + + "- **CharCNN 딥러닝 엔진**: `CHARCNN_SUSPICIOUS_URL_PATTERN`", allowableValues = { "SHORTENED_URL", "percent_encoding_detected", "double_encoding_suspected", "suspicious_query_param_detected", "embedded_url", "suspicious_query_keyword_detected", "suspicious_path_keyword_detected", "suspicious_fragment_keyword_detected", @@ -41,10 +44,10 @@ public record AnalysisResponse( "REDIRECT_FAILED", "REDIRECT_REQUEST_FAILED", "REDIRECT_CLIENT_ERROR", "REDIRECT_LOOP_DETECTED", "REDIRECT_TOO_MANY_REDIRECTS", "REDIRECT_INVALID_LOCATION", "SERVER_INFO_FAILED", "CERTIFICATE_FAILED", - "CHARCNN_FAILED", "XGB_FAILED", "ML_FAILED", - "SCORING_FAILED" + "CHARCNN_FAILED", "XGB_FAILED", "ML_FAILED", "SCORING_FAILED", + "XGB_SUSPICIOUS_URL_FEATURES", "XGB_HIGH_RISK_URL", "CHARCNN_SUSPICIOUS_URL_PATTERN" }, - example = "['PHISHING', 'XGB_FAILED']" + example = "['PHISHING', 'XGB_SUSPICIOUS_URL_FEATURES']" ) List threats,