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 new file mode 100644 index 0000000..dfbd72e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,28 @@ +name: Veri-Q Develop Deploy + +on: + push: + branches: [ "develop", "feat/KD-12" ] + +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 }} + command_timeout: 50m + script: | + cd /opt/main-server + git pull origin develop + chmod +x gradlew + sudo ./gradlew bootJar + sudo docker-compose build --no-cache + sudo docker-compose up --build -d + diff --git a/.gitignore b/.gitignore index 8d569d6..a987f30 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ out/ .gradle/ # 비밀번호가 담긴 설정 파일은 제외 src/main/resources/application.yml +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e96c29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# 1단계: 빌드 스테이지 +FROM gradle:jdk21 AS build +WORKDIR /app +COPY . . + +# 빌드 인자로 메인 클래스를 받습니다 (기본값은 Gateway) +ARG MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication + +# 지정된 메인 클래스로 빌드하도록 명령 +RUN gradle clean build -x test -PmainClass=${MAIN_CLASS} + +# 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/README.md b/README.md index bf728d4..f1b600d 100644 --- a/README.md +++ b/README.md @@ -1 +1,301 @@ -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` +> 모든 팀원은 로컬에서 테스트할 때 아래 설정값을 참조하여 접속 정보를 수정해 주세요. +> +> > 🚨 **중요: 보안을 위해 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 + + + be3: + api: + url: "http://localhost:8083/api/v1/scan/upload" +``` +`src/main/resources/application-be3.yml` 아래와 같이 수정합니다. +```yaml +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 + # MySQL 8.0 버전에 맞게 Dialect를 조금 더 구체적으로 지정 + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: veriq-redis + port: 6379 + +``` + +### **⚡FastAPI +--env파일을 다음과 같이 수정합니다 +```yaml +# Database 연결 정보 +DB_URL = "mysql+pymysql://root:[비밀번호]@34.64.218.236:3306/veriq_db" + +# Redis 연결 정보 +REDIS_HOST = "34.64.218.236" +REDIS_PORT = 6379 + +``` + + + + + + + +### **📦 인프라 및 배포 설정 파일 안내(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 --chown=appuser:appgroup ${JAR_FILE} /app/app.jar + +# 사용자 전환 및 실행 +USER appuser +ENTRYPOINT ["java", "-jar", "/app/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"] + +``` +### **🐳 docker-compose.yml(공통) (최상위 경로) +```yaml +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "127.0.0.1:3306:3306" # 외부 IP 접속 차단, 로컬 호스트만 허용 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: veriq_db + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "127.0.0.1:6379:6379" + + # 3. Spring BE1 (Gateway) + veriq-gateway: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + 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: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be3 + restart: always + ports: + - "8083:8083" + environment: + SPRING_PROFILES_ACTIVE: be3 + depends_on: + - veriq-db + - veriq-redis + + # 5. FastAPI (분석 엔진) + veriq-analysis: + build: + context: ../analysis-engine + dockerfile: Dockerfile + container_name: veriq-analysis + restart: always + ports: + - "8000:8000" + depends_on: + - 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 + +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 + + + +``` + + + diff --git a/build.gradle b/build.gradle index 794f42a..aafbe6a 100644 --- a/build.gradle +++ b/build.gradle @@ -36,8 +36,29 @@ 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' + 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.8.9' } tasks.named('test') { useJUnitPlatform() } +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 new file mode 100644 index 0000000..e1eb7f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +version: '3.8' + +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "127.0.0.1:3306:3306" + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: veriq_db + TZ: Asia/Seoul + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "127.0.0.1:6379:6379" + environment: + TZ: Asia/Seoul # 👈 Redis 타임존 설정 + + # 3. Spring BE1 (Gateway) + veriq-gateway: + build: + context: . + dockerfile: Dockerfile + args: + - MAIN_CLASS=com.veriq.veriqgateway.VeriQGatewayApplication + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + 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 + + # 4. Spring BE3 (Platform/Data Hub) + veriq-platform: + 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 + 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 + + # 5. FastAPI (분석 엔진) + veriq-analysis: + build: + context: ../analysis-engine + dockerfile: Dockerfile + container_name: veriq-analysis + restart: always + ports: + - "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" + OTX_API_KEY: ${OTX_API_KEY} + GOOGLE_SAFE_BROWSING_API_KEY: ${GOOGLE_SAFE_BROWSING_API_KEY} + depends_on: + - veriq-db 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/VeriQBe3Application.java b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java index 25eb7ea..3b1b8a4 100644 --- a/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java +++ b/src/main/java/com/veriq/veriqbe3/VeriQBe3Application.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; + @SpringBootApplication + 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..7b6e358 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/AsyncConfig.java @@ -0,0 +1,32 @@ +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; +import java.util.concurrent.ThreadPoolExecutor; + +@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.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/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/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/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java new file mode 100644 index 0000000..92883cc --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ + +package com.veriq.veriqbe3.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; + +import java.time.Duration; +@Configuration +public class RestTemplateConfig { + @Bean + public org.springframework.web.client.RestTemplate restTemplate() { + // 1. 타임아웃 설정을 담당하는 공장(Factory) 생성 + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + // 2. 10초 시한폭탄 장착! + factory.setConnectTimeout(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/config/SwaggerConfig.java b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java new file mode 100644 index 0000000..cec060c --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/config/SwaggerConfig.java @@ -0,0 +1,33 @@ +package com.veriq.veriqbe3.config; + +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 { + // @OpenAPIDefinition을 통해 API 메타데이터가 정의되었으므로 + // 클래스 내부에 추가적인 Bean 설정 없이도 스웨거 페이지에 반영됩니다. +} 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("필수 값 누락"); + } +} diff --git a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java index bae4aac..524f8eb 100644 --- a/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java +++ b/src/main/java/com/veriq/veriqbe3/controller/QrScanController.java @@ -1,30 +1,379 @@ package com.veriq.veriqbe3.controller; -import com.veriq.veriqbe3.dto.QrScanResponse; -import com.veriq.veriqbe3.service.ProcessQrScan; +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; +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; +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; +import java.time.Duration; +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; + 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 제한 - public ResponseEntity uploadQrImage( //반환 http body 타입 제한 - @RequestHeader(value = "guest_uuid", required = false ) String guestUuid, - @RequestParam("image") MultipartFile image) { + public ResponseEntity uploadQrImage( + @RequestHeader(value = "guest_uuid" ) String guestUuid, + @RequestParam("image") MultipartFile image) { + try { + // 🚨 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); + } + + // --- 아래 예외 처리는 상민님 코드 그대로 유지 --- + } catch (IllegalArgumentException e) { // 사용자 잘못 -> 400 + log.warn("잘못된 QR 업로드 요청 - guestUuid: {}", guestUuid, e); + return ResponseEntity.badRequest().build(); + + } catch (org.springframework.web.client.RestClientException e) { // 파이썬 통신 에러 -> 502 + log.error("QR 스캔 요청 처리 중 서버(다운스트림) 에러 발생 - guestUuid: {}", guestUuid, e); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + + } catch (Exception e) { // 스프링 서버 내부 에러 -> 500 + log.error("QR 스캔 요청 처리 중 서버 내부 에러 발생 - guestUuid: {}", guestUuid, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + @Operation( + summary = "URL 텍스트 직접 입력 분석 시작", + description = """ + ### 텍스트 기반 URL 분석 요청 + QR 이미지 대신, 사용자가 텍스트 창에 직접 입력하거나 복사해 넣은 URL을 검사합니다. + + **[요청 방법]** + - `url`: 검사할 문자열을 쿼리 파라미터로 전송합니다. (예: `?url=https://...`) + - `guest_uuid`: 헤더에 반드시 포함해야 합니다. + + **[참고사항]** + - 프론트엔드는 이 API를 호출하기 전 반드시 `/subscribe`로 SSE 파이프를 먼저 연결해야 합니다. + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "캐시 적중: 기존 분석 데이터가 있어 즉시 완료됨 (status: COMPLETED)"), + @ApiResponse(responseCode = "202", description = "신규 분석: 파이썬 서버로 분석을 지시함 (status: PROCESSING, SSE 대기 필요)"), + @ApiResponse(responseCode = "400", description = "입력값이 비어있거나 잘못된 요청 형식"), + @ApiResponse(responseCode = "500", description = "서버 내부 에러 발생") + }) + @GetMapping(value = "/text") + public ResponseEntity uploadTextUrl( + @RequestHeader(value = "guest_uuid") String guestUuid, + @RequestParam("url") String url) { // 🚨 Body 대신 RequestParam(쿼리 파라미터)으로 받습니다. try { - QrScanResponse response = processQrScan.toFront(image, guestUuid); + log.info("텍스트 직접 입력 분석 요청 (GET) - URL: {}, guestUuid: {}", url, guestUuid); + + // 🚨 request.url() 대신 파라미터로 받은 url을 바로 넘깁니다. + Object response = qrScanRedisService.processTextWithRedis(url, guestUuid); + + if (response instanceof AnalysisResponse) { + log.info("✅ 신선한 데이터 적중! 분석 결과를 즉시 반환합니다."); + return ResponseEntity.ok(response); + } else { + log.info("🔄 파이썬 서버로 분석을 지시했습니다. 프론트엔드에 대기 신호를 보냅니다."); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + } catch (IllegalArgumentException e) { + log.warn("잘못된 텍스트 입력 요청 - guestUuid: {}", guestUuid, e); + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.error("텍스트 분석 요청 처리 중 서버 내부 에러 발생 - guestUuid: {}", guestUuid, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + // 스캔 내역 및 로딩화면 후 상세 보고서 API 엔드포인트 ,프런트랑 연결 + @Operation( + summary = "분석 결과 상세 조회", + description = """ + ### 호출 시점: + SSE의 `COMPLETE` 이벤트를 수신한 직후 호출합니다. + + ### 요청 방법: + - **URL 파라미터**: `COMPLETE` 이벤트 메시지에 포함된 `originalUrl`을 `url` 쿼리 파라미터로 전달합니다. + - **헤더**: 요청 시 `guest_uuid`를 반드시 포함해야 합니다. + """ + ) + @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에도 없는 경우 + log.warn("URL 분석 결과를 찾을 수 없습니다: {}", url); + // ❌ 기존: .body("문자열") -> ✅ 수정: .build()로 본문 없이 404 상태코드만 반환 + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + // 성공 시 200 OK와 함께 데이터 쫙 뿌려주기 return ResponseEntity.ok(response); } catch (Exception e) { - return ResponseEntity.badRequest().build(); + log.error("상세 조회 중 서버 에러 발생 - URL: {}", url, e); + // ❌ 기존: .body("문자열") -> ✅ 수정: .build()로 본문 없이 500 상태코드만 반환 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + + + + // ML 서버가 분석을 마치고 분석최종결과를 던져주는 콜백 API + + @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 { + // 🚨 1. 가장 먼저 UUID부터 검사합니다! (입구 컷) + if (guestUuid == null || guestUuid.isBlank()) { + log.error("❌ [콜백 실패] 파이썬이 헤더에 guest_uuid를 안 보냈습니다zkzk! 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) { + 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)."); + } + log.info("ML 서버로부터 분석 완료 콜백 수신 - URL: {}", resultDto.originalUrl()); + + // 1. 서비스 계층에 넘겨서 DB 저장 및 Redis 캐싱을 한 번에 처리! + // 2. 비밀번호가 맞을 때만 DB 저장 및 캐싱을 진행 + String safeUuid = (guestUuid != null) ? guestUuid : "unknown"; + String idempotencyKey = "callbackdone:" + safeUuid + ":" + resultDto.originalUrl(); +// 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); + + + //4.프런트로 완료 신호 전송 + + + 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) { + log.error("콜백 데이터 처리 중 에러 발생", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("콜백 처리 실패: " + e.getMessage()); } } + + // 파이썬 서버가 중간 단계가 끝날 때마다 쏴주는 중간보고 API + @PostMapping("/callback/progress") + 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("접근 권한이 없습니다."); + } + + 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)) { + log.warn("❌ [보안 실패] Secret 불일치! 기대값: [{}], 받은값: [{}]", mlServerSecret, providedSecret); + 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) { + String uuid = (request != null && request.guestUuid() != null) ? request.guestUuid() : "unknown"; + + log.error("진행 상태 콜백 처리 중 에러 발생 - guestUuid: {}", uuid, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("에러 발생"); + } + } + + // 프론트엔드가 파이프를 꽂으러 오는 곳 + @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) { + + 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(프런트) + @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) { + + // 방어 로직: UUID가 없으면 빈 리스트 반환 + if (guestUuid == null || guestUuid.isBlank()) { + return ResponseEntity.ok(List.of()); + } + + // 서비스 계층 호출해서 Redis 리스트 가져오기 + List historyList = qrScanRedisService.getHistoryFromRedis(guestUuid); + + return ResponseEntity.ok(historyList); + } + } \ No newline at end of file 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..8958912 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/domain/RiskLevel.java @@ -0,0 +1,34 @@ +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) { + if (value == null || value.isBlank()) return RiskLevel.SUSPICIOUS; // null 방어 + + for (RiskLevel level : RiskLevel.values()) { + if (level.value.equalsIgnoreCase(value) || level.name().equalsIgnoreCase(value)) { + return level; + } + } + // 매칭되는 게 없을 때 null 대신 의심(SUSPICIOUS) 상태로 기본값 설정 + return RiskLevel.SUSPICIOUS; + } +} 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:) 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..50a273b --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/AnalysisResponse.java @@ -0,0 +1,145 @@ +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.JsonIgnoreProperties; +import com.veriq.veriqbe3.domain.RiskLevel; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AnalysisResponse( + String analysisTime, + 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" + + "- `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`\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", + "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", + "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", + "XGB_SUSPICIOUS_URL_FEATURES", "XGB_HIGH_RISK_URL", "CHARCNN_SUSPICIOUS_URL_PATTERN" + }, + example = "['PHISHING', 'XGB_SUSPICIOUS_URL_FEATURES']" + ) + List threats, + + @Schema(hidden = true) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + MlInfo ml, + ExternalApiInfo externalApi, + Integer reportCount, + Integer blockCount, + String domainAge, + RedirectInfo redirect, + ServerInfo serverInfo, + Integer score, + RiskLevel riskLevel +) { + @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("threats") List threats, + @JsonProperty("ml") MlInfo ml, + @JsonProperty("externalApi") @JsonAlias({"safe_browsing"}) ExternalApiInfo externalApi, + @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, + @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.threats = threats; + 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) {} + public record ShortUrlInfo(boolean isShortened) {} + + public record MlInfo( + @Schema(hidden = true) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + List threats, + + @JsonAlias({"mlScore", "score"}) + Integer score + ) {} + + 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 + ) {} + + public record ServerInfo( + String type, + String location, + CertificateInfo certificate + ) {} + + public record CertificateInfo( + boolean valid, + String issuer, + String validFrom, + String validTo + ) {} +} \ 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..c85c6cc --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/ProgressRequest.java @@ -0,0 +1,12 @@ +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 위험 분석") + String status, // 상태 (예: "IN_PROGRESS", "COMPLETED") + String message // 프론트에 띄울 실제 문구 +) { +} \ No newline at end of file diff --git a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java index 45367b2..8dcb1f7 100644 --- a/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java +++ b/src/main/java/com/veriq/veriqbe3/dto/QrScanResponse.java @@ -1,15 +1,20 @@ package com.veriq.veriqbe3.dto; import com.veriq.veriqbe3.domain.SchemeType; -import lombok.Builder; -import lombok.Getter; +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; } \ 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 new file mode 100644 index 0000000..c5a3c4b --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/dto/RedisScanHistoryDto.java @@ -0,0 +1,39 @@ +package com.veriq.veriqbe3.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.Objects; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Getter +@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"); + + // 편의 메서드: QrScanResponse 객체를 받아서 Redis용 객체로 쏙 변환해줍니다. + public static RedisScanHistoryDto from(AnalysisResponse mlResponse) { + Objects.requireNonNull(mlResponse, "mlResponse가 null일 수 없습니다!"); + return RedisScanHistoryDto.builder() + .schemeType("URL") // 분석서버에서 callback해서 온것이므로 무조건 url + .typeInfo(mlResponse.originalUrl()) + //.status(response.getStatus()) + .scannedAt(LocalDateTime.now().format(FORMATTER)) + .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 5b75984..6681df7 100644 --- a/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java +++ b/src/main/java/com/veriq/veriqbe3/entity/ScanHistory.java @@ -7,26 +7,98 @@ import java.time.LocalDateTime; @Entity -@Table(name = "scan_history") -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Table(name = "scan_history",indexes={ + @Index(name = "idx_final_url", columnList = "redirect_final_url"), + // 추가 스캔 보고서 제공시 url을 매게로 하기떄문에 idndexing에 추가, + // URL과 스캔 시간을 분리해서 인덱스를 걸어줍니다. + @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 + public class ScanHistory { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private String guestUuid; - @Column(length = 1000) + @Column(length = 700) private String originalUrl; - @Column(length = 1000) - private String extractedData; - @Enumerated(EnumType.STRING) //어노테이션으로 enum의 숫자가 아닌 String 저장 + + private String typeInfo; + @Column(name = "final_threats") + private String threats; + private String domainAge; + + @Enumerated(EnumType.STRING) private SchemeType schemeType; +//유재 스캔한 시간 private LocalDateTime scannedAt; @PrePersist public void prePersist() { this.scannedAt = LocalDateTime.now(); } +//ML서버가 분석한 시간 + private String analysisTime; + private Integer 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 { + @Column(name = "ml_threats") + private String threats; + private Integer 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 Integer 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 String certValidFrom; + private String certValidTo; + } } \ 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..473e625 100644 --- a/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java +++ b/src/main/java/com/veriq/veriqbe3/repository/ScanHistoryRepository.java @@ -3,7 +3,11 @@ 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); + Optional findFirstByOriginalUrlOrRedirectFinalUrlOrderByScannedAtDesc(String originalUrl, String finalUrl); + } \ 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 new file mode 100644 index 0000000..2d36d5d --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/service/MlRequestService.java @@ -0,0 +1,82 @@ +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.scheduling.annotation.Async; +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_SECRET_KEY}")// 파이썬 서버랑 맞춘 비밀키 + 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형태로 들어감 + body.put("guestUuid", guestUuid); + // 3. 엔티티 조립 바디랑 헤더 조립 + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // 4. 파이썬 서버로 POST 요청 발사 + + restTemplate.postForObject(mlServerUrl, requestEntity, String.class); + //로그에 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) { + // [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); + } + } + +} 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 4a02ea1..0000000 --- a/src/main/java/com/veriq/veriqbe3/service/ProcessQrScan.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.veriq.veriqbe3.service; - -import com.veriq.veriqbe3.domain.SchemeType; -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; - -@Service -@RequiredArgsConstructor -public class ProcessQrScan { - - private final QrDecoder qrDecoder; - private final SchemeClassifier schemeClassifier; - private final ScanHistoryRepository scanHistoryRepository; - - @Transactional // DB 저장 중 에러 발생 시 모든 작업을 롤백하기 위함 - public QrScanResponse toFront(MultipartFile image, String guest_uuid) throws Exception { - - String url = qrDecoder.decode(image); //이미지 디코드 - - 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"; - - //WEB이나 단축 URL이면 상태를 변경 - if (result.type() == SchemeType.WEB || result.type() == SchemeType.SHORT_URL) { - status = "ANALYSIS_REQUIRED"; - - } - return QrScanResponse.builder() - .guestUuid(guest_uuid) // FE가 보낸 식별자 - .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 new file mode 100644 index 0000000..37194a8 --- /dev/null +++ b/src/main/java/com/veriq/veriqbe3/service/QrScanRedisService.java @@ -0,0 +1,708 @@ +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 java.time.ZonedDateTime; +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 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + + +import java.io.IOException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + + +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; + private final ScanHistoryRepository scanHistoryRepository; + private final MlRequestService mlRequestService; + private final Map emitters = new ConcurrentHashMap<>(); + + + + + private static final int MAX_HISTORY_SIZE = 50; + private static final Duration HISTORY_TTL = Duration.ofDays(7); + + + // ============================================================================== + // 1. [기존] QR 이미지 업로드 시 호출되는 메서드 + // ============================================================================== + public Object processWithRedis(MultipartFile image, String guestUuid) throws Exception { + String url = qrDecoder.decode(image); + + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("QR 코드 디코딩에 실패했습니다."); + } + + // QR에서 온 요청임을 표시(true)하여 공통 로직으로 전달 + return executeAnalysisFlow(url, guestUuid, true); + } + + // ============================================================================== + // 2. [신규] 텍스트(URL) 직접 입력 시 호출되는 메서드 + // ============================================================================== + public Object processTextWithRedis(String rawText, String guestUuid) throws Exception { + if (rawText == null || rawText.isBlank()) { + throw new IllegalArgumentException("입력된 텍스트가 비어있습니다."); + } + + // 텍스트 직접 입력임을 표시(false)하여 앞뒤 공백 제거 후 공통 로직으로 전달 + return executeAnalysisFlow(rawText.trim(), guestUuid, false); + } + + // ============================================================================== + // 3. [핵심] 공통 분석 로직 (기존 processWithRedis 내용 이동 및 메시지 분기) + // ============================================================================== + private Object executeAnalysisFlow(String url, String guestUuid, boolean isFromQr) throws Exception { + + // 1. URL 분류 + SchemeClassifier.ClassificationResult result = schemeClassifier.classify(url); + + // 2. 프론트로 보낼 메시지 세팅 (QR/텍스트 입력 출처에 따라 다르게 설정) + boolean isUrl = result.toBe2(); + String decodingMsg; + if (isFromQr) { + decodingMsg = isUrl ? "QR 코드 디코딩이 완료되었습니다." : "QR 코드 내용을 확인한 결과 URL 형식이 아니었습니다."; + } else { + decodingMsg = isUrl ? "URL 입력이 확인되었습니다." : "입력하신 내용이 올바른 URL 형식이 아닙니다."; + } + + log.info("[AnalysisFlow] {}", decodingMsg); +// 2. [비 URL] 처리 로직 + if (!isUrl) { + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("COMPLETED") + .isUrl(false) + .message(decodingMsg) + .schemeType(result.type() != null ? result.type() : SchemeType.OTHER) + .build(); + } + +// 3. [URL] 처리 로직 + // 4-1. 캐시 및 DB에서 '신선한' 데이터 가져오기 시도 + AnalysisResponse freshResult = getUrlDetail(result.typeInfo()); + + if (freshResult != null) { + // 🚨 [핵심 A] 신선한 데이터가 있으면 파이썬 분석 스킵! AnalysisResponse 객체를 통째로 리턴! + + log.info("[Fast-Path] 신선한 캐시/DB 데이터 적중! 파이썬 분석 생략 - URL: {}", result.typeInfo()); + saveHitHistory(freshResult, guestUuid); + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("COMPLETED") // 이미 끝났으니 완료 상태 + .isUrl(true) + .message("안전하게 분석된 기존 이력이 있습니다.") + // 필요하다면 QrScanResponse 안에 AnalysisResponse를 담을 수 있는 + // 필드(예: private AnalysisResponse detailResult;)를 하나 만들어서 넣어주는 것이 가장 좋습니다. + .build(); + } + + // 4-2. 데이터가 아예 없거나 너무 낡았다면? (null이 반환된 경우) + try { + log.info("데이터가 없거나 오래되었습니다. 파이썬 ML 서버에 분석을 지시합니다. URL: {}", result.typeInfo()); + + // 여기서 파이썬 서버로 분석 지시 핑(Ping)을 날립니다. + mlRequestService.sendToPythonServer(guestUuid, result.typeInfo()); + + // 🚨 [핵심 B] 파이썬이 일하러 갔으니, 프론트에게는 QrScanResponse(로딩 상태)를 리턴! + return QrScanResponse.builder() + .guestUuid(guestUuid) + .typeInfo(result.typeInfo()) + .status("PROCESSING") // 프론트는 이걸 보고 SSE 대기 화면을 띄웁니다. + .isUrl(true) + .message("분석이 시작되었습니다.") + .build(); + + } catch (org.springframework.web.client.RestClientException e) { + log.error("ML 서버 통신 실패 (RestClientException) - guestUuid: {}", guestUuid, e); + throw e; // 컨트롤러가 잡아서 502 Bad Gateway로 처리 + + } catch (Exception e) { + log.error("분석 요청 중 알 수 없는 에러", e); + throw new RuntimeException("ML 서버 통신 실패", e); + } + + + } + /** + * [추가] 파이썬이 보낸 가변 공백 날짜 문자열(예: "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 적중 시, 분석 없이 '방문 기록'만 새로 남기는 메서드 + */ + 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(cleanDateString(responseDto.analysisTime())) + + // 🚨 핵심 2: null 방어 로직 (이전에 수정한 것과 동일하게 적용) + .totalScore(responseDto.score() != null ? responseDto.score() : 0) + .riskLevel(responseDto.riskLevel() != null ? responseDto.riskLevel().name() : "SUSPICIOUS") + + // 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() + // 🚨 껍데기 없이 직접 호출! exists는 신고 건수가 1 이상일 때 true로 판단 + .dbExists(responseDto.reportCount() != null && responseDto.reportCount() > 0) + .dbReportCount(responseDto.reportCount()) + .dbBlockCount(responseDto.blockCount()) + .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(cleanDateString(responseDto.serverInfo().certificate().validFrom())) + .certValidTo(cleanDateString(responseDto.serverInfo().certificate().validTo())) + .build() + : null) + .build()) + .build(); + + // 🚨 핵심 3: 새로운 엔티티로 DB에 Insert! + // 여기서 @PrePersist가 발동해서 hitHistory.scannedAt 에 '현재 스캔 시간'이 자동으로 들어갑니다! + scanHistoryRepository.save(hitHistory); + + log.info("캐시 적중 - 새로운 스캔 히스토리(방문 기록) DB 저장 완료: {}", url); + // 유저 개인의 Redis 히스토리 리스트에도 추가 + saveHistoryToRedis(guestUuid, responseDto); + + } catch (Exception e) { + log.error("캐시 적중 히스토리 저장 중 에러 발생", e); + } + } + // 스캔 내역 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) { + 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에서 조회 + //스캔한 내역중 최신 내역을 db에서 뽑아옴 + Optional dbHistory = scanHistoryRepository.findFirstByOriginalUrlOrRedirectFinalUrlOrderByScannedAtDesc(url,url); + if (dbHistory.isEmpty()) { + return null; // DB에도 없으면 분석이 아직 안 끝났거나 없는 URL + } + // 🚨 여기서 history 변수를 명확하게 뽑아냅니다! (에러 해결 핵심) + ScanHistory history = dbHistory.get(); + + + // 3. 신선도 체크 + if (isStale(history)) { + return null; // 상했으면 null (processWithRedis에서 캐치해서 파이썬 호출함) + } + + // 4. 신선하면 DTO로 변환 후 캐싱 및 반환 + AnalysisResponse dbResponse = convertToAnalysisResponse(history); + 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; + + try { + String rawDate = history.getAnalysisTime().replaceAll("\\s{2,}", " "); + 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(); + } + + // 현재 시간과 비교하여 며칠이 지났는지 계산 + 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; + } + } + /** + + */ + public void cacheAnalysisResult(AnalysisResponse resultDto, Duration ttl) { + try { + String originalUrl = resultDto.originalUrl(); + String finalUrl = resultDto.redirect() != null ? resultDto.redirect().finalUrl() : null; + + // 저장할 데이터는 동일하므로 한 번만 JSON으로 변환 + String jsonCache = objectMapper.writeValueAsString(resultDto); + + // 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 캐싱 실패", e); + } + } + + public void saveHistoryToRedis(String guestUuid, AnalysisResponse mlResponse) { + // 1. 방어 로직: 저장할 필요가 없는 경우 즉시 반환 + if (guestUuid == null || guestUuid.isBlank() || guestUuid.equals("ANONYMOUS")) { + return; + } + + try { + String historyKey = "history:" + guestUuid; + 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); + } + } + /** + * URL 캐시 키를 안전하게 정규화하고 해싱하는 헬퍼 메서드 + */ + private String buildUrlCacheKey(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + return "url_cache:empty"; + } + + // 1. 정규화: 앞뒤 공백 제거 + String cleanUrl = rawUrl.trim(); + // 프론트가 "https://yes24.com/" 라고 보내든 "https://yes24.com" 이라고 보내든 똑같이 취급합니다. + if (cleanUrl.endsWith("/")) { + cleanUrl = cleanUrl.substring(0, cleanUrl.length() - 1); + } + + // 2. Fragment(#)만 제거하고, QueryString(?)은 반드시 보존! + // 브라우저 내부용인 # 뒤는 잘라도 되지만, ? 뒤는 데이터이므로 남깁니다. + int hashIndex = cleanUrl.indexOf('#'); + if (hashIndex != -1) { + cleanUrl = cleanUrl.substring(0, hashIndex); + } + // 3. 해싱: MD5 알고리즘을 사용하여 어떤 길이의 URL이든 짧고 안전한 문자열로 변환 + String hashedUrl = DigestUtils.md5DigestAsHex(cleanUrl.getBytes()); + + return "url_cache:" + hashedUrl; + } + /** + * DB 엔티티(ScanHistory)를 Redis용 캐시/응답 DTO(AnalysisResponse)로 변환하는 헬퍼 메서드 + */ + private AnalysisResponse convertToAnalysisResponse(ScanHistory entity) { + + // 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()) { + mlThreatsList = java.util.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, + + // 🚨 [추가됨] 새로 만든 최상단 위협 리스트를 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, + + // ✅ dbReportCount, dbBlockCount + entity.getInternalDb() != null ? entity.getInternalDb().getDbReportCount() : null, + entity.getInternalDb() != null ? entity.getInternalDb().getDbBlockCount() : null, + + // 🚨 [수정됨] 이제 DB에 domainAge가 있으므로 null 대신 entity.getDomainAge() 사용 + entity.getDomainAge(), + + 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(), + RiskLevel.from(entity.getRiskLevel()) + ); + } + /** + * [추가된 핵심 로직] 위험도에 따른 차등 TTL 계산 메서드//redis갱신 + */ + private Duration calculateDynamicTtl(RiskLevel riskLevel) { + if (riskLevel == null) return Duration.ofDays(2); // 안전장치 (기본값) + + return switch (riskLevel) { + case SAFE -> Duration.ofDays(1); + case SUSPICIOUS, DANGER -> Duration.ofDays(3); + }; + } + + @Transactional + // DB 저장하다 에러 나면 롤백!, + // 분석서버에서 온 결과 db 및 redis에 저장 + public void saveAndCacheAnalysisResult(AnalysisResponse responseDto, String guestUuid) { + + try { + // ========================================== + // 1. 프론트용 DTO를 DB 저장용 Entity로 변환 + // ========================================== + String url = responseDto.originalUrl(); // 편의를 위해 변수 추출 + String safeGuestUuid = (guestUuid != null && !guestUuid.isBlank()) ? guestUuid : "ANONYMOUS"; + + ScanHistory scanHistory = ScanHistory.builder() + .guestUuid(safeGuestUuid) + .originalUrl(url) + .typeInfo(url) // 보통 원본 URL을 넣습니다 + .schemeType(SchemeType.WEB) // 콜백으로 오는 건 사실상 모두 WEB이라고 가정 + .analysisTime(cleanDateString(responseDto.analysisTime())) + .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()) + .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) + // 🚨 2. mlScore 방어 (null이 아닐 때만 값을 넣고, null이면 0) + .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.reportCount() != null && responseDto.reportCount() > 0) + .dbReportCount(responseDto.reportCount()) + .dbBlockCount(responseDto.blockCount()) + .build()) + + // 6. RedirectInfo 빌드 + .redirect(ScanHistory.RedirectInfo.builder() + .finalUrl(responseDto.redirect() != null ? responseDto.redirect().finalUrl() : url) + // 🚨 3. redirectCount 방어 (null이 아닐 때만 값을 넣고, null이면 0) + .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(cleanDateString(responseDto.serverInfo().certificate().validFrom())) + .certValidTo(cleanDateString(responseDto.serverInfo().certificate().validTo())) + .build() + : null) + .build()) + .build(); + + // ========================================== + // 2. DB에 저장 + // ========================================== + scanHistoryRepository.save(scanHistory); + log.info("DB 저장 완료: {}", url); + + // ========================================== + // 3. 차등 TTL 적용하여 Redis 캐시 워밍 + // ========================================== + Duration dynamicTtl = calculateDynamicTtl(responseDto.riskLevel()); + cacheAnalysisResult(responseDto, dynamicTtl); + // 분석 완료 후 유저 개인의 Redis 히스토리 리스트에도 추가! + saveHistoryToRedis(guestUuid, responseDto); + + log.info("모든 저장 및 동적 캐싱 프로세스 완료"); + + } 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); // 명부에 등록 + //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)); + emitter.onTimeout(() -> emitters.remove(guestUuid, emitter)); + + 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) { + // 추가: 누가 끊겼는지, 왜 끊겼는지 경고 로그를 남겨줍니다. + log.warn("SSE COMPLETE 이벤트 전송 실패 - guestUuid: {}", guestUuid, e); + emitters.remove(guestUuid); + } + } + + } + // 저장된 히스토리 목록 가져오기 + public List getHistoryFromRedis(String guestUuid) { + String historyKey = "history:" + guestUuid; + try { + // 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) { + log.error("Redis 히스토리 조회 실패 - guestUuid: {}", guestUuid, e); + return List.of(); + } + } + + +} + diff --git a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java index 5a6207e..e8bdceb 100644 --- a/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java +++ b/src/main/java/com/veriq/veriqbe3/service/SchemeClassifier.java @@ -3,40 +3,134 @@ 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 DoS 방지 및 특수문자 % 허용 + private static final Pattern WEB_URL_PATTERN = Pattern.compile( + "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,})([/a-zA-Z0-9.~:?#@!$&'()*+,;=%-]*)$", + Pattern.CASE_INSENSITIVE + ); + + private static final List SHORTENER_PATTERNS = List.of( + 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) + private static final Pattern OTP_PATTERN = Pattern.compile( + "^otpauth://(totp|hotp)/.*$", + Pattern.CASE_INSENSITIVE + ); + + // 3. 가상화폐 지갑 (?amount= 등 쿼리파라미터 허용) + private static final Pattern CRYPTO_PATTERN = Pattern.compile( + "^(bitcoin|ethereum|litecoin|bitcoincash):[a-zA-Z0-9]+(\\?.*)?$", + Pattern.CASE_INSENSITIVE + ); + + // 4. SMS + private static final Pattern SMS_PATTERN = Pattern.compile( + "^sms:(\\+?[0-9\\-]+)(\\?.*)?$", + Pattern.CASE_INSENSITIVE + ); + + // 5. WI-FI (비밀번호가 없는 Open 네트워크도 존재할 수 있으므로 P:는 필수가 아님) + private static final Pattern WIFI_PATTERN = Pattern.compile( + "^WIFI:.*(S:[^;]+).*;;?$", + Pattern.CASE_INSENSITIVE + ); + + // 6. 연락처 (vCard / MeCard) + private static final Pattern CONTACT_PATTERN = Pattern.compile( + "^(BEGIN:VCARD|MECARD:).*$", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL + ); + + // 7. 전화번호 (일반 유선, 국제번호 포함하여 유연하게 변경) + private static final Pattern TEL_PATTERN = Pattern.compile( + "^(tel:)?(\\+?[0-9\\-]{8,15})$", + Pattern.CASE_INSENSITIVE + ); + + // 8. 이메일 (?subject= 등 쿼리파라미터 허용) + 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. 앱 스토어 + private static final Pattern APP_STORE_PATTERN = Pattern.compile( + "^(market|itms-apps)://.*$", + Pattern.CASE_INSENSITIVE + ); + + // 10. 일반 딥 링크 + 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); + + Matcher telMatcher = TEL_PATTERN.matcher(trimmed); + if (telMatcher.matches()) { + String phoneNum = trimmed.replaceFirst("(?i)tel:", ""); + 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); } - // 2. 이메일 (mailto:test@veriq.com) - if (lowerText.startsWith("mailto:")) { - return new ClassificationResult(SchemeType.EMAIL, rawText.substring(7)); + 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); } - // 3. 앱 스토어 (안드로이드 마켓 또는 iOS 앱스토어) - if (lowerText.startsWith("market:") || lowerText.startsWith("itms-apps:")) { - return new ClassificationResult(SchemeType.APP_STORE, rawText); + if (WEB_URL_PATTERN.matcher(trimmed).matches()) { + // 정규식을 통해 정확히 도메인 형태가 단축 URL인지 판별 + boolean isShortener = SHORTENER_PATTERNS.stream() + .anyMatch(pattern -> pattern.matcher(trimmed).matches()); + + 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 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..d817644 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/VeriQGatewayApplication.java @@ -0,0 +1,21 @@ +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); + } + +} 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/java/com/veriq/veriqgateway/config/RestTemplateConfig.java b/src/main/java/com/veriq/veriqgateway/config/RestTemplateConfig.java new file mode 100644 index 0000000..e50b3f3 --- /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; + +@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(40)); // 응답 대기 10초 제한 + + // 3. 설정된 공장을 RestTemplate에 넣어서 완성 + return new RestTemplate(factory); + } + +} 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")); + } +} 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..0064f53 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/controller/GatewayController.java @@ -0,0 +1,296 @@ +package com.veriq.veriqgateway.controller; +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; +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; +import lombok.RequiredArgsConstructor; +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 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 +@Profile("be1") +public class GatewayController { + private final SecurityService securityService; + private final RestTemplate restTemplate; + + // 🔗 고근 님(BE 3) 서버의 실제 API 주소 + // 실전에서는 localhost 대신 도커 네트워크 서비스 명칭을 쓰기도 하지만, + // 지금은 로컬 테스트용으로 작성했습니다. + @Value("${be3.api.url}") + private String BE3_URL ; + + /** + * [POST] /api/v1/scan + * 1. Redis(도커)를 통한 5회 제한 보안 검사 + * 2. 통과 시 BE 3 서버로 이미지 및 guestId 전송 + */ + /** + * 클라이언트의 실제 IP를 추출하는 헬퍼 메서드 (도커/프록시 환경 대응) + */ + 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(); + } + @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에 **반영**합니다. + """ + ) + @ApiResponses(value = { + // --- 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, + "schemeType": "URL", + "typeInfo": "https://naver-login-check.xyz" + } + """ + ), + @ExampleObject( + name = "2. COMPLETED (비 URL 타입)", + summary = "텍스트, 전화번호 등 비 URL QR인 경우", + value = """ + { + "guestUuid": "sangmin-uuid-123", + "status": "COMPLETED", + "isUrl": false, + "schemeType": "TEL", + "typeInfo": "010-1234-5678" + } + """ + ) + } + ) + ), + // --- 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, + "schemeType": null, + "typeInfo": "https://unknown-qr-link.com" + } + """ + ) + ) + ), + // 🚨 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( + @RequestHeader("guest_uuid") String guestUuid, + @RequestParam("image") MultipartFile image, + HttpServletRequest request) { + + // --- [STEP 1] 보안 검사 (Redis 연동) --- + // 도커로 띄운 클라이언트가 보낸 UUID가 아닌, 실제 IP를 식별자로 사용. + String clientIp = getClientIp(request); + if (!securityService.isAllowed(clientIp)) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) // 429 에러 + .body(ScanResponse.builder() + .guestUuid(guestUuid) + .status("REJECTED") + .typeInfo("REQUIRE_CAPTCHA") + .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) 호출 + System.out.println(">>> [BE1] BE3(" + BE3_URL + ")로 데이터 전송 시도 중..."); + + ResponseEntity responseFromBe3 = restTemplate.postForEntity(BE3_URL, requestEntity, QrScanResponse.class); + System.out.println(">>> [BE1] BE3로부터 응답 무사히 도착!"); + + + // --- [STEP 3] 최종 성공 응답 --- + QrScanResponse be3Data = responseFromBe3.getBody(); + if (be3Data == null) { + throw new RuntimeException("BE3 서버로부터 빈 응답(null)이 도착했습니다."); + } + + ScanResponse finalResponse = ScanResponse.builder() + .guestUuid(be3Data.getGuestUuid()) + .status(be3Data.getStatus()) + .isUrl(be3Data.getIsUrl()) + .schemeType(be3Data.getSchemeType() != null ? be3Data.getSchemeType().name() : null) + .typeInfo(be3Data.getTypeInfo()) + .build(); + + return ResponseEntity.status(responseFromBe3.getStatusCode()).body(finalResponse); + + } 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(); + System.out.println(">>> 에러 메시지: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ScanResponse.builder() + .guestUuid(guestUuid) + .status("ERROR") + .build()); + } + } + + /** + * [POST] /api/v1/auth/captcha/verify + * 캡차 성공 시 해당 사용자의 Redis 제한 카운트를 초기화합니다. + */ + @Operation( + summary = "구글 reCAPTCHA 검증 및 제한 초기화", + description = "프론트엔드에서 획득한 캡차 토큰을 검증합니다. 성공 시 해당 IP의 Redis 카운트가 초기화되어 다시 스캔이 가능해집니다." + ) + @PostMapping("/auth/captcha/verify") + public ResponseEntity verifyCaptcha( + @RequestBody CaptchaRequest captchaReq, + HttpServletRequest request) { + + // [STEP 1] 구글 실전 검증 + String clientIp = getClientIp(request); + String currentUuid = captchaReq.getGuestUuid(); + boolean isHuman = securityService.verifyWithGoogle(captchaReq.getCaptchaToken()); + + if (!isHuman) { + // 매크로인인 경우 + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ScanResponse.builder() + .guestUuid(currentUuid) + .status("REJECTED") + .build()); + } + + // [STEP 2] 검증 성공 시 Redis 카운트 초기화 + securityService.resetCount(clientIp); + + // [STEP 3] 성공 응답 (프런트가 메시지를 받고 스캔을 재시도함) + return ResponseEntity.ok(ScanResponse.builder() + .guestUuid(currentUuid) + .status("SUCCESS") + .build());} +} 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/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..a85a0aa --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/GoogleCaptchaResponse.java @@ -0,0 +1,19 @@ +package com.veriq.veriqgateway.dto; +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; // 요청된 도메인 + + @JsonProperty("error-codes") + private List errorCodes; // 실패 시 에러 코드들 +} 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..017198e --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/QrScanResponse.java @@ -0,0 +1,19 @@ +package com.veriq.veriqgateway.dto; +import com.veriq.veriqgateway.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; +} 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..b7bb762 --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/dto/ScanResponse.java @@ -0,0 +1,58 @@ +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.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonPropertyOrder({"guestUuid", "status", "isUrl", "schemeType", "typeInfo"}) +@Schema(description = "QR 스캔 최종 결과 응답 객체") +public class ScanResponse { + + @Schema(description = "사용자 식별용 UUID", example = "sangmin-uuid-123") + private String guestUuid; + + @Schema(description = "분석 진행 상태 (COMPLETED 또는 PROCESSING)", example = "COMPLETED") + private String status; + + @JsonProperty("isUrl") + @Schema(description = "URL 여부 확인", example = "true") + private Boolean isUrl; + + @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") + private String typeInfo; + + // 🗑️ message와 error_code 필드는 삭제되었습니다. +} \ No newline at end of file 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..74c903b --- /dev/null +++ b/src/main/java/com/veriq/veriqgateway/service/SecurityService.java @@ -0,0 +1,95 @@ +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; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import java.util.Collections; +@Service +@RequiredArgsConstructor + +public class SecurityService { + // Redis 조작을 위한 스프링 표준 템플릿 + private final StringRedisTemplate redisTemplate; + + // 사용자(Guest)에게 허용할 최대 스캔 횟수 + private static final int LIMIT = 50; + private final RestTemplate restTemplate; // 메인 클래스에서 등록한 빈 사용 + @Value("${GOOGLE_RECAPTCHA_SECRET_KEY}") + private String secretKey; + @Value("${google.recaptcha.verify-url}") + private String verifyUrl; + + /** + * 유저의 요청이 허용 범위 내에 있는지 확인합니다. + * @param clinetIp 사용자 식별자 + * @return 허용 여부 (true: 통과, false: 캡차 필요) + */ + public boolean isAllowed(String clinetIp) { + // Redis에 저장할 키 이름 (limit+ip) + String key = "limit:" + clinetIp; + // 1. [원자적 연산] 읽기 + 비교하기 전에 무조건 1을 먼저 증가시킵니다. + // Redis는 자체적으로 동시성을 제어하므로, 이 연산은 절대 꼬이지 않습니다. + // 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"; + + // 스크립트 객체 생성 (반환값은 Long 타입) + RedisScript redisScript = new DefaultRedisScript<>(script, Long.class); + + // 2. 스크립트 실행 (키 1개 전달, 만료 시간은 3600초(1시간) 전달) + Long currentCount = redisTemplate.execute( + redisScript, + Collections.singletonList(key), + "3600" + ); + + if (currentCount == null) { + return false; // Redis 통신 에러 대비 + } + + // 3. 만약 방금 올린 숫자가 제한선(LIMIT)을 넘어버렸다면? (기존 로직 유지) + if (currentCount > LIMIT) { + // 차단할 것이므로 원상복구(Rollback) + redisTemplate.opsForValue().decrement(key); + return false; // 차단 + } + + // 4. 제한선 이하라면 무사히 통과 + return true; + + } + + /** + * 캡차 인증에 성공했을 때, 해당 유저의 요청 횟수 기록을 지워줍니다. + */ + public void resetCount(String clinetIp) { + redisTemplate.delete("limit:" + clinetIp); + } + 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..d4af7f2 --- /dev/null +++ b/src/main/resources/application-be1.yml @@ -0,0 +1,44 @@ +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 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + 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" +# 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) +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 new file mode 100644 index 0000000..806d866 --- /dev/null +++ b/src/main/resources/application-be3.yml @@ -0,0 +1,40 @@ +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 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + + + data: + redis: + host: veriq-redis + port: 6379 + +ml: + api : + url: "http://veriq-analysis:8000/analyze" #재조정 + + + +# 프론트엔드 허용 주소 (도커 환경변수로 덮어쓰기 가능) +frontend: + url: ${FRONTEND_CORS_URL:http://localhost:3000} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a4e3cfe..048dfc0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,13 +1,40 @@ +# [공통 설정] +spring: + profiles: + active: gateway + +--- +# [Gateway 설정 구역 - BE1] +spring: + config: + activate: + on-profile: gateway + server: - port: 8083 + 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://localhost:3306/veriq?useSSL=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + url: jdbc:mysql://veriq-db:3306/veriq_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root - password: 1234 + password: ${MYSQL_ROOT_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - + hikari: + connection-timeout: 30000 jpa: hibernate: ddl-auto: update @@ -15,9 +42,6 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.MySQLDialect - data: - redis: - host: localhost - port: 6379 \ No newline at end of file +server: + port: 8083 \ No newline at end of file