diff --git a/.github/resources/deploy.sh b/.github/resources/deploy.sh index 1f5badfd..ab427e1c 100644 --- a/.github/resources/deploy.sh +++ b/.github/resources/deploy.sh @@ -106,7 +106,6 @@ send_slack ">>> Nginx 트래픽 전환 ($TARGET_CONTAINER)..." echo "set \$service_url http://$TARGET_CONTAINER:8080;" > ./nginx/conf.d/service-url.inc IS_NGINX_RUNNING=$(docker ps | grep nginx) - if [ -z "$IS_NGINX_RUNNING" ]; then send_slack ">>> Nginx가 실행 중이지 않습니다. Nginx 시작..." docker compose up -d nginx @@ -133,7 +132,8 @@ if [ -n "$CURRENT_PROFILE" ]; then send_slack ">>> 🛑 구 버전 컨테이너 중지 완료: ${STOP_DURATION}초 소요 ($STOP_MSG)" fi -send_slack ">>> 사용하지 않는 Docker 이미지 정리(Prune)..." +send_slack ">>> 사용하지 않는 Docker 이미지 정리..." +docker images qasker/api --format "{{.Repository}}:{{.Tag}}" | grep -v ":latest" | xargs docker rmi docker image prune -f TOTAL_END_TIME=$(date +%s) diff --git a/.github/resources/docker-compose.yml b/.github/resources/docker-compose.yml index e05767eb..ca658772 100644 --- a/.github/resources/docker-compose.yml +++ b/.github/resources/docker-compose.yml @@ -21,4 +21,4 @@ services: environment: - SPRING_PROFILES_ACTIVE=prod,green ports: - - "${GREEN_PORT}:8080" + - "${GREEN_PORT}:8080" \ No newline at end of file diff --git a/.github/workflows/prod_deploy.yml b/.github/workflows/prod_deploy.yml index 807500c0..59ea13e8 100644 --- a/.github/workflows/prod_deploy.yml +++ b/.github/workflows/prod_deploy.yml @@ -2,7 +2,7 @@ name: Docker Hub Push & 배포 서버 EC2 배포 on: push: branches: - - main + - ICC-242-scouter workflow_dispatch: jobs: @@ -20,7 +20,6 @@ jobs: - name: 환경변수들 등록 run: | - echo "${{ env.NEWRELIC_YML }}" > app/newrelic/newrelic.yml echo "${{ env.APPLICATION_PROD_YML }}" > app/src/main/resources/application-prod.yml echo "${{ env.GRADLE_PROPERTIES }}" > app/gradle.properties @@ -34,7 +33,7 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Jib로 Docker 이미지 빌드 및 푸시 - run: ./gradlew jib -PPROFILE=prod --build-cache + run: ./gradlew jib --build-cache deploy: name: EC2 배포 @@ -46,7 +45,6 @@ jobs: EC2_KEY: ${{ secrets.EC2_KEY }} BLUE_PORT: 8001 GREEN_PORT: 8002 - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} steps: - name: gradle.properties 값 환경 변수로 등록 및 마스킹 @@ -67,6 +65,10 @@ jobs: # DOCKER_CONTAINER_NAME 생성, 등록 DOCKER_CONTAINER_NAME_VALUE="${DOCKER_ID_VALUE}-${DOCKER_IMAGE_NAME_VALUE}" echo "DOCKER_CONTAINER_NAME=$DOCKER_CONTAINER_NAME_VALUE" >> $GITHUB_ENV + + # SLACK_WEBHOOK_URL 추출, 마스킹, 환경 변수 등록 + SLACK_WEBHOOK_URL_VALUE=$(echo "${{ secrets.GRADLE_PROPERTIES }}" | grep '^SLACK_WEBHOOK_URL=' | cut -d'=' -f2-) + echo "SLACK_WEBHOOK_URL=$SLACK_WEBHOOK_URL_VALUE" >> $GITHUB_ENV - name: 코드 체크아웃 uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 2e8973b1..fc7cd1c5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,5 @@ out/ .env app/gradle.properties app/newrelic/newrelic.yml -/heapdump +**/heapdump monitor_downtime.sh \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 84fe7760..618d0cac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,9 @@ dependencies { implementation project(':auth:auth-impl') implementation project(':aws:aws-impl') implementation project(':quiz:quiz-impl') + implementation project(':util:util-impl') + implementation project(':global') - implementation "org.springframework.boot:spring-boot-starter-actuator" annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } @@ -37,23 +38,55 @@ jib { extraDirectories { paths { path { - setFrom(file("newrelic").toPath()) - into = "/app/newrelic" + setFrom(file("./scouter").toPath()) + into = "/app/scouter" } } } container { def jvmHeapSize = project.property("JVM_HEAP_SIZE") + // 기본 JVM 플래그 설정 jvmFlags = ["-Xms${jvmHeapSize}", "-Xmx${jvmHeapSize}"] - def newRelicConfig = project.file("newrelic/newrelic.yml") - def newRelicJar = project.file("newrelic/newrelic.jar") - if (newRelicConfig.exists() && newRelicJar.exists()) { - jvmFlags = jvmFlags + [ - "-Dnewrelic.config.file=/app/newrelic/newrelic.yml", - "-javaagent:/app/newrelic/newrelic.jar", - ] + def scouterJar = project.file("./scouter/agent.java/scouter.agent.jar") + + if (!scouterJar.exists()) { + throw new GradleException("Scouter agent file not found: ${scouterJar.absolutePath}") + } + + // 1. 필수값 검증 (값이 없으면 여기서 빌드 실패함 🛑) + def requiredProps = ["SCOUTER_IP", "SCOUTER_PORT", "SCOUTER_OBJ_NAME"] + requiredProps.each { prop -> + if (!project.hasProperty(prop)) { + throw new GradleException("❌ [빌드 실패] ${prop} 프로퍼티가 누락되었습니다. (-P${prop}=값 필요)") + } } + + // 2. 값 할당 (검증 통과했으므로 안전하게 가져옴) + def collectorIp = project.property("SCOUTER_IP") + def objName = project.property("SCOUTER_OBJ_NAME") + def collectorPort = project.property("SCOUTER_PORT") + + jvmFlags = jvmFlags + [ + // [필수] 에이전트 로드 + "-javaagent:/app/scouter/agent.java/scouter.agent.jar", + + // [필수] Java 16+ 대응을 위한 모듈 접근 허용 옵션 + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + + // [필수] conf 파일 대신 직접 설정 주입 + "-Dnet_collector_ip=${collectorIp}", + "-Dnet_collector_udp_port=${collectorPort}", + "-Dnet_collector_tcp_port=${collectorPort}", + + // [보안 설정: 권장] 클라이언트에서 제어 기능(HeapDump, Method Patch 등) 비활성화 + "-Denable_mgr_agent=false", + + // [권장] 어플리케이션 이름 + "-Dobj_name=${objName}" + + ] } } \ No newline at end of file diff --git a/app/newrelic/newrelic.jar b/app/newrelic/newrelic.jar deleted file mode 100644 index 4632fad4..00000000 Binary files a/app/newrelic/newrelic.jar and /dev/null differ diff --git a/app/scouter/agent.java/conf/scouter.conf b/app/scouter/agent.java/conf/scouter.conf new file mode 100644 index 00000000..27aec279 --- /dev/null +++ b/app/scouter/agent.java/conf/scouter.conf @@ -0,0 +1,12 @@ +### scouter java agent configuration sample +#obj_name=WAS-01 +#net_collector_ip=127.0.0.1 +#net_collector_udp_port=6100 +#net_collector_tcp_port=6100 +#hook_method_patterns=sample.mybiz.*Biz.*,sample.service.*Service.* +#trace_http_client_ip_header_key=X-Forwarded-For +#profile_spring_controller_method_parameter_enabled=false +#hook_exception_class_patterns=my.exception.TypedException +#profile_fullstack_hooked_exception_enabled=true +#hook_exception_handler_method_patterns=my.AbstractAPIController.fallbackHandler,my.ApiExceptionLoggingFilter.handleNotFoundErrorResponse +#hook_exception_hanlder_exclude_class_patterns=exception.BizException diff --git a/app/scouter/agent.java/conf/testcase-scouter.conf b/app/scouter/agent.java/conf/testcase-scouter.conf new file mode 100644 index 00000000..5f168d14 --- /dev/null +++ b/app/scouter/agent.java/conf/testcase-scouter.conf @@ -0,0 +1 @@ +objName=test-case-scouter diff --git a/app/scouter/agent.java/plugin/capture.plug b/app/scouter/agent.java/plugin/capture.plug new file mode 100644 index 00000000..9a6cf848 --- /dev/null +++ b/app/scouter/agent.java/plugin/capture.plug @@ -0,0 +1,9 @@ +[args] +// void capArgs(WrContext $ctx, HookArgs $hook) + +[return] +// void capReturn(WrContext $ctx, HookReturn $hook) + + +[this] +// void capThis(WrContext $ctx, String $class, String $desc, Object $this) diff --git a/app/scouter/agent.java/plugin/counter.plug b/app/scouter/agent.java/plugin/counter.plug new file mode 100644 index 00000000..b77b4aff --- /dev/null +++ b/app/scouter/agent.java/plugin/counter.plug @@ -0,0 +1,2 @@ +[counter] +//public void counter(scouter.lang.pack.PerfCounterPack $pack) diff --git a/app/scouter/agent.java/plugin/httpcall.plug b/app/scouter/agent.java/plugin/httpcall.plug new file mode 100644 index 00000000..3215bb34 --- /dev/null +++ b/app/scouter/agent.java/plugin/httpcall.plug @@ -0,0 +1,2 @@ +[call] +// void call(WrContext $ctx, WrHttpCallRequest $req) diff --git a/app/scouter/agent.java/plugin/httpservice.plug b/app/scouter/agent.java/plugin/httpservice.plug new file mode 100644 index 00000000..739ba8e1 --- /dev/null +++ b/app/scouter/agent.java/plugin/httpservice.plug @@ -0,0 +1,12 @@ +[start] +// void start(WrContext $ctx, WrRequest $req, WrResponse $res) + + + +[end] +// void end(WrContext $ctx, WrRequest $req, WrResponse $res) + + +[reject] +// boolean reject(WrContext $ctx, WrRequest $req, WrResponse $res) +return false; \ No newline at end of file diff --git a/app/scouter/agent.java/plugin/jdbcpool.plug b/app/scouter/agent.java/plugin/jdbcpool.plug new file mode 100644 index 00000000..91641720 --- /dev/null +++ b/app/scouter/agent.java/plugin/jdbcpool.plug @@ -0,0 +1,4 @@ +[url] +// String url(WrContext $ctx, String $msg, Object $pool) + +return null; \ No newline at end of file diff --git a/app/scouter/agent.java/plugin/readme.md b/app/scouter/agent.java/plugin/readme.md new file mode 100644 index 00000000..6c3b2ee3 --- /dev/null +++ b/app/scouter/agent.java/plugin/readme.md @@ -0,0 +1,138 @@ +## Javaagent Plugin + - Default File Location : ${directory of scouter.agent.jar}/plugin + - Dynamic application + - By java code + - Plugin 종류 + - Http-service + - Service + - HttpCall + - Capture + - JDBC-Pool + +### Http-service Plugin(httpservice.plug) + +1. void start(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 시작 시점 +2. void end(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 종료 시점 +3. boolean reject(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 시작 시점에 reject 조건 (default : false) + +### Service Plugin(service.plug) + **추가적인 hooking 설정을 통해서만 동작** + +1. void start(WrContext $ctx, HookArgs $hook) : Service 시작 시점 +2. void end(WrContext $ctx) : Service 종료 시점 + +### HttpCall Plugin(httpcall.plug) + +1. void call(WrContext $ctx, WrHttpCallRequest $req) : Http Call 요청 시점 + +### Capture Plugin(capture.plug) + **추가적인 hooking 설정을 통해서만 동작** + +1. void capArgs(WrContext $ctx, HookArgs $hook) : Method 시작 시점 +2. void capReturn(WrContext $ctx, HookReturn $hook) : Method Return 시점 +3. void capThis(WrContext $ctx, String $class, String $desc, Object $this) : Constructor 생성 시점 + +### JDBC-Pool Plugin(jdbcpool.plug) + +1. String url(WrContext $ctx, String $msg, Object $pool) + : DB Connection URL 요청 시점 + + +## API + +### Common API + - void log(Object c) : Logger를 통한 log + - void println(Object c) : System.out를 통한 log + - Object field(Object o, String field) : Object의 filed 값을 가져옴 + - Object method(Object o, String method) : Object의 method를 강제invoke 함 + - Object method1(Object o, String method) : Object의 method를 invoke 함 + - Object method(Object o, String method, String param) : Object의 method를 String 파라미터와 함께 invoke 함 + - String toString(Object o) : Object 를 toString 하여 반환 + - String toString(Object o, String def) : Object 를 toString 하여 반환, null 이면 default string 반환 + - void alert(char level, String title, String message) : Alert 을 보냄 + - int syshash(Object o) : Object 의 identityHash 값 반환 + - int syshash(HookArgs hook, int x) : Arguments의 i 인덱스의 identyHash 값 반환 + - int syshash(HookArgs hook) : This 의 identyHash 값 반환 + - void forward(WrContext wctx, int uuid) : Async Thread 를 App service로 연결 + - void forwardThread(WrContext wctx, int uuid) : Async Thread 를 Background service로 연결 + - void receive(WrContext ctx, int uuid) : 앞서 등록된 Service가 있으면 연결 + + +### WrContext class API + - String service() : Service Name 을 반환 + - void service(String name) : Service Name 을 set + - int serviceHash() : Service Hash 값을 반환 + - void remoteIp(String ip) : Remote IP 을 set + - String remoteIp() : Remote IP를 반환 + - void error(String err) : 임의의 error 를 주입 + - boolean isError() : 에러 체크 + - void group(String group) : 임의의 group을 set + - String group() : Group을 반환 + - void login(String id) : 임의의 사용자 ID 를 set + - String login() : 사용자 ID를 반환 + - void desc(String desc) : 임의의 Desc를 set + - String desc() : Desc를 반환 + - String httpMethod() : Http Method를 반환 + - String httpQuery() : Http Query를 반환 + - String httpContentType() : Http Content-type을 반환 + - String userAgent() : User-Agent를 반환 + - void profile(String msg) : Msg 를 profile에 기록 + - long txid() : txid 를 반환 + - long gxid() : gxid 를 반환 + - TraceContext inner() : context를 반환 + +### WrRequest class API + - String getCookie(String key) : Cookie 값을 반환 + - String getRequestURI() : Request URI를 반환 + - String getRemoteAddr() : Remote Address를 반환 + - String getMethod() : Method 를 반환 + - String getQueryString() : Query String을 반환 + - String getParameter(String key) : Parameter를 반환 + - Object getAttribute(String key) : Attribute를 반환 + - String getHeader(String key) : Header값을 반환 + - Enumeration getParameterNames() : Parameter 값들을 반환 + - Enumeration getHeaderNames() : HeaderName들을 반환 + - WrSession getSession() : WrSession객체를 반환 + - Set getSessionNames() : Session Name들을 반환 + - Object getSessionAttribute(String key) : Session 값을 반환 + - Object inner() : Request Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrResponse class API + - PrintWriter getWriter() : Writer를 반환 + - String getContentType() : Content-type을 반환 + - String getCharacterEncoding() : Character-encoding을 반환 + - Object inner() : Response Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrSession class API + - getAttribute(String key) : Attribute를 반환 + - Enumeration getAttributeNames() : Attribute Names를 반환 + - Object inner() : Session Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrHttpCallRequest class API + - void header(Object key, Object value) : Header값 추가 + - Object inner() : Request Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### HookArgs class API + - String getClassName() : Class 이름 반환 + - String getMethodName() : Method 이름 반환 + - String getMethodDesc() : Method 의 Desc 반환 + - Object getThis() : this object 반환 + - Object[] getArgs() : Arguments 반환 + - int getArgCount() : Argument 갯수 반환 + +### HookReturn class API + - String getClassName() : Class 이름 반환 + - String getMethodName() : Method 이름 반환 + - String getMethodDesc() : Method 의 Desc 반환 + - Object getThis() : this object 반환 + - Object getReturn() : Return 값 반환 + + diff --git a/app/scouter/agent.java/plugin/readme_kr.md b/app/scouter/agent.java/plugin/readme_kr.md new file mode 100644 index 00000000..e7bed28d --- /dev/null +++ b/app/scouter/agent.java/plugin/readme_kr.md @@ -0,0 +1,138 @@ +## Javaagent Plugin + - Default File Location : ${${directory of scouter.agent.jar}/plugin}/plugin + - Dynamic application + - By java code + - Plugin 종류 + - Http-service + - Service + - HttpCall + - Capture + - JDBC-Pool + +### Http-service Plugin(httpservice.plug) + +1. void start(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 시작 시점 +2. void end(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 종료 시점 +3. boolean reject(WrContext $ctx, WrRequest $req, WrResponse $res) : Http Service 시작 시점에 reject 조건 (default : false) + +### Service Plugin(service.plug) + **추가적인 hooking 설정을 통해서만 동작** + +1. void start(WrContext $ctx, HookArgs $hook) : Service 시작 시점 +2. void end(WrContext $ctx) : Service 종료 시점 + +### HttpCall Plugin(httpcall.plug) + +1. void call(WrContext $ctx, WrHttpCallRequest $req) : Http Call 요청 시점 + +### Capture Plugin(capture.plug) + **추가적인 hooking 설정을 통해서만 동작** + +1. void capArgs(WrContext $ctx, HookArgs $hook) : Method 시작 시점 +2. void capReturn(WrContext $ctx, HookReturn $hook) : Method Return 시점 +3. void capThis(WrContext $ctx, String $class, String $desc, Object $this) : Constructor 생성 시점 + +### JDBC-Pool Plugin(jdbcpool.plug) + +1. String url(WrContext $ctx, String $msg, Object $pool) + : DB Connection URL 요청 시점 + + +## API + +### Common API + - void log(Object c) : Logger를 통한 log + - void println(Object c) : System.out를 통한 log + - Object field(Object o, String field) : Object의 filed 값을 가져옴 + - Object method(Object o, String method) : Object의 method를 강제invoke 함 + - Object method1(Object o, String method) : Object의 method를 invoke 함 + - Object method(Object o, String method, String param) : Object의 method를 String 파라미터와 함께 invoke 함 + - String toString(Object o) : Object 를 toString 하여 반환 + - String toString(Object o, String def) : Object 를 toString 하여 반환, null 이면 default string 반환 + - void alert(char level, String title, String message) : Alert 을 보냄 + - int syshash(Object o) : Object 의 identityHash 값 반환 + - int syshash(HookArgs hook, int x) : Arguments의 i 인덱스의 identyHash 값 반환 + - int syshash(HookArgs hook) : This 의 identyHash 값 반환 + - void forward(WrContext wctx, int uuid) : Async Thread 를 App service로 연결 + - void forwardThread(WrContext wctx, int uuid) : Async Thread 를 Background service로 연결 + - void receive(WrContext ctx, int uuid) : 앞서 등록된 Service가 있으면 연결 + + +### WrContext class API + - String service() : Service Name 을 반환 + - void service(String name) : Service Name 을 set + - int serviceHash() : Service Hash 값을 반환 + - void remoteIp(String ip) : Remote IP 을 set + - String remoteIp() : Remote IP를 반환 + - void error(String err) : 임의의 error 를 주입 + - boolean isError() : 에러 체크 + - void group(String group) : 임의의 group을 set + - String group() : Group을 반환 + - void login(String id) : 임의의 사용자 ID 를 set + - String login() : 사용자 ID를 반환 + - void desc(String desc) : 임의의 Desc를 set + - String desc() : Desc를 반환 + - String httpMethod() : Http Method를 반환 + - String httpQuery() : Http Query를 반환 + - String httpContentType() : Http Content-type을 반환 + - String userAgent() : User-Agent를 반환 + - void profile(String msg) : Msg 를 profile에 기록 + - long txid() : txid 를 반환 + - long gxid() : gxid 를 반환 + - TraceContext inner() : context를 반환 + +### WrRequest class API + - String getCookie(String key) : Cookie 값을 반환 + - String getRequestURI() : Request URI를 반환 + - String getRemoteAddr() : Remote Address를 반환 + - String getMethod() : Method 를 반환 + - String getQueryString() : Query String을 반환 + - String getParameter(String key) : Parameter를 반환 + - Object getAttribute(String key) : Attribute를 반환 + - String getHeader(String key) : Header값을 반환 + - Enumeration getParameterNames() : Parameter 값들을 반환 + - Enumeration getHeaderNames() : HeaderName들을 반환 + - WrSession getSession() : WrSession객체를 반환 + - Set getSessionNames() : Session Name들을 반환 + - Object getSessionAttribute(String key) : Session 값을 반환 + - Object inner() : Request Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrResponse class API + - PrintWriter getWriter() : Writer를 반환 + - String getContentType() : Content-type을 반환 + - String getCharacterEncoding() : Character-encoding을 반환 + - Object inner() : Response Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrSession class API + - getAttribute(String key) : Attribute를 반환 + - Enumeration getAttributeNames() : Attribute Names를 반환 + - Object inner() : Session Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### WrHttpCallRequest class API + - void header(Object key, Object value) : Header값 추가 + - Object inner() : Request Object를 반환 + - boolean isOk() : Plugin 상태 확인 + - Throwable error() : Error 확인 + +### HookArgs class API + - String getClassName() : Class 이름 반환 + - String getMethodName() : Method 이름 반환 + - String getMethodDesc() : Method 의 Desc 반환 + - Object getThis() : this object 반환 + - Object[] getArgs() : Arguments 반환 + - int getArgCount() : Argument 갯수 반환 + +### HookReturn class API + - String getClassName() : Class 이름 반환 + - String getMethodName() : Method 이름 반환 + - String getMethodDesc() : Method 의 Desc 반환 + - Object getThis() : this object 반환 + - Object getReturn() : Return 값 반환 + + diff --git a/app/scouter/agent.java/plugin/service.plug b/app/scouter/agent.java/plugin/service.plug new file mode 100644 index 00000000..8b30426c --- /dev/null +++ b/app/scouter/agent.java/plugin/service.plug @@ -0,0 +1,6 @@ +[start] +// void start(WrContext $ctx, HookArgs $hook) + + +[end] +// void end(WrContext $ctx) diff --git a/app/scouter/agent.java/plugin/springControllerCapture.plug b/app/scouter/agent.java/plugin/springControllerCapture.plug new file mode 100644 index 00000000..f11e5b32 --- /dev/null +++ b/app/scouter/agent.java/plugin/springControllerCapture.plug @@ -0,0 +1,11 @@ +[args] +// void capArgs(WrContext $ctx, HookArgs $hook) + + + +[return] +// unused + + +[this] +// unused diff --git a/app/scouter/agent.java/scouter-agent-java-2.21.2.jar b/app/scouter/agent.java/scouter-agent-java-2.21.2.jar new file mode 100644 index 00000000..71fd072a Binary files /dev/null and b/app/scouter/agent.java/scouter-agent-java-2.21.2.jar differ diff --git a/app/scouter/agent.java/scouter.agent.jar b/app/scouter/agent.java/scouter.agent.jar new file mode 100644 index 00000000..685d3c68 Binary files /dev/null and b/app/scouter/agent.java/scouter.agent.jar differ diff --git a/build.gradle b/build.gradle index 5a2bcd39..e53f8059 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { group = "com.icc.qasker" -version = "1.6.5" +version = "1.7.0" subprojects { apply plugin: 'java' @@ -38,11 +38,6 @@ subprojects { testImplementation "org.springframework.boot:spring-boot-starter-test" testRuntimeOnly "org.junit.platform:junit-platform-launcher" - // ──────────────────────────────── - // APM - // ──────────────────────────────── - implementation "com.newrelic.agent.java:newrelic-api:8.11.0" - // ──────────────────────────────── // 스프링부트 web과 JPA // ──────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index ca5db939..6a099280 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,17 +15,22 @@ services: volumes: - q-asker-db:/var/lib/mysql - q-asker-redis: - image: redis:latest - container_name: q-asker-redis + scouter-server: + image: scouterapm/scouter-server:2.17.1 + container_name: scouter-server restart: no ports: - - "6379:6379" + - "6100:6100/tcp" + - "6100:6100/udp" + environment: + - JAVA_OPTS=-Xmx512m volumes: - - q-asker-redis:/data - command: > - redis-server --requirepass ${REDIS_PASSWORD} + - scouter-server-data:/home/scouter-server/database + deploy: + resources: + limits: + memory: 1024m volumes: q-asker-db: - q-asker-redis: \ No newline at end of file + scouter-server-data: \ No newline at end of file diff --git a/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalJoinService.java b/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalJoinService.java deleted file mode 100644 index 9dc458ab..00000000 --- a/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalJoinService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.icc.qasker.auth; - -import com.icc.qasker.auth.dto.request.JoinRequest; - -public interface NormalJoinService { - - void register(JoinRequest normalJoinRequest); -} - diff --git a/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalLoginService.java b/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalLoginService.java deleted file mode 100644 index 117baae5..00000000 --- a/modules/auth/api/src/main/java/com/icc/qasker/auth/NormalLoginService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.icc.qasker.auth; - -import com.icc.qasker.auth.dto.request.LoginRequest; -import com.icc.qasker.auth.dto.response.LoginResponse; - -public interface NormalLoginService { - - LoginResponse getNickname(LoginRequest loginRequest); -} - diff --git a/modules/auth/api/src/main/java/com/icc/qasker/auth/TokenRotationService.java b/modules/auth/api/src/main/java/com/icc/qasker/auth/TokenRotationService.java index 82310c91..d77dcfac 100644 --- a/modules/auth/api/src/main/java/com/icc/qasker/auth/TokenRotationService.java +++ b/modules/auth/api/src/main/java/com/icc/qasker/auth/TokenRotationService.java @@ -1,13 +1,14 @@ package com.icc.qasker.auth; +import com.icc.qasker.auth.dto.response.RotateTokenResponse; import jakarta.servlet.http.HttpServletResponse; public interface TokenRotationService { void issueRefreshToken(String userId, HttpServletResponse response); - void issueTokens(String userId, HttpServletResponse response); + RotateTokenResponse issueTokens(String userId, HttpServletResponse response); - String rotateTokens(String refreshToken, HttpServletResponse response); + RotateTokenResponse rotateTokens(String refreshToken, HttpServletResponse response); } diff --git a/modules/auth/api/src/main/java/com/icc/qasker/auth/dto/response/RotateTokenResponse.java b/modules/auth/api/src/main/java/com/icc/qasker/auth/dto/response/RotateTokenResponse.java new file mode 100644 index 00000000..4b10cffc --- /dev/null +++ b/modules/auth/api/src/main/java/com/icc/qasker/auth/dto/response/RotateTokenResponse.java @@ -0,0 +1,7 @@ +package com.icc.qasker.auth.dto.response; + +public record RotateTokenResponse( + String accessToken +) { + +} \ No newline at end of file diff --git a/modules/auth/impl/build.gradle b/modules/auth/impl/build.gradle index 3e312f09..04e2bcb1 100644 --- a/modules/auth/impl/build.gradle +++ b/modules/auth/impl/build.gradle @@ -12,9 +12,8 @@ dependencies { implementation "org.hashids:hashids:1.0.3" implementation "com.auth0:java-jwt:4.5.0" // ──────────────────────────────── - // 스프링 부트와 레디스 + // 스프링 부트와 OAuth // ──────────────────────────────── - implementation "org.springframework.boot:spring-boot-starter-data-redis" implementation "org.springframework.boot:spring-boot-starter-oauth2-client" annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } \ No newline at end of file diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java index 3e7e3cbd..a9dd7b44 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/component/AccessTokenHandler.java @@ -2,10 +2,10 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.icc.qasker.auth.repository.UserRepository; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.auth.properties.JwtProperties; -import com.icc.qasker.auth.repository.UserRepository; +import com.icc.qasker.global.properties.JwtProperties; import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,6 +14,7 @@ @RequiredArgsConstructor public class AccessTokenHandler { + private final JwtProperties jwtProperties; private final UserRepository userRepository; public String validateAndGenerate(String userId) { @@ -23,8 +24,8 @@ public String validateAndGenerate(String userId) { .withClaim("userId", user.getUserId()) .withClaim("nickname", user.getNickname()) .withExpiresAt( - new Date(System.currentTimeMillis() + JwtProperties.ACCESS_EXPIRATION_TIME)) - .sign(Algorithm.HMAC512(JwtProperties.SECRET))) + new Date(System.currentTimeMillis() + jwtProperties.getAccessExpirationTime())) + .sign(Algorithm.HMAC512(jwtProperties.getSecret()))) .orElseThrow(() -> new CustomException(ExceptionMessage.USER_NOT_FOUND)); } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/RedisConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/RedisConfig.java deleted file mode 100644 index 7294fe68..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/RedisConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.icc.qasker.auth.config; - -import com.icc.qasker.auth.properties.RedisProperties; -import lombok.AllArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; - -@Configuration -@AllArgsConstructor -public class RedisConfig { - - private final RedisProperties redisProperties; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(redisProperties.getRedisConfiguration()); - } -} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java new file mode 100644 index 00000000..15c14d0f --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package com.icc.qasker.auth.config; + +import com.icc.qasker.auth.config.security.filter.JwtTokenAuthenticationFilter; +import com.icc.qasker.auth.config.security.handler.OAuth2LoginSuccessHandler; +import com.icc.qasker.auth.config.security.service.PrincipalOAuth2UserService; +import com.icc.qasker.auth.repository.UserRepository; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.global.properties.JwtProperties; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.AllArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@AllArgsConstructor +public class SecurityConfig { + + private final UserRepository userRepository; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final PrincipalOAuth2UserService principalOauth2UserService; + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain apiFilterChain(HttpSecurity http, + AuthenticationManager authenticationManager, JwtProperties jwtProperties) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> { + createUnauthorizedResponse(response); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + createForbiddenResponse(response); + }) + ) + .addFilterBefore( + new JwtTokenAuthenticationFilter(authenticationManager, userRepository, + jwtProperties), + UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/statistics/**", "/test").authenticated() + .anyRequest().permitAll() + ) + .oauth2Login(oauth -> oauth + .userInfoEndpoint(user -> user.userService(principalOauth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + ); + return http.build(); + } + + public void createUnauthorizedResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"message\": \"" + ExceptionMessage.UNAUTHORIZED.getMessage() + "\"}" + ); + } + + public void createForbiddenResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"message\": \"" + ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage() + "\"}" + ); + } +} \ No newline at end of file diff --git a/modules/global/src/main/java/com/icc/qasker/global/config/WebMvcConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java similarity index 63% rename from modules/global/src/main/java/com/icc/qasker/global/config/WebMvcConfig.java rename to modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java index 1aa3294e..e51a996d 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/config/WebMvcConfig.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/WebMvcConfig.java @@ -1,8 +1,11 @@ -package com.icc.qasker.global.config; +package com.icc.qasker.auth.config; +import com.icc.qasker.auth.resolver.UserIdArgumentResolver; import com.icc.qasker.global.properties.QAskerProperties; +import java.util.List; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -10,6 +13,7 @@ @AllArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { + private final UserIdArgumentResolver userIdArgumentResolver; private final QAskerProperties qAskerProperties; @Override @@ -18,6 +22,12 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(qAskerProperties.getFrontendDevUrl(), qAskerProperties.getFrontendDeployUrl()) .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowCredentials(true) .maxAge(3600); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdArgumentResolver); + } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/JwtTokenAuthenticationFilter.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java similarity index 90% rename from modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/JwtTokenAuthenticationFilter.java rename to modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java index eaf753f3..8d3f562d 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/JwtTokenAuthenticationFilter.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/filter/JwtTokenAuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.icc.qasker.auth.security.filter; +package com.icc.qasker.auth.config.security.filter; import static com.auth0.jwt.JWT.require; @@ -6,8 +6,8 @@ import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.TokenExpiredException; import com.icc.qasker.auth.entity.User; -import com.icc.qasker.auth.properties.JwtProperties; import com.icc.qasker.auth.repository.UserRepository; +import com.icc.qasker.global.properties.JwtProperties; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -27,11 +27,13 @@ public class JwtTokenAuthenticationFilter extends BasicAuthenticationFilter { private static final String BEARER_PREFIX = "Bearer "; + private final JwtProperties jwtProperties; private final UserRepository userRepository; public JwtTokenAuthenticationFilter(AuthenticationManager authManager, - UserRepository userRepository) { + UserRepository userRepository, JwtProperties jwtProperties) { super(authManager); + this.jwtProperties = jwtProperties; this.userRepository = userRepository; } @@ -46,7 +48,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); try { - var decoded = require(Algorithm.HMAC512(JwtProperties.SECRET)).build() + var decoded = require(Algorithm.HMAC512(jwtProperties.getSecret())).build() .verify(accessToken); String userId = decoded.getClaim("userId").asString(); diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/handler/OAuth2LoginSuccessHandler.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..62177f41 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,32 @@ +package com.icc.qasker.auth.config.security.handler; + +import com.icc.qasker.auth.TokenRotationService; +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.principal.UserPrincipal; +import com.icc.qasker.global.properties.QAskerProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final TokenRotationService tokenRotationService; + private final QAskerProperties qAskerProperties; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + User user = principal.getUser(); + tokenRotationService.issueRefreshToken(user.getUserId(), response); + response.sendRedirect(qAskerProperties.getFrontendDeployUrl() + "/login/redirect"); + } +} \ No newline at end of file diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/GoogleUserInfo.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/GoogleUserInfo.java similarity index 88% rename from modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/GoogleUserInfo.java rename to modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/GoogleUserInfo.java index f4dad849..a67cce51 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/GoogleUserInfo.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/GoogleUserInfo.java @@ -1,4 +1,4 @@ -package com.icc.qasker.auth.security.provider; +package com.icc.qasker.auth.config.security.provider; import java.util.Map; diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/KakaoUserInfo.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/KakaoUserInfo.java similarity index 88% rename from modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/KakaoUserInfo.java rename to modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/KakaoUserInfo.java index 5fdadd20..dfbc8316 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/KakaoUserInfo.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/KakaoUserInfo.java @@ -1,4 +1,4 @@ -package com.icc.qasker.auth.security.provider; +package com.icc.qasker.auth.config.security.provider; import java.util.Map; diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/OAuth2UserInfo.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/OAuth2UserInfo.java similarity index 63% rename from modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/OAuth2UserInfo.java rename to modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/OAuth2UserInfo.java index 72637b25..adf6066b 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/provider/OAuth2UserInfo.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/provider/OAuth2UserInfo.java @@ -1,4 +1,4 @@ -package com.icc.qasker.auth.security.provider; +package com.icc.qasker.auth.config.security.provider; public interface OAuth2UserInfo { diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/service/PrincipalOAuth2UserService.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/service/PrincipalOAuth2UserService.java new file mode 100644 index 00000000..25dfd4db --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/config/security/service/PrincipalOAuth2UserService.java @@ -0,0 +1,57 @@ +package com.icc.qasker.auth.config.security.service; + +import com.icc.qasker.auth.config.security.provider.GoogleUserInfo; +import com.icc.qasker.auth.config.security.provider.KakaoUserInfo; +import com.icc.qasker.auth.config.security.provider.OAuth2UserInfo; +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.principal.UserPrincipal; +import com.icc.qasker.auth.repository.UserRepository; +import com.icc.qasker.auth.util.NicknameGenerateUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@AllArgsConstructor +public class PrincipalOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) + throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2UserInfo oAuth2UserInfo = null; + if (registrationId.equals("google")) { + oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes()); + } else if (registrationId.equals("kakao")) { + oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + } else { + log.error("Unsupported OAuth2 provider: {}", registrationId); + throw new CustomException(ExceptionMessage.DEFAULT_ERROR); + } + String userId = oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId(); + String provider = oAuth2UserInfo.getProvider(); + String nickname = NicknameGenerateUtil.generate(); + String role = "ROLE_USER"; + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + user = User.builder() + .userId(userId) + .role(role) + .nickname(nickname) + .provider(provider) + .build(); + userRepository.save(user); + } + return new UserPrincipal(user, oAuth2User.getAttributes()); + } +} \ No newline at end of file diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java index 8758196e..39959606 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/AuthController.java @@ -1,20 +1,17 @@ package com.icc.qasker.auth.controller; import com.icc.qasker.auth.LogoutService; -import com.icc.qasker.auth.NormalJoinService; -import com.icc.qasker.auth.NormalLoginService; import com.icc.qasker.auth.TokenRotationService; -import com.icc.qasker.auth.dto.request.JoinRequest; -import com.icc.qasker.auth.dto.request.LoginRequest; -import com.icc.qasker.auth.dto.response.LoginResponse; +import com.icc.qasker.auth.dto.response.RotateTokenResponse; import com.icc.qasker.auth.util.CookieUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -23,30 +20,21 @@ @RequestMapping("/auth") public class AuthController { - private final NormalJoinService normalJoinService; - private final NormalLoginService normalLoginService; private final TokenRotationService tokenRotationService; private final LogoutService logoutService; - @PostMapping("/join") - public ResponseEntity normalJoin(@RequestBody JoinRequest normalJoinRequest) { - normalJoinService.register(normalJoinRequest); - return ResponseEntity.status(HttpStatus.CREATED).build(); - } - - @PostMapping("/login") - public ResponseEntity normalLogin(@RequestBody LoginRequest loginRequest, - HttpServletResponse response) { - LoginResponse loginResponse = normalLoginService.getNickname(loginRequest); - tokenRotationService.issueTokens(loginRequest.getUserId(), response); - return ResponseEntity.ok(loginResponse); + @GetMapping("/test") + public ResponseEntity test() { + System.out.println("test 성공"); + return ResponseEntity.ok().build(); } @PostMapping("/refresh") - public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { - var rtCookie = CookieUtil.getCookie(request, "refresh_token").orElse(null); - tokenRotationService.rotateTokens(rtCookie.getValue(), response); - return ResponseEntity.ok().build(); + public ResponseEntity refresh(HttpServletRequest request, + HttpServletResponse response) { + var rtCookie = CookieUtil.getCookie(request, "refresh_token") + .orElseThrow(() -> new CustomException(ExceptionMessage.UNAUTHORIZED)); + return ResponseEntity.ok(tokenRotationService.rotateTokens(rtCookie.getValue(), response)); } @PostMapping("/logout") diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/TestController.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/TestController.java deleted file mode 100644 index 4d9106d5..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/controller/TestController.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.icc.qasker.auth.controller; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class TestController { - - @GetMapping("/test") - public ResponseEntity test() { - System.out.println("test 성공"); - return ResponseEntity.ok().build(); - } -} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/RefreshToken.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/RefreshToken.java index 503213f3..3cb92790 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/RefreshToken.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/RefreshToken.java @@ -1,19 +1,34 @@ package com.icc.qasker.auth.entity; +import com.icc.qasker.global.entity.CreatedAt; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.Instant; import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.ToString; -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; +import lombok.NoArgsConstructor; -@RedisHash(value = "refreshToken", timeToLive = 0) +@Entity @Getter -@ToString -@RequiredArgsConstructor - -public class RefreshToken { +@NoArgsConstructor +public class RefreshToken extends CreatedAt { @Id - private final String rtHash; - private final String userId; + private String userId; + private String rtHash; + private Instant expiresAt; + + public RefreshToken(String userId, String rtHash, Instant expiresAt) { + this.userId = userId; + this.rtHash = rtHash; + this.expiresAt = expiresAt; + } + + public void rotate(String newRtHash, Instant newExpiresAt) { + this.rtHash = newRtHash; + this.expiresAt = newExpiresAt; + } + + public boolean isExpired(Instant now) { + return expiresAt != null && expiresAt.isBefore(now); + } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/User.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/User.java index eaaa320b..e4bf5b1b 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/User.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/entity/User.java @@ -8,23 +8,19 @@ import lombok.NoArgsConstructor; @Entity -@NoArgsConstructor @Getter +@NoArgsConstructor public class User extends CreatedAt { @Id private String userId; - private String password; private String role; private String provider; private String nickname; @Builder - private User(String userId, String password, String role, String provider, - String nickname) { - super(); + public User(String userId, String password, String role, String provider, String nickname) { this.userId = userId; - this.password = password; this.role = role; this.provider = provider; this.nickname = nickname; diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/principal/UserPrincipal.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/principal/UserPrincipal.java new file mode 100644 index 00000000..ced2fb5d --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/principal/UserPrincipal.java @@ -0,0 +1,35 @@ +package com.icc.qasker.auth.principal; + +import com.icc.qasker.auth.entity.User; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Data +@AllArgsConstructor +public class UserPrincipal implements OAuth2User { + + private User user; + private Map attributes; + + @Override + public String getName() { + return user.getUserId(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + Collection collect = new ArrayList(); + collect.add(() -> user.getRole()); + return collect; + } +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/JwtProperties.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/JwtProperties.java deleted file mode 100644 index b7b5105a..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/JwtProperties.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.icc.qasker.auth.properties; - -import lombok.Getter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@ConfigurationProperties(prefix = "jwt") -public class JwtProperties { - - public static String SECRET; - public static long ACCESS_EXPIRATION_TIME; // ms - public static long REFRESH_EXPIRATION_TIME; // s - private String secret; - private long accessExpirationTime; // s - private long refreshExpirationTime; // s - - public void setSecret(String secret) { - this.secret = secret; - SECRET = secret; - } - - public void setAccessExpirationTime(long seconds) { - this.accessExpirationTime = seconds; - ACCESS_EXPIRATION_TIME = seconds * 1000; // ms 변경 - } - - public void setRefreshExpirationTime(long seconds) { - this.refreshExpirationTime = seconds; - REFRESH_EXPIRATION_TIME = seconds; - } -} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/RedisProperties.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/RedisProperties.java deleted file mode 100644 index 3652a1da..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/properties/RedisProperties.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.icc.qasker.auth.properties; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.data.redis.connection.RedisConfiguration; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; - -@ConfigurationProperties(prefix = "spring.data.redis") -@Getter -@AllArgsConstructor -public class RedisProperties { - - private String host; - private int port; - private String password; - private int database; - - public RedisConfiguration getRedisConfiguration() { - - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); - config.setHostName(host); - config.setPort(port); - config.setPassword(password); - config.setDatabase(database); - - return config; - } -} \ No newline at end of file diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/repository/RefreshTokenRepository.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..e7653f61 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.icc.qasker.auth.repository; + +import com.icc.qasker.auth.entity.RefreshToken; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByRtHash(String rtHash); +} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/PasswordEncoderConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/PasswordEncoderConfig.java deleted file mode 100644 index 81976b33..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/PasswordEncoderConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.icc.qasker.auth.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -@Configuration -public class PasswordEncoderConfig { - - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); - } -} - diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/SecurityConfig.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/SecurityConfig.java deleted file mode 100644 index 3d8a43a9..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/SecurityConfig.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.icc.qasker.auth.security; - -import com.icc.qasker.auth.TokenRotationService; -import com.icc.qasker.auth.entity.User; -import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.auth.repository.UserRepository; -import com.icc.qasker.auth.security.filter.JwtTokenAuthenticationFilter; -import com.icc.qasker.auth.security.filter.RefreshRotationFilter; -import com.icc.qasker.auth.security.principal.PrincipalDetails; -import com.icc.qasker.auth.security.provider.GoogleUserInfo; -import com.icc.qasker.auth.security.provider.KakaoUserInfo; -import com.icc.qasker.auth.security.provider.OAuth2UserInfo; -import com.icc.qasker.auth.util.NicknameGenerateUtil; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import lombok.AllArgsConstructor; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.stereotype.Component; - -@Configuration -@EnableWebSecurity -@AllArgsConstructor -public class SecurityConfig { - - private final PrincipalOAuth2UserService principalOauth2UserService; - private final UserRepository userRepository; - private final TokenRotationService tokenRotationService; - private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; - - @Bean - public AuthenticationManager authenticationManager( - AuthenticationConfiguration authenticationConfiguration) throws Exception { - return authenticationConfiguration.getAuthenticationManager(); - } - - // JWT 필터 통하는 것 - @Bean - @Order(1) - public SecurityFilterChain apiFilterChain(HttpSecurity http, - AuthenticationManager authenticationManager) throws Exception { - http - .securityMatcher("/statistics/**", "/admin/**", "/test") - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .exceptionHandling(ex -> ex - .authenticationEntryPoint((request, response, authException) -> { - createUnauthorizedResponse(response); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - createForbiddenResponse(response); - }) - ) - .addFilterBefore( - new JwtTokenAuthenticationFilter(authenticationManager, userRepository), - UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(new RefreshRotationFilter(tokenRotationService), - JwtTokenAuthenticationFilter.class) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/statistics/**").authenticated() - .requestMatchers("/admin/**").hasRole("ADMIN") - .requestMatchers("/test").authenticated() - .anyRequest().denyAll() // 이 필터 체인에 해당하지만 위에서 명시되지 않은 다른 모든 요청은 거부 - ); - return http.build(); - } - - // JWT 필터 통하지 않는 것 - @Bean - @Order(2) - public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .exceptionHandling(ex -> ex - .authenticationEntryPoint((request, response, authException) -> { - createUnauthorizedResponse(response); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - createForbiddenResponse(response); - }) - ) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // 나머지 모든 요청 허용 - ) - .oauth2Login(oauth -> oauth - .userInfoEndpoint(user -> user.userService(principalOauth2UserService)) - .successHandler(oAuth2LoginSuccessHandler) - ); - return http.build(); - } - - - public void createUnauthorizedResponse(HttpServletResponse response) { - try { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write( - "{\"message\": \"" + ExceptionMessage.UNAUTHORIZED.getMessage() + "\"}" - ); - } catch (IOException e) { - } - } - - public void createForbiddenResponse(HttpServletResponse response) { - try { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write( - "{\"message\": \"" + ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage() + "\"}" - ); - } catch (IOException e) { - } - } - - @Component - @RequiredArgsConstructor - public static class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { - - private final TokenRotationService tokenRotationService; - @Value("${q-asker.frontend-deploy-url}") - private String frontendDeployUrl; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException { - - PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal(); - User user = principal.getUser(); - tokenRotationService.issueRefreshToken(user.getUserId(), response); - response.sendRedirect(frontendDeployUrl); - } - } - - @Component - @AllArgsConstructor - public static class PrincipalOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - private final BCryptPasswordEncoder bCryptPasswordEncoder; - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) - throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - OAuth2UserInfo oAuth2UserInfo = null; - if (userRequest.getClientRegistration().getRegistrationId().equals("google")) { - oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes()); - } else if (userRequest.getClientRegistration().getRegistrationId() - .equals("kakao")) { - oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); - } - String userId = oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId(); - String provider = oAuth2UserInfo.getProvider(); - String nickname = NicknameGenerateUtil.generate(); - String password = bCryptPasswordEncoder.encode("temporaryPassword"); - String role = "ROLE_USER"; - User user = userRepository.findById(userId).orElse(null); - if (user == null) { - user = User.builder() - .userId(userId) - .password(password) - .role(role) - .nickname(nickname) - .provider(provider) - .build(); - userRepository.save(user); - } - return new PrincipalDetails(user, oAuth2User.getAttributes()); - } - } -} \ No newline at end of file diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/RefreshRotationFilter.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/RefreshRotationFilter.java deleted file mode 100644 index 28ee730e..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/filter/RefreshRotationFilter.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.icc.qasker.auth.security.filter; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.icc.qasker.auth.TokenRotationService; -import com.icc.qasker.auth.properties.JwtProperties; -import com.icc.qasker.auth.util.CookieUtil; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.web.filter.OncePerRequestFilter; - -@RequiredArgsConstructor -public class RefreshRotationFilter extends OncePerRequestFilter { - - private static final String BEARER_PREFIX = "Bearer "; - private final TokenRotationService tokenRotationService; - - private boolean skip(String path) { - // 인증이 필요하지 않은 url 적기 - return path.startsWith("/auth/") - || path.startsWith("/oauth2") - || path.startsWith("/login/oauth2") - || path.startsWith("/s3/upload") - || path.startsWith("/explanation/") - || path.startsWith("/problem-set/") - || path.startsWith("/specific-explanation/") - || path.startsWith("/generation"); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - if (skip(request.getRequestURI())) { - filterChain.doFilter(request, response); - return; - } - String auth = request.getHeader(HttpHeaders.AUTHORIZATION); - if (auth != null && auth.startsWith(BEARER_PREFIX)) { - String at = auth.substring(BEARER_PREFIX.length()); - try { - JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(at); - filterChain.doFilter(request, response); - return; - } catch (Exception e) { - System.out.println(e.getMessage()); - } - } - - var rtCookie = CookieUtil.getCookie(request, "refresh_token").orElse(null); - if (rtCookie == null) { - filterChain.doFilter(request, response); - return; - } - try { - String newAt = tokenRotationService.rotateTokens(rtCookie.getValue(), response); - CustomHttpServletRequest customRequest = new CustomHttpServletRequest(request); - customRequest.putHeader(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + newAt); - filterChain.doFilter(customRequest, response); - } catch (Exception e) { - filterChain.doFilter(request, response); - } - } - - private static class CustomHttpServletRequest extends HttpServletRequestWrapper { - - private final Map customHeaders = new HashMap<>(); - - public CustomHttpServletRequest(HttpServletRequest request) { - super(request); - } - - public void putHeader(String name, String value) { - customHeaders.put(name, value); - } - - @Override - public String getHeader(String name) { - String v = customHeaders.get(name); - if (v != null) { - return v; - } - return super.getHeader(name); - } - - @Override - public Enumeration getHeaders(String name) { - String v = customHeaders.get(name); - if (v != null) { - return Collections.enumeration(List.of(v)); - } - return super.getHeaders(name); - } - - @Override - public Enumeration getHeaderNames() { - Set names = new HashSet<>(customHeaders.keySet()); - Enumeration e = super.getHeaderNames(); - while (e.hasMoreElements()) { - names.add(e.nextElement()); - } - return Collections.enumeration(names); - } - } -} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/principal/PrincipalDetails.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/principal/PrincipalDetails.java deleted file mode 100644 index 33acedb7..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/security/principal/PrincipalDetails.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.icc.qasker.auth.security.principal; - -import com.icc.qasker.auth.entity.User; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Map; -import lombok.Data; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; - -@Data -public class PrincipalDetails implements UserDetails, OAuth2User { - - private User user; - private Map attributes; - - public PrincipalDetails(User user) { - this.user = user; - } - - public PrincipalDetails(User user, Map attributes) { - this.user = user; - this.attributes = attributes; - } - - @Override - public String getPassword() { - return user.getPassword(); - } - - @Override - public String getUsername() { - return user.getUserId(); - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } - - @Override - public String getName() { - return null; - } - - @Override - public Map getAttributes() { - return attributes; - } - - @Override - public Collection getAuthorities() { - Collection collect = new ArrayList(); - collect.add(() -> { - return user.getRole(); - }); - return collect; - } -} diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalJoinServiceImpl.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalJoinServiceImpl.java deleted file mode 100644 index 3bf4eae3..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalJoinServiceImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.icc.qasker.auth.service; - -import com.icc.qasker.auth.NormalJoinService; -import com.icc.qasker.auth.dto.request.JoinRequest; -import com.icc.qasker.auth.entity.User; -import com.icc.qasker.global.error.CustomException; -import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.auth.repository.UserRepository; -import com.icc.qasker.auth.util.NicknameGenerateUtil; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NormalJoinServiceImpl implements NormalJoinService { - - private final UserRepository userRepository; - private final BCryptPasswordEncoder bCryptPasswordEncoder; - - @Override - public void register(JoinRequest normalJoinRequest) { - String userId = normalJoinRequest.getUserId(); - - if (userRepository.existsByUserId(userId)) { - throw new CustomException(ExceptionMessage.DUPLICATE_USERNAME); - } - - String nickname = NicknameGenerateUtil.generate(); - String password = bCryptPasswordEncoder.encode(normalJoinRequest.getPassword()); - User user = User.builder() - .userId(userId) - .password(password) - .role("ROLE_USER") - .provider(null) - .nickname(nickname) - .build(); - userRepository.save(user); - System.out.println("기본 회원가입 완료, " + nickname); - } -} - diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalLoginServiceImpl.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalLoginServiceImpl.java deleted file mode 100644 index 918794cf..00000000 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/NormalLoginServiceImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.icc.qasker.auth.service; - -import com.icc.qasker.auth.NormalLoginService; -import com.icc.qasker.auth.dto.request.LoginRequest; -import com.icc.qasker.auth.dto.response.LoginResponse; -import com.icc.qasker.auth.entity.User; -import com.icc.qasker.global.error.CustomException; -import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.auth.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NormalLoginServiceImpl implements NormalLoginService { - - private final UserRepository userRepository; - private final BCryptPasswordEncoder bCryptPasswordEncoder; - - @Override - public LoginResponse getNickname(LoginRequest loginRequest) { - String userId = loginRequest.getUserId(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ExceptionMessage.USER_NOT_FOUND)); - - if (!bCryptPasswordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { - throw new CustomException(ExceptionMessage.INVALID_PASSWORD); - } - System.out.println("로그인 완료: " + user.getUserId() + " 닉네임: " + user.getNickname()); - return LoginResponse.builder() - .nickname(user.getNickname()).build(); - } -} - diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/TokenRotationServiceImpl.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/TokenRotationServiceImpl.java index ba3239dc..f2764046 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/TokenRotationServiceImpl.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/service/TokenRotationServiceImpl.java @@ -2,8 +2,10 @@ import com.icc.qasker.auth.TokenRotationService; import com.icc.qasker.auth.component.AccessTokenHandler; +import com.icc.qasker.auth.dto.response.RotateTokenResponse; import com.icc.qasker.auth.util.CookieUtil; import com.icc.qasker.auth.util.RefreshTokenUtil; +import com.icc.qasker.global.properties.JwtProperties; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -13,6 +15,7 @@ @RequiredArgsConstructor public class TokenRotationServiceImpl implements TokenRotationService { + private final JwtProperties jwtProperties; private final RefreshTokenUtil refreshTokenUtil; private final AccessTokenHandler accessTokenHandler; @@ -23,31 +26,27 @@ public void issueRefreshToken(String userId, HttpServletResponse response) { } @Override - public void issueTokens(String userId, HttpServletResponse response) { + public RotateTokenResponse issueTokens(String userId, HttpServletResponse response) { String newRtPlain = refreshTokenUtil.issue(userId); String newAt = accessTokenHandler.validateAndGenerate(userId); - setAccessToken(response, newAt); setRefreshToken(response, newRtPlain); + return new RotateTokenResponse(newAt); } @Override - public String rotateTokens(String refreshToken, HttpServletResponse response) { + public RotateTokenResponse rotateTokens(String refreshToken, HttpServletResponse response) { var newRtCookie = refreshTokenUtil.validateAndRotate(refreshToken); String newAt = accessTokenHandler.validateAndGenerate(newRtCookie.userId()); - setAccessToken(response, newAt); setRefreshToken(response, newRtCookie.newRtPlain()); - return newAt; - } - - private void setAccessToken(HttpServletResponse response, String newAt) { - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + newAt); + return new RotateTokenResponse(newAt); } private void setRefreshToken(HttpServletResponse response, String newRtPlain) { response.setHeader(HttpHeaders.SET_COOKIE, - CookieUtil.buildCookies(newRtPlain).toString()); + CookieUtil.buildCookies(newRtPlain, jwtProperties.getAccessExpirationTime()) + .toString()); } } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/CookieUtil.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/CookieUtil.java index b40bce9d..50670c88 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/CookieUtil.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/CookieUtil.java @@ -1,22 +1,24 @@ package com.icc.qasker.auth.util; -import com.icc.qasker.auth.properties.JwtProperties; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.Optional; -import lombok.RequiredArgsConstructor; +import lombok.AllArgsConstructor; import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; -@RequiredArgsConstructor +@Component +@AllArgsConstructor public class CookieUtil { - public static ResponseCookie buildCookies(String value) { + + public static ResponseCookie buildCookies(String value, long expireTime) { return ResponseCookie.from("refresh_token", value) .httpOnly(true) .secure(true) .path("/") - .maxAge(JwtProperties.REFRESH_EXPIRATION_TIME) + .maxAge(expireTime) .build(); } diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java index 22c8d009..79415043 100644 --- a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/RefreshTokenUtil.java @@ -1,87 +1,73 @@ package com.icc.qasker.auth.util; +import com.icc.qasker.auth.entity.RefreshToken; +import com.icc.qasker.auth.repository.RefreshTokenRepository; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.auth.properties.JwtProperties; +import com.icc.qasker.global.properties.JwtProperties; +import jakarta.transaction.Transactional; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; -import java.time.Duration; +import java.time.Instant; import java.util.Base64; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class RefreshTokenUtil { - private final StringRedisTemplate redis; - private final RtKeys rtKeys; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtProperties jwtProperties; public String issue(String userId) { try { String rtPlain = TokenUtils.randomUrlSafe(64); String rtHash = TokenUtils.sha256Hex(rtPlain); - String setKey = rtKeys.userSet(userId); - - redis.opsForHash().put(rtHash, "userId", userId); - redis.opsForSet().add(setKey, rtHash); - redis.expire(rtHash, rtKeys.ttl()); - redis.expire(setKey, rtKeys.ttl()); + refreshTokenRepository.save(new RefreshToken(userId, rtHash, nextExpiry())); return rtPlain; } catch (Exception e) { - System.out.println(e.getMessage()); + log.error(e.getMessage()); throw new CustomException(ExceptionMessage.TOKEN_GENERATION_FAILED); } } + @Transactional public RotateResult validateAndRotate(String oldRtPlain) { String oldRtHash = TokenUtils.sha256Hex(oldRtPlain); - String userId = (String) redis.opsForHash().get(oldRtHash, "userId"); + RefreshToken refreshToken = refreshTokenRepository.findByRtHash(oldRtHash) + .orElseThrow(() -> new CustomException(ExceptionMessage.UNAUTHORIZED)); - if (userId == null) { + if (refreshToken.isExpired(Instant.now())) { throw new CustomException(ExceptionMessage.UNAUTHORIZED); } - redis.delete(oldRtHash); - redis.opsForSet().remove(rtKeys.userSet(userId), oldRtHash); - String newRtPlain = issue(userId); - return new RotateResult(userId, newRtPlain); + String newRtPlain = TokenUtils.randomUrlSafe(64); + String newRtHash = TokenUtils.sha256Hex(newRtPlain); + refreshToken.rotate(newRtHash, nextExpiry()); + refreshTokenRepository.save(refreshToken); + + return new RotateResult(refreshToken.getUserId(), newRtPlain); } + @Transactional public void revoke(String presentedRtPlain) { String rtHash = TokenUtils.sha256Hex(presentedRtPlain); - String userId = (String) redis.opsForHash().get(rtHash, "userId"); - if (userId == null) { - return; - } - redis.delete(rtHash); - redis.opsForSet().remove(rtKeys.userSet(userId), rtHash); + refreshTokenRepository.findByRtHash(rtHash) + .ifPresent(refreshTokenRepository::delete); } - public record RotateResult(String userId, String newRtPlain) { - + private Instant nextExpiry() { + return Instant.now().plusSeconds(jwtProperties.getRefreshExpirationTime()); } - @Component - public static class RtKeys { - - private final JwtProperties jwtProperties; - - public RtKeys(JwtProperties jwtProperties) { - this.jwtProperties = jwtProperties; - } - - public String userSet(String userId) { - return "rt:u:" + userId; - } + public record RotateResult(String userId, String newRtPlain) { - public Duration ttl() { - return Duration.ofSeconds(jwtProperties.getRefreshExpirationTime()); - } } public static class TokenUtils { diff --git a/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java new file mode 100644 index 00000000..2220f0c9 --- /dev/null +++ b/modules/auth/impl/src/main/java/com/icc/qasker/auth/util/UserIdArgumentResolver.java @@ -0,0 +1,50 @@ +package com.icc.qasker.auth.resolver; + +import com.icc.qasker.auth.entity.User; +import com.icc.qasker.auth.principal.UserPrincipal; +import com.icc.qasker.global.annotation.UserId; +import org.jspecify.annotations.Nullable; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public @Nullable Object resolveArgument(MethodParameter parameter, + @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + @Nullable WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof User user) { + return user.getUserId(); + } + if (principal instanceof UserPrincipal userPrincipal) { + return userPrincipal.getUser().getUserId(); + } + if (principal instanceof String userId && !userId.isBlank() + && !"anonymousUser".equalsIgnoreCase(userId)) { + return userId; + } + return null; + } +} diff --git a/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/doc/S3ApiDoc.java b/modules/aws/api/src/main/java/com/icc/qasker/aws/doc/S3ApiDoc.java similarity index 96% rename from modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/doc/S3ApiDoc.java rename to modules/aws/api/src/main/java/com/icc/qasker/aws/doc/S3ApiDoc.java index d33fc08a..207c082d 100644 --- a/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/doc/S3ApiDoc.java +++ b/modules/aws/api/src/main/java/com/icc/qasker/aws/doc/S3ApiDoc.java @@ -1,4 +1,4 @@ -package com.icc.qasker.aws.controller.doc; +package com.icc.qasker.aws.doc; import com.icc.qasker.aws.dto.FileExistStatusResponse; import com.icc.qasker.aws.dto.PresignRequest; diff --git a/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/S3Controller.java b/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/S3Controller.java index a1ff6259..610d39f2 100644 --- a/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/S3Controller.java +++ b/modules/aws/impl/src/main/java/com/icc/qasker/aws/controller/S3Controller.java @@ -1,7 +1,7 @@ package com.icc.qasker.aws.controller; import com.icc.qasker.aws.S3Service; -import com.icc.qasker.aws.controller.doc.S3ApiDoc; +import com.icc.qasker.aws.doc.S3ApiDoc; import com.icc.qasker.aws.dto.FileExistStatusResponse; import com.icc.qasker.aws.dto.PresignRequest; import com.icc.qasker.aws.dto.PresignResponse; diff --git a/modules/global/src/main/java/com/icc/qasker/global/annotation/UserId.java b/modules/global/src/main/java/com/icc/qasker/global/annotation/UserId.java new file mode 100644 index 00000000..0d3b2ee2 --- /dev/null +++ b/modules/global/src/main/java/com/icc/qasker/global/annotation/UserId.java @@ -0,0 +1,12 @@ +package com.icc.qasker.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserId { + +} diff --git a/modules/global/src/main/java/com/icc/qasker/global/util/HashUtil.java b/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java similarity index 95% rename from modules/global/src/main/java/com/icc/qasker/global/util/HashUtil.java rename to modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java index b5232c60..e81d5b3f 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/util/HashUtil.java +++ b/modules/global/src/main/java/com/icc/qasker/global/component/HashUtil.java @@ -1,4 +1,4 @@ -package com.icc.qasker.global.util; +package com.icc.qasker.global.component; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; diff --git a/modules/global/src/main/java/com/icc/qasker/global/component/SlackNotifier.java b/modules/global/src/main/java/com/icc/qasker/global/component/SlackNotifier.java index c89e5f7e..31cf1b9b 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/component/SlackNotifier.java +++ b/modules/global/src/main/java/com/icc/qasker/global/component/SlackNotifier.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient.Builder; @@ -16,9 +17,10 @@ public class SlackNotifier { private final Builder restClientBuilder; private final SlackProperties slackProperties; - public void notifyText(String text) { + @Async + public void asyncNotifyText(String text) { boolean enabled = slackProperties.isEnabled(); - String webhookUrl = slackProperties.getWebhookUrlNotify().toString(); + String webhookUrl = slackProperties.getWebhookUrlNotify(); if (!enabled || webhookUrl == null || webhookUrl.isBlank()) { return; } diff --git a/modules/global/src/main/java/com/icc/qasker/global/config/AsyncConfig.java b/modules/global/src/main/java/com/icc/qasker/global/config/AsyncConfig.java new file mode 100644 index 00000000..39b0cc8f --- /dev/null +++ b/modules/global/src/main/java/com/icc/qasker/global/config/AsyncConfig.java @@ -0,0 +1,21 @@ +package com.icc.qasker.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; + + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setVirtualThreads(true); + executor.setTaskTerminationTimeout(10 * 1000); + return executor; + } +} diff --git a/modules/global/src/main/java/com/icc/qasker/global/config/HashIdConfig.java b/modules/global/src/main/java/com/icc/qasker/global/config/HashIdConfig.java index 6bf5b7b0..47eae8eb 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/config/HashIdConfig.java +++ b/modules/global/src/main/java/com/icc/qasker/global/config/HashIdConfig.java @@ -1,6 +1,6 @@ package com.icc.qasker.global.config; -import com.icc.qasker.global.properties.HashProperties; +import com.icc.qasker.global.properties.HashIdProperties; import lombok.AllArgsConstructor; import org.hashids.Hashids; import org.springframework.context.annotation.Bean; @@ -10,10 +10,10 @@ @AllArgsConstructor public class HashIdConfig { - private final HashProperties hashProperties; + private final HashIdProperties hashIdProperties; @Bean public Hashids hashids() { - return new Hashids(hashProperties.getSalt(), hashProperties.getMinLength()); + return new Hashids(hashIdProperties.getSalt(), hashIdProperties.getMinLength()); } } diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java index 56169125..c1f2995c 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java @@ -34,7 +34,6 @@ public enum ExceptionMessage { AI_SERVER_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AI서버에서 오류가 발생했습니다."), AI_SERVER_TO_MANY_REQUEST(HttpStatus.TOO_MANY_REQUESTS, "서버가 생성요청 한도에 도달했습니다. 문제 개수를 줄이거나 1분 뒤 다시 시도해주세요."), - NULL_AI_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답이 null입니다."), INVALID_AI_RESPONSE(HttpStatus.UNPROCESSABLE_ENTITY, "유효하지 않은 AI의 응답입니다."), @@ -53,7 +52,7 @@ public enum ExceptionMessage { INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), // JWT - TOKEN_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토큰 생성 중 서버 오류가 발생했습니다."), + TOKEN_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "로그인에 실패했습니다."), // Auth UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java index 7a187e85..d2545bbb 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java @@ -6,11 +6,17 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @ControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e) { + return ResponseEntity.notFound().build(); + } + @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException( CustomException customException) { diff --git a/modules/global/src/main/java/com/icc/qasker/global/properties/HashProperties.java b/modules/global/src/main/java/com/icc/qasker/global/properties/HashIdProperties.java similarity index 78% rename from modules/global/src/main/java/com/icc/qasker/global/properties/HashProperties.java rename to modules/global/src/main/java/com/icc/qasker/global/properties/HashIdProperties.java index f8effc66..cfc7b4e2 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/properties/HashProperties.java +++ b/modules/global/src/main/java/com/icc/qasker/global/properties/HashIdProperties.java @@ -6,8 +6,8 @@ @Getter @AllArgsConstructor -@ConfigurationProperties("app.hashids") -public class HashProperties { +@ConfigurationProperties("q-asker.hashid") +public class HashIdProperties { private final String salt; private final int minLength; diff --git a/modules/global/src/main/java/com/icc/qasker/global/properties/JwtProperties.java b/modules/global/src/main/java/com/icc/qasker/global/properties/JwtProperties.java new file mode 100644 index 00000000..b7f08977 --- /dev/null +++ b/modules/global/src/main/java/com/icc/qasker/global/properties/JwtProperties.java @@ -0,0 +1,15 @@ +package com.icc.qasker.global.properties; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "spring.security.jwt") +public class JwtProperties { + + private String secret; + private long accessExpirationTime; + private long refreshExpirationTime; +} diff --git a/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java b/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java index c937f37a..fd9076c7 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java +++ b/modules/global/src/main/java/com/icc/qasker/global/properties/QAskerProperties.java @@ -6,7 +6,7 @@ @Getter @AllArgsConstructor -@ConfigurationProperties(prefix = "q-asker") +@ConfigurationProperties(prefix = "q-asker.web") public class QAskerProperties { private final String frontendDeployUrl; diff --git a/modules/global/src/main/java/com/icc/qasker/global/properties/SlackProperties.java b/modules/global/src/main/java/com/icc/qasker/global/properties/SlackProperties.java index 80faadf7..61f72b69 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/properties/SlackProperties.java +++ b/modules/global/src/main/java/com/icc/qasker/global/properties/SlackProperties.java @@ -1,19 +1,19 @@ package com.icc.qasker.global.properties; -import java.net.URI; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; + @Getter -@ConfigurationProperties(prefix = "slack") +@ConfigurationProperties(prefix = "q-asker.slack") public class SlackProperties { private final boolean enabled; - private final URI webhookUrlNotify; + private final String webhookUrlNotify; private final String usernameNotify; private final String iconNotify; - public SlackProperties(boolean enabled, URI webhookUrlNotify) { + public SlackProperties(boolean enabled, String webhookUrlNotify) { this.enabled = enabled; this.webhookUrlNotify = webhookUrlNotify; this.usernameNotify = "퀴즈생성 알림이"; diff --git a/modules/quiz/api/build.gradle b/modules/quiz/api/build.gradle index 620edb71..473d5f30 100644 --- a/modules/quiz/api/build.gradle +++ b/modules/quiz/api/build.gradle @@ -1,3 +1,7 @@ plugins { id 'java-library' +} + +dependencies { + implementation project(":global") } \ No newline at end of file diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ExplanationService.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ExplanationService.java index b9281143..0e2c20d4 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ExplanationService.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ExplanationService.java @@ -1,6 +1,6 @@ package com.icc.qasker.quiz; -import com.icc.qasker.quiz.dto.response.ExplanationResponse; +import com.icc.qasker.quiz.dto.feResponse.ExplanationResponse; public interface ExplanationService { diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/GenerationService.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/GenerationService.java index 73cdad07..595b02eb 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/GenerationService.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/GenerationService.java @@ -1,10 +1,11 @@ package com.icc.qasker.quiz; -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.GenerationResponse; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; public interface GenerationService { - GenerationResponse processGenerationRequest(FeGenerationRequest feGenerationRequest); + SseEmitter processGenerationRequest(GenerationRequest generationRequest, + String userId); } diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ProblemSetService.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ProblemSetService.java index d24c48f1..6453862b 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ProblemSetService.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/ProblemSetService.java @@ -1,6 +1,6 @@ package com.icc.qasker.quiz; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; public interface ProblemSetService { diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/SpecificExplanationService.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/SpecificExplanationService.java index 090bbe7e..422f6b07 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/SpecificExplanationService.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/SpecificExplanationService.java @@ -1,6 +1,6 @@ package com.icc.qasker.quiz; -import com.icc.qasker.quiz.dto.response.SpecificExplanationResponse; +import com.icc.qasker.quiz.dto.feResponse.SpecificExplanationResponse; public interface SpecificExplanationService { diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ExplanationApiDoc.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ExplanationApiDoc.java similarity index 83% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ExplanationApiDoc.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ExplanationApiDoc.java index f11d0086..83960aca 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ExplanationApiDoc.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ExplanationApiDoc.java @@ -1,6 +1,6 @@ -package com.icc.qasker.quiz.controller.doc; +package com.icc.qasker.quiz.doc; -import com.icc.qasker.quiz.dto.response.ExplanationResponse; +import com.icc.qasker.quiz.dto.feResponse.ExplanationResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/GenerationApiDoc.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/GenerationApiDoc.java new file mode 100644 index 00000000..c1f12a78 --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/GenerationApiDoc.java @@ -0,0 +1,20 @@ +package com.icc.qasker.quiz.doc; + +import com.icc.qasker.global.annotation.UserId; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Tag(name = "Generation", description = "생성 관련 API") +public interface GenerationApiDoc { + + @Operation(summary = "문제를 생성한다") + @PostMapping + SseEmitter postProblemSetId( + @UserId + String userId, + @RequestBody GenerationRequest generationRequest); +} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ProblemSetApiDoc.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ProblemSetApiDoc.java similarity index 84% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ProblemSetApiDoc.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ProblemSetApiDoc.java index f0a2118a..a77cdf54 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/ProblemSetApiDoc.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/ProblemSetApiDoc.java @@ -1,6 +1,6 @@ -package com.icc.qasker.quiz.controller.doc; +package com.icc.qasker.quiz.doc; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/SpecificExplanationApiDoc.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/SpecificExplanationApiDoc.java similarity index 79% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/SpecificExplanationApiDoc.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/SpecificExplanationApiDoc.java index 6700fd34..100aed27 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/SpecificExplanationApiDoc.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/doc/SpecificExplanationApiDoc.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.controller.doc; +package com.icc.qasker.quiz.doc; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/GenerationRequestToAI.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/GenerationRequestToAI.java new file mode 100644 index 00000000..cf09b9df --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/GenerationRequestToAI.java @@ -0,0 +1,17 @@ +package com.icc.qasker.quiz.dto.aiRequest; + +import com.icc.qasker.quiz.dto.feRequest.enums.DifficultyType; +import com.icc.qasker.quiz.dto.feRequest.enums.QuizType; +import java.util.List; +import lombok.Builder; + +@Builder +public record GenerationRequestToAI( + String uploadedUrl, + int quizCount, + QuizType quizType, + DifficultyType difficultyType, + List pageNumbers +) { + +}; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/SpecificExplanationRequestToAI.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/SpecificExplanationRequestToAI.java new file mode 100644 index 00000000..00069e2d --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiRequest/SpecificExplanationRequestToAI.java @@ -0,0 +1,12 @@ +package com.icc.qasker.quiz.dto.aiRequest; + +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; +import java.util.List; + +public record SpecificExplanationRequestToAI( + String title, + List selections +) { + + +}; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ErrorEvent.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ErrorEvent.java new file mode 100644 index 00000000..d153846f --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ErrorEvent.java @@ -0,0 +1,10 @@ +package com.icc.qasker.quiz.dto.aiResponse; + + +public record ErrorEvent( + String status, + String message, + int code +) implements StreamEvent { + +} \ No newline at end of file diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ProblemSetGeneratedEvent.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ProblemSetGeneratedEvent.java new file mode 100644 index 00000000..53ba28f2 --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/ProblemSetGeneratedEvent.java @@ -0,0 +1,29 @@ +package com.icc.qasker.quiz.dto.aiResponse; + +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class ProblemSetGeneratedEvent implements StreamEvent { + + private List quiz; + + @Getter + public static class QuizGeneratedFromAI { + + private Integer number; + private String title; + private List selections; + private String explanation; + private List referencedPages; + + @Getter + @Setter + public static class SelectionsOfAI { + + private String content; + private boolean correct; + } + } +} diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/StreamEvent.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/StreamEvent.java new file mode 100644 index 00000000..19687871 --- /dev/null +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/aiResponse/StreamEvent.java @@ -0,0 +1,23 @@ +package com.icc.qasker.quiz.dto.aiResponse; + + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; + +@JsonTypeInfo( + use = Id.NAME, + include = As.PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ProblemSetGeneratedEvent.class, name = "quiz"), + @JsonSubTypes.Type(value = ErrorEvent.class, name = "error") +}) +public interface StreamEvent { + +} + + diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/FeGenerationRequest.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/GenerationRequest.java similarity index 67% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/FeGenerationRequest.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/GenerationRequest.java index 132bb3ab..52cd3bb0 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/FeGenerationRequest.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/GenerationRequest.java @@ -1,7 +1,7 @@ -package com.icc.qasker.quiz.dto.request; +package com.icc.qasker.quiz.dto.feRequest; -import com.icc.qasker.quiz.dto.request.enums.DifficultyType; -import com.icc.qasker.quiz.dto.request.enums.QuizType; +import com.icc.qasker.quiz.dto.feRequest.enums.DifficultyType; +import com.icc.qasker.quiz.dto.feRequest.enums.QuizType; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -9,7 +9,7 @@ import jakarta.validation.constraints.Size; import java.util.List; -public record FeGenerationRequest( +public record GenerationRequest( @NotBlank(message = "url이 존재하지 않습니다.") String uploadedUrl, @@ -22,7 +22,11 @@ public record FeGenerationRequest( DifficultyType difficultyType, @NotNull(message = "pageNumbers가 null입니다.") @Size(min = 1, max = 150, message = "pageNumbers는 1개 이상 150 이하이어야 합니다.") - List pageNumbers + List< + @NotNull(message = "배열 요소가 null입니다.") + @Min(value = 1, message = "배열 요소는 1 이상이어야 합니다.") + Integer + > pageNumbers ) { diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/DifficultyType.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/DifficultyType.java similarity index 58% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/DifficultyType.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/DifficultyType.java index b010897e..b5e366da 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/DifficultyType.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/DifficultyType.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.request.enums; +package com.icc.qasker.quiz.dto.feRequest.enums; public enum DifficultyType { RECALL, diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/QuizType.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/QuizType.java similarity index 54% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/QuizType.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/QuizType.java index f0cff402..45c8c01e 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/enums/QuizType.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feRequest/enums/QuizType.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.request.enums; +package com.icc.qasker.quiz.dto.feRequest.enums; public enum QuizType { MULTIPLE, diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ExplanationResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ExplanationResponse.java similarity index 81% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ExplanationResponse.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ExplanationResponse.java index e9159d1e..8baa452f 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ExplanationResponse.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ExplanationResponse.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.response; +package com.icc.qasker.quiz.dto.feResponse; import java.util.List; import lombok.AllArgsConstructor; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ProblemSetResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ProblemSetResponse.java similarity index 87% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ProblemSetResponse.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ProblemSetResponse.java index f79e31b4..c9eea7a7 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ProblemSetResponse.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ProblemSetResponse.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.response; +package com.icc.qasker.quiz.dto.feResponse; import java.util.List; import lombok.AllArgsConstructor; @@ -10,7 +10,8 @@ @NoArgsConstructor public class ProblemSetResponse { - private String title; + private String problemSetId; + private int totalCount; private List quiz; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ResultResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ResultResponse.java similarity index 84% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ResultResponse.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ResultResponse.java index 6aac1b4a..c632fec1 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/ResultResponse.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/ResultResponse.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.response; +package com.icc.qasker.quiz.dto.feResponse; import java.util.List; import lombok.AllArgsConstructor; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/SpecificExplanationResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/SpecificExplanationResponse.java similarity index 85% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/SpecificExplanationResponse.java rename to modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/SpecificExplanationResponse.java index 398aad62..8a9b0447 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/SpecificExplanationResponse.java +++ b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/feResponse/SpecificExplanationResponse.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.response; +package com.icc.qasker.quiz.dto.feResponse; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/SpecificExplanationRequest.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/SpecificExplanationRequest.java deleted file mode 100644 index 462fc0c4..00000000 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/SpecificExplanationRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.icc.qasker.quiz.dto.request; - -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI.SelectionsOfAi; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Getter -public class SpecificExplanationRequest { - - private String title; - private List selections; -} diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/AiGenerationResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/AiGenerationResponse.java deleted file mode 100644 index 536a1d05..00000000 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/AiGenerationResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.icc.qasker.quiz.dto.response; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class AiGenerationResponse { - - private String title; - - @NotEmpty(message = "quiz가 null입니다.") - @Valid - private List quiz; -} diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/GenerationResponse.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/GenerationResponse.java deleted file mode 100644 index 00825f2e..00000000 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/GenerationResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.icc.qasker.quiz.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class GenerationResponse { - - private String problemSetId; -} diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/QuizGeneratedByAI.java b/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/QuizGeneratedByAI.java deleted file mode 100644 index e9bab09c..00000000 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/QuizGeneratedByAI.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.icc.qasker.quiz.dto.response; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class QuizGeneratedByAI { - - @NotNull(message = "number가 null입니다.") - private int number; - - @NotBlank(message = "title이 존재하지 않습니다.") - private String title; - - @NotNull(message = "selections가 null입니다.") - private List selections; - - @NotBlank(message = "explanation이 null입니다.") - private String explanation; - - @NotNull(message = "referencedPages가 null입니다.") - private List referencedPages; - - @Getter - @Setter - @NoArgsConstructor - @AllArgsConstructor - public static class SelectionsOfAi { - - @NotBlank(message = "selection의 content가 존재하지 않습니다.") - private String content; - private boolean correct; - } -} diff --git a/modules/quiz/impl/build.gradle b/modules/quiz/impl/build.gradle index fad8e322..a2e57d77 100644 --- a/modules/quiz/impl/build.gradle +++ b/modules/quiz/impl/build.gradle @@ -1,12 +1,13 @@ plugins { - id 'java-library' + id "java-library" } dependencies { implementation project(":global") implementation project(":aws:aws-api") implementation project(":quiz:quiz-api") - implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1' + implementation "io.github.resilience4j:resilience4j-spring-boot3:2.3.0" implementation "org.springframework.boot:spring-boot-starter-aop" + implementation "org.apache.httpcomponents.client5:httpclient5:5.3.1" } \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AIServerAdapter.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AIServerAdapter.java new file mode 100644 index 00000000..61b90048 --- /dev/null +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AIServerAdapter.java @@ -0,0 +1,111 @@ +package com.icc.qasker.quiz.adapter; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.icc.qasker.global.error.ClientSideException; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quiz.dto.aiRequest.GenerationRequestToAI; +import com.icc.qasker.quiz.dto.aiResponse.ErrorEvent; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent; +import com.icc.qasker.quiz.dto.aiResponse.StreamEvent; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.function.Consumer; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +@Slf4j +@Component +@AllArgsConstructor +public class AIServerAdapter { + + private final ObjectMapper objectMapper; + private final RestClient aiStreamClient; + + @CircuitBreaker(name = "aiServer", fallbackMethod = "fallback") + public void streamRequest(GenerationRequestToAI request, + Consumer onLineReceived) { + aiStreamClient.post() + .uri("/generation") + .body(request) + .accept(MediaType.APPLICATION_NDJSON) + .exchange((req, res) -> { + HttpStatusCode status = res.getStatusCode(); + + if (status.is4xxClientError()) { + try (InputStream is = res.getBody()) { + String messageBody = new String(is.readAllBytes(), UTF_8); + throw new ClientSideException(messageBody); + } + } + + if (status.is5xxServerError()) { + try (InputStream is = res.getBody()) { + String messageBody = new String(is.readAllBytes(), UTF_8); + throw new RuntimeException(messageBody); + } + } + + try ( + InputStream is = res.getBody(); + BufferedReader br = new BufferedReader( + new InputStreamReader(is, UTF_8)) + ) { + while (true) { + String line = br.readLine(); + if (line == null) { + break; + } + if (line.isBlank()) { + continue; + } + StreamEvent event = objectMapper.readValue(line, StreamEvent.class); + if (event instanceof ErrorEvent error) { + if (400 <= error.code() && error.code() <= 499) { + throw new ClientSideException(error.message()); + } else { + throw new RuntimeException(error.message()); + } + } else if (event instanceof ProblemSetGeneratedEvent quiz) { + onLineReceived.accept(quiz); + } + } + } catch (IOException e) { + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } + + return null; + }); + } + + private void fallback(GenerationRequest request, + Consumer onLineReceived, + Throwable t) { + if (t instanceof CallNotPermittedException) { + log.error("⛔ [CircuitBreaker] AI 서버 요청 차단됨 (Circuit Open): {}", t.getMessage()); + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } + if (t instanceof ResourceAccessException e) { + log.error("⏳ AI 서버 연결 시간 초과/실패: {}", t.getMessage()); + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } + if (t instanceof ClientSideException e) { + log.error("⏳ 사용자 오류 발생: {}", t.getMessage()); + return; + } + log.error("⚠ AI Server Unknown Error: {}", t.getMessage()); + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } +} \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AiServerAdapter.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AiServerAdapter.java index 3861590d..61b90048 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AiServerAdapter.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/AiServerAdapter.java @@ -1,78 +1,111 @@ package com.icc.qasker.quiz.adapter; -import com.fasterxml.jackson.databind.JsonNode; +import static java.nio.charset.StandardCharsets.UTF_8; + import com.fasterxml.jackson.databind.ObjectMapper; import com.icc.qasker.global.error.ClientSideException; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.AiGenerationResponse; +import com.icc.qasker.quiz.dto.aiRequest.GenerationRequestToAI; +import com.icc.qasker.quiz.dto.aiResponse.ErrorEvent; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent; +import com.icc.qasker.quiz.dto.aiResponse.StreamEvent; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.function.Consumer; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; @Slf4j @Component -public class AiServerAdapter { - - private final RestClient aiRestClient; - - public AiServerAdapter(@Qualifier("aiGenerationRestClient") RestClient aiRestClient) { - this.aiRestClient = aiRestClient; - } - - @CircuitBreaker(name = "aiServer") - public AiGenerationResponse requestGenerate(FeGenerationRequest feGenerationRequest) { - try { - return aiRestClient.post() - .uri("/generation") - .body(feGenerationRequest) - .retrieve() - .body(AiGenerationResponse.class); +@AllArgsConstructor +public class AIServerAdapter { - // 1. 400 에러 -> 서킷 브레이커가 무시해야 함 (ignoreExceptions) - } catch (HttpClientErrorException e) { + private final ObjectMapper objectMapper; + private final RestClient aiStreamClient; - String messageBody = e.getResponseBodyAsString(); - log.error("[AI Server] Bad Request: Status={}, Body={}", e.getStatusCode(), - messageBody); + @CircuitBreaker(name = "aiServer", fallbackMethod = "fallback") + public void streamRequest(GenerationRequestToAI request, + Consumer onLineReceived) { + aiStreamClient.post() + .uri("/generation") + .body(request) + .accept(MediaType.APPLICATION_NDJSON) + .exchange((req, res) -> { + HttpStatusCode status = res.getStatusCode(); - String message = ""; - try { - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode rootNode = objectMapper.readTree(messageBody); + if (status.is4xxClientError()) { + try (InputStream is = res.getBody()) { + String messageBody = new String(is.readAllBytes(), UTF_8); + throw new ClientSideException(messageBody); + } + } - if (rootNode.has("detail")) { - message = rootNode.get("detail").asText(); + if (status.is5xxServerError()) { + try (InputStream is = res.getBody()) { + String messageBody = new String(is.readAllBytes(), UTF_8); + throw new RuntimeException(messageBody); + } } - } catch (Exception exception) { - message = messageBody; - } - throw new ClientSideException(message); + try ( + InputStream is = res.getBody(); + BufferedReader br = new BufferedReader( + new InputStreamReader(is, UTF_8)) + ) { + while (true) { + String line = br.readLine(); + if (line == null) { + break; + } + if (line.isBlank()) { + continue; + } + StreamEvent event = objectMapper.readValue(line, StreamEvent.class); + if (event instanceof ErrorEvent error) { + if (400 <= error.code() && error.code() <= 499) { + throw new ClientSideException(error.message()); + } else { + throw new RuntimeException(error.message()); + } + } else if (event instanceof ProblemSetGeneratedEvent quiz) { + onLineReceived.accept(quiz); + } + } + } catch (IOException e) { + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } - // 2. 5xx 에러 (Server Fault) -> 서킷 브레이커가 실패로 기록해야 함 - } catch (HttpServerErrorException e) { - log.error("AI Server Server Error (5xx): Status={}, Body={}", e.getStatusCode(), - e.getResponseBodyAsString()); - // 예외를 그대로 던지거나, 커스텀 예외(ServerException)로 감싸서 던져야 함 - throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + return null; + }); + } - // 3. 타임아웃/연결 오류 (Network Fault) -> 서킷 브레이커가 실패로 기록해야 함 - } catch (ResourceAccessException e) { - log.error("AI Server Connection/Timeout Error: {}", e.getMessage()); - // 타임아웃은 반드시 던져야 함 + private void fallback(GenerationRequest request, + Consumer onLineReceived, + Throwable t) { + if (t instanceof CallNotPermittedException) { + log.error("⛔ [CircuitBreaker] AI 서버 요청 차단됨 (Circuit Open): {}", t.getMessage()); throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); - - // 4. 그 외의 오류 - } catch (Exception e) { - log.error("AI Server Unknown Error: {}", e.getMessage()); + } + if (t instanceof ResourceAccessException e) { + log.error("⏳ AI 서버 연결 시간 초과/실패: {}", t.getMessage()); throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); } + if (t instanceof ClientSideException e) { + log.error("⏳ 사용자 오류 발생: {}", t.getMessage()); + return; + } + log.error("⚠ AI Server Unknown Error: {}", t.getMessage()); + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); } } \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAiServerAdapter.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAiServerAdapter.java deleted file mode 100644 index e5315406..00000000 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/adapter/MockAiServerAdapter.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.icc.qasker.quiz.adapter; - -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.AiGenerationResponse; -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI; -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI.SelectionsOfAi; -import java.util.List; -import java.util.stream.IntStream; -import org.springframework.context.annotation.Primary; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -/** - * 부하 테스트용 Mock Adapter 'stress-test' 프로파일이 활성화되었을 때만 빈으로 등록되고, - * - * @Primary를 통해 기존 AiServerAdapter보다 우선순위를 가짐. - */ -@Component -@Profile("stress-test") -@Primary -public class MockAiServerAdapter extends AiServerAdapter { - - public MockAiServerAdapter() { - super(null); // 부모의 RestClient는 필요 없음 - } - - @Override - public AiGenerationResponse requestGenerate(FeGenerationRequest feGenerationRequest) { - // 요청된 퀴즈 개수만큼 더미 데이터 생성 - List quizzes = IntStream.range(0, feGenerationRequest.quizCount()) - .mapToObj(i -> new QuizGeneratedByAI( - i + 1, - "Mock Quiz Title " + i, - List.of( - new SelectionsOfAi("Option 1", true), - new SelectionsOfAi("Option 2", false), - new SelectionsOfAi("Option 3", false), - new SelectionsOfAi("Option 4", false) - ), - "Mock Explanation " + i, - List.of() - )) - .toList(); - - return new AiGenerationResponse("Mock Problem Set Title", quizzes); - } -} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/AiWebClientConfig.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/AiWebClientConfig.java deleted file mode 100644 index 2552dbbd..00000000 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/AiWebClientConfig.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.icc.qasker.quiz.config; - -import com.icc.qasker.global.properties.QAskerProperties; -import java.time.Duration; -import lombok.AllArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -@Configuration -@AllArgsConstructor -public class AiWebClientConfig { - - private final QAskerProperties qAskerProperties; - - @Primary - @Bean("aiGenerationRestClient") - public RestClient aiGenerationRestClient() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofSeconds(5)); - factory.setReadTimeout(Duration.ofSeconds(80)); - - return RestClient.builder() - .baseUrl(qAskerProperties.getAiServerUrl()) - .requestFactory(factory) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); - } - - @Primary - @Bean("aiFindRestClient") - public RestClient aiRestClient() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofSeconds(5)); - factory.setReadTimeout(Duration.ofSeconds(40)); - - return RestClient.builder() - .baseUrl(qAskerProperties.getAiServerUrl()) - .requestFactory(factory) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); - } - - @Bean("aiMockingRestClient") - public RestClient aiMockingRestClient() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(Duration.ofSeconds(5)); - factory.setReadTimeout(Duration.ofSeconds(60)); - - return RestClient.builder() - .baseUrl(qAskerProperties.getAiMockingServerUrl()) - .requestFactory(factory) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); - } -} \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/RestClientConfig.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/RestClientConfig.java new file mode 100644 index 00000000..b7700d91 --- /dev/null +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/config/RestClientConfig.java @@ -0,0 +1,68 @@ +package com.icc.qasker.quiz.config; + +import com.icc.qasker.global.properties.QAskerProperties; +import java.time.Duration; +import lombok.AllArgsConstructor; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.util.Timeout; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +@AllArgsConstructor +public class RestClientConfig { + + private final QAskerProperties qAskerProperties; + + @Primary + @Bean("aiStreamClient") + public RestClient aiGenerationClient(QAskerProperties qAskerProperties) { + // 1. 소켓 레벨 타임아웃 설정 (데이터 패킷 간 최대 유휴 시간) + SocketConfig socketConfig = SocketConfig.custom() + .setSoTimeout(Timeout.ofSeconds(50)) + .build(); + + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setDefaultSocketConfig(socketConfig); + + // 1. Apache HttpClient 5 설정 + RequestConfig requestConfig = RequestConfig.custom() + .setResponseTimeout(Timeout.ofSeconds(50)) + .setConnectTimeout(Timeout.ofSeconds(3)) + .build(); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .build(); + + // 2. RestClient 빌드 + return RestClient.builder() + .baseUrl(qAskerProperties.getAiServerUrl()) + .requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)) + .build(); + } + + @Bean("aiRestClient") + public RestClient aiRestClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(40)); + + return RestClient.builder() + .baseUrl(qAskerProperties.getAiServerUrl()) + .requestFactory(factory) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ExplanationController.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ExplanationController.java index d8d791d9..c2ac1e81 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ExplanationController.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ExplanationController.java @@ -1,8 +1,8 @@ package com.icc.qasker.quiz.controller; import com.icc.qasker.quiz.ExplanationService; -import com.icc.qasker.quiz.controller.doc.ExplanationApiDoc; -import com.icc.qasker.quiz.dto.response.ExplanationResponse; +import com.icc.qasker.quiz.doc.ExplanationApiDoc; +import com.icc.qasker.quiz.dto.feResponse.ExplanationResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/GenerationController.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/GenerationController.java index be38bdfd..40683f2b 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/GenerationController.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/GenerationController.java @@ -1,17 +1,17 @@ package com.icc.qasker.quiz.controller; +import com.icc.qasker.global.annotation.UserId; import com.icc.qasker.quiz.GenerationService; -import com.icc.qasker.quiz.controller.doc.GenerationApiDoc; -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.GenerationResponse; -import com.icc.qasker.quiz.service.MockGenerationService; +import com.icc.qasker.quiz.doc.GenerationApiDoc; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor @@ -19,20 +19,14 @@ public class GenerationController implements GenerationApiDoc { private final GenerationService generationService; - private final MockGenerationService mockGenerationService; - @PostMapping + @PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) @Override - public ResponseEntity postProblemSetId( - @Valid @RequestBody FeGenerationRequest feGenerationRequest) { - return ResponseEntity.ok(generationService.processGenerationRequest(feGenerationRequest)); - } - - @PostMapping("/mock") - @Override - public ResponseEntity generateMockQuiz( - @Valid @RequestBody FeGenerationRequest feGenerationRequest) { - return ResponseEntity.ok( - mockGenerationService.processGenerationRequest(feGenerationRequest)); + public SseEmitter postProblemSetId( + @UserId + String userId, + @Valid @RequestBody + GenerationRequest generationRequest) { + return generationService.processGenerationRequest(generationRequest, userId); } } diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ProblemSetController.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ProblemSetController.java index 32ff70d8..89212e33 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ProblemSetController.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/ProblemSetController.java @@ -1,8 +1,8 @@ package com.icc.qasker.quiz.controller; import com.icc.qasker.quiz.ProblemSetService; -import com.icc.qasker.quiz.controller.doc.ProblemSetApiDoc; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse; +import com.icc.qasker.quiz.doc.ProblemSetApiDoc; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/SpecificExplanationController.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/SpecificExplanationController.java index 4f4eb72b..166a8e43 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/SpecificExplanationController.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/SpecificExplanationController.java @@ -1,8 +1,8 @@ package com.icc.qasker.quiz.controller; import com.icc.qasker.quiz.SpecificExplanationService; -import com.icc.qasker.quiz.controller.doc.SpecificExplanationApiDoc; -import com.icc.qasker.quiz.dto.response.SpecificExplanationResponse; +import com.icc.qasker.quiz.doc.SpecificExplanationApiDoc; +import com.icc.qasker.quiz.dto.feResponse.SpecificExplanationResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/GenerationApiDoc.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/GenerationApiDoc.java deleted file mode 100644 index 8dc8c4cb..00000000 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/GenerationApiDoc.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.icc.qasker.quiz.controller.doc; - -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.GenerationResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -@Tag(name = "Generation", description = "생성 관련 API") -public interface GenerationApiDoc { - - @Operation(summary = "문제를 생성한다") - @PostMapping - ResponseEntity postProblemSetId( - @RequestBody FeGenerationRequest feGenerationRequest); - - - @Operation(summary = "모의 퀴즈 생성을 요청한다") - @PostMapping("/mock") - ResponseEntity generateMockQuiz( - @RequestBody FeGenerationRequest feGenerationRequest); -} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Problem.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Problem.java index 075c77f7..35e99e8c 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Problem.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Problem.java @@ -4,7 +4,7 @@ import static java.util.stream.Collectors.toList; import com.icc.qasker.global.entity.CreatedAt; -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.EmbeddedId; @@ -43,7 +43,7 @@ public class Problem extends CreatedAt { @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) private List referencedPages = new ArrayList<>(); - public static Problem of(QuizGeneratedByAI quizDto, ProblemSet problemSet) { + public static Problem of(QuizGeneratedFromAI quizDto, ProblemSet problemSet) { Problem problem = new Problem(); ProblemId problemId = new ProblemId(); problemId.setNumber(quizDto.getNumber()); diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/ProblemSet.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/ProblemSet.java index cd812e39..0804ed40 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/ProblemSet.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/ProblemSet.java @@ -3,7 +3,7 @@ import com.icc.qasker.global.entity.CreatedAt; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.quiz.dto.response.AiGenerationResponse; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -12,36 +12,42 @@ import jakarta.persistence.OneToMany; import java.util.List; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Getter @NoArgsConstructor @AllArgsConstructor -@Setter public class ProblemSet extends CreatedAt { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private String title; + private String userId; @OneToMany(mappedBy = "problemSet", cascade = CascadeType.ALL, orphanRemoval = true) private List problems; - public static ProblemSet of(AiGenerationResponse aiResponse) { + @Builder + public ProblemSet(String userId) { + this.userId = userId; + } + + public static ProblemSet of(ProblemSetGeneratedEvent aiResponse) { + return of(aiResponse, null); + } + + public static ProblemSet of(ProblemSetGeneratedEvent aiResponse, String userId) { if (aiResponse == null || aiResponse.getQuiz() == null) { throw new CustomException(ExceptionMessage.NULL_AI_RESPONSE); } - ProblemSet problemSet = new ProblemSet(); - problemSet.setTitle(aiResponse.getTitle()); - List problems = aiResponse.getQuiz().stream() + ProblemSet problemSet = ProblemSet.builder().userId(userId).build(); + problemSet.problems = aiResponse.getQuiz().stream() .map(quizDto -> Problem.of(quizDto, problemSet)) .toList(); - problemSet.problems = problems; return problemSet; } } diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Selection.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Selection.java index bc9ec152..86cba7dd 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Selection.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/Selection.java @@ -3,7 +3,7 @@ import static jakarta.persistence.FetchType.LAZY; import com.icc.qasker.global.entity.CreatedAt; -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -39,10 +39,10 @@ public class Selection extends CreatedAt { }) private Problem problem; - public static Selection of(QuizGeneratedByAI.SelectionsOfAi selDto, Problem problem) { + public static Selection of(SelectionsOfAI dto, Problem problem) { Selection selection = new Selection(); - selection.content = selDto.getContent(); - selection.correct = selDto.isCorrect(); + selection.content = dto.getContent(); + selection.correct = dto.isCorrect(); selection.problem = problem; return selection; } diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/FeRequestToAIRequestMapper.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/FeRequestToAIRequestMapper.java new file mode 100644 index 00000000..a67ce8a8 --- /dev/null +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/FeRequestToAIRequestMapper.java @@ -0,0 +1,21 @@ +package com.icc.qasker.quiz.mapper; + +import com.icc.qasker.quiz.dto.aiRequest.GenerationRequestToAI; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +public class FeRequestToAIRequestMapper { + + public static GenerationRequestToAI toAIRequest(GenerationRequest fe) { + return GenerationRequestToAI.builder() + .uploadedUrl(fe.uploadedUrl()) + .quizCount(fe.quizCount()) + .quizType(fe.quizType()) + .difficultyType(fe.difficultyType()) + .pageNumbers(fe.pageNumbers()) + .build(); + } +} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/ProblemSetResponseMapper.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/ProblemSetResponseMapper.java index 11b66068..caf343cd 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/ProblemSetResponseMapper.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/ProblemSetResponseMapper.java @@ -1,28 +1,24 @@ package com.icc.qasker.quiz.mapper; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse.QuizForFe; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse.QuizForFe.SelectionsForFE; +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse.QuizForFe; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse.QuizForFe.SelectionsForFE; import com.icc.qasker.quiz.entity.Problem; import com.icc.qasker.quiz.entity.ProblemSet; import com.icc.qasker.quiz.entity.Selection; import java.util.List; import java.util.stream.IntStream; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; +@Component +@AllArgsConstructor public final class ProblemSetResponseMapper { - public static ProblemSetResponse fromEntity(ProblemSet problemSet) { - List quizzes = problemSet.getProblems().stream() - .map(ProblemSetResponseMapper::fromEntity) - .toList(); - - return new ProblemSetResponse( - problemSet.getTitle(), - quizzes - ); - } + private final HashUtil hashUtil; - private static QuizForFe fromEntity(Problem problem) { + public QuizForFe fromEntity(Problem problem) { List selections = IntStream .range(0, problem.getSelections().size()) .mapToObj(i -> { @@ -43,4 +39,16 @@ private static QuizForFe fromEntity(Problem problem) { selections ); } + + public ProblemSetResponse fromEntity(ProblemSet problemSet) { + List quizzes = problemSet.getProblems().stream() + .map(this::fromEntity) + .toList(); + + return new ProblemSetResponse( + hashUtil.encode(problemSet.getId()), + quizzes.size(), + quizzes + ); + } } \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/ProblemRepository.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/ProblemRepository.java index 1002a521..4786be66 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/ProblemRepository.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/ProblemRepository.java @@ -11,5 +11,7 @@ public interface ProblemRepository extends JpaRepository { List findByIdProblemSetId(Long problemSetId); Optional findByIdProblemSetIdAndIdNumber(Long problemSetId, int number); + + long countByIdProblemSetId(Long id); } diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ExplanationServiceImpl.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ExplanationServiceImpl.java index f115dfdf..efd916cd 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ExplanationServiceImpl.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ExplanationServiceImpl.java @@ -1,11 +1,11 @@ package com.icc.qasker.quiz.service; +import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.util.HashUtil; import com.icc.qasker.quiz.ExplanationService; -import com.icc.qasker.quiz.dto.response.ExplanationResponse; -import com.icc.qasker.quiz.dto.response.ResultResponse; +import com.icc.qasker.quiz.dto.feResponse.ExplanationResponse; +import com.icc.qasker.quiz.dto.feResponse.ResultResponse; import com.icc.qasker.quiz.entity.Problem; import com.icc.qasker.quiz.entity.ReferencedPage; import com.icc.qasker.quiz.repository.ProblemRepository; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/GenerationServiceImpl.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/GenerationServiceImpl.java index f92922c1..01c33be2 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/GenerationServiceImpl.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/GenerationServiceImpl.java @@ -1,67 +1,193 @@ package com.icc.qasker.quiz.service; -import com.icc.qasker.aws.S3Service; -import com.icc.qasker.aws.S3ValidateService; -import com.icc.qasker.aws.dto.FileExistStatusResponse; -import com.icc.qasker.aws.dto.Status; +import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.component.SlackNotifier; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.util.HashUtil; import com.icc.qasker.quiz.GenerationService; -import com.icc.qasker.quiz.adapter.AiServerAdapter; -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.AiGenerationResponse; -import com.icc.qasker.quiz.dto.response.GenerationResponse; +import com.icc.qasker.quiz.adapter.AIServerAdapter; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; +import com.icc.qasker.quiz.dto.feRequest.GenerationRequest; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse.QuizForFe; +import com.icc.qasker.quiz.entity.Problem; import com.icc.qasker.quiz.entity.ProblemSet; +import com.icc.qasker.quiz.mapper.FeRequestToAIRequestMapper; +import com.icc.qasker.quiz.mapper.ProblemSetResponseMapper; +import com.icc.qasker.quiz.repository.ProblemRepository; import com.icc.qasker.quiz.repository.ProblemSetRepository; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreaker.State; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +@Slf4j @Service @AllArgsConstructor public class GenerationServiceImpl implements GenerationService { + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final ProblemSetResponseMapper problemSetResponseMapper; private final SlackNotifier slackNotifier; - private final AiServerAdapter aiServerAdapter; private final ProblemSetRepository problemSetRepository; private final HashUtil hashUtil; - private final S3ValidateService s3ValidateService; - private final S3Service s3Service; + private final ProblemRepository problemRepository; + private final AIServerAdapter aiServerAdapter; @Override - public GenerationResponse processGenerationRequest( - FeGenerationRequest feGenerationRequest) { - validateQuizCount(feGenerationRequest); - FileExistStatusResponse fileExistStatusResponse = s3Service.checkFileExistence( - feGenerationRequest.uploadedUrl()); - if (fileExistStatusResponse.status().equals(Status.NOT_EXIST)) { - throw new CustomException(ExceptionMessage.FILE_NOT_FOUND_ON_S3); + public SseEmitter processGenerationRequest( + GenerationRequest request, String userId) { + SseEmitter emitter = new SseEmitter(110 * 1000L); + + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("aiServer"); + if (circuitBreaker.getState() == State.OPEN) { + sendErrorAndComplete(emitter, + new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR)); + return emitter; + } + + ProblemSet saveProblemSet = null; + try { + ProblemSet problemSet = ProblemSet.builder().userId(userId).build(); + saveProblemSet = problemSetRepository.save(problemSet); + } catch (Exception e) { + log.error("초기 저장 실패: {}", e.getMessage()); + sendErrorAndComplete(emitter, e); + return emitter; } - s3ValidateService.checkCloudFrontUrlWithThrowing(feGenerationRequest.uploadedUrl()); - AiGenerationResponse aiResponse = aiServerAdapter.requestGenerate(feGenerationRequest); + final AtomicBoolean cancelled = new AtomicBoolean(false); + emitter.onTimeout(() -> cancelled.set(true)); + emitter.onCompletion(() -> cancelled.set(true)); + emitter.onError(e -> cancelled.set(true)); - ProblemSet problemSet = ProblemSet.of(aiResponse); - ProblemSet savedPs = problemSetRepository.save(problemSet); + ProblemSet finalSaveProblemSet = saveProblemSet; + Thread.ofVirtual().start(() -> { + try { + aiServerAdapter.streamRequest( + FeRequestToAIRequestMapper.toAIRequest(request), + (quiz) -> { + if (cancelled.get()) { + return; + } + doMainLogic(request, quiz, emitter, finalSaveProblemSet); + } + ); - GenerationResponse response = new GenerationResponse( - hashUtil.encode(savedPs.getId()) - ); + Long problemSetId = finalSaveProblemSet.getId(); + long generatedCount = problemRepository.countByIdProblemSetId( + finalSaveProblemSet.getId()); + if (generatedCount == 0) { + throw new CustomException(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR); + } + if (generatedCount == request.quizCount()) { + finalizeSuccess(request, problemSetId, emitter); + } else { + finalizePartialSuccess( + request, + generatedCount, + problemSetId, + emitter + ); + } + } catch (Exception e) { + finalizeError(request, finalSaveProblemSet.getId(), emitter, e); + } + }); + return emitter; + } + + private void doMainLogic(GenerationRequest request, ProblemSetGeneratedEvent quiz, + SseEmitter emitter, + ProblemSet saveProblemSet) { + try { + String encodedId = hashUtil.encode(saveProblemSet.getId()); + List problems = new ArrayList<>(); + List quizForFeList = new ArrayList<>(); + for (QuizGeneratedFromAI quizGeneratedFromAI : quiz.getQuiz()) { + Problem problem = Problem.of(quizGeneratedFromAI, saveProblemSet); + problems.add(problem); + quizForFeList.add(problemSetResponseMapper.fromEntity(problem)); + } + problemRepository.saveAll(problems); + emitter.send( + new ProblemSetResponse( + encodedId, + request.quizCount(), + quizForFeList + )); + } catch (IOException ignored) { + } + } - slackNotifier.notifyText(""" + private void finalizeSuccess( + GenerationRequest request, + Long problemSetId, + SseEmitter emitter + ) { + emitter.complete(); + slackNotifier.asyncNotifyText(""" ✅ [퀴즈 생성 완료 알림] - ProblemSet ID: %s + ProblemSetId: %s + 퀴즈 타입: %s + 문제 수: %d """.formatted( - response.getProblemSetId() + hashUtil.encode(problemSetId), + request.quizType(), + request.quizCount() )); + } - return response; + private void finalizePartialSuccess( + GenerationRequest request, + long generatedCount, + Long problemSetId, + SseEmitter emitter + ) { + emitter.complete(); + slackNotifier.asyncNotifyText(""" + ⚠️ [퀴즈 생성 부분 완료] + ProblemSetId: %s + 생성된 문제 수: %d개 중 %d개 + """.formatted( + hashUtil.encode(problemSetId), + request.quizCount(), + generatedCount + )); + } + + private void finalizeError( + GenerationRequest request, + Long problemSetId, + SseEmitter emitter, + Exception e + ) { + sendErrorAndComplete(emitter, e); + // 보상 트랜잭션 수행 + // 영속성 컨텍스트 추가 -> 삭제 비용 + // problemSetRepository.delete(problemSet); + problemSetRepository.deleteById(problemSetId); + slackNotifier.asyncNotifyText(""" + ❌ [퀴즈 생성 실패] + 사유: %s + """.formatted( + e.getMessage() + )); } - private void validateQuizCount(FeGenerationRequest feGenerationRequest) { - if (feGenerationRequest.quizCount() % 5 != 0) { - throw new CustomException(ExceptionMessage.INVALID_QUIZ_COUNT_REQUEST); + private void sendErrorAndComplete(SseEmitter emitter, Exception e) { + try { + emitter.send(SseEmitter.event().name("error").data(e.getMessage())); + emitter.complete(); + } catch (IOException ignored) { } } -} \ No newline at end of file +} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/MockGenerationService.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/MockGenerationService.java deleted file mode 100644 index 3373865f..00000000 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/MockGenerationService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.icc.qasker.quiz.service; - -import com.icc.qasker.global.error.CustomException; -import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.util.HashUtil; -import com.icc.qasker.quiz.dto.request.FeGenerationRequest; -import com.icc.qasker.quiz.dto.response.AiGenerationResponse; -import com.icc.qasker.quiz.dto.response.GenerationResponse; -import com.icc.qasker.quiz.entity.ProblemSet; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestClient; - -@Slf4j -@Service -public class MockGenerationService { - - private static final int DUMMY_PROBLEM_SET_ID = 1; - private final RestClient aiRestClient; - private final HashUtil hashUtil; - - public MockGenerationService( - HashUtil hashUtil, - @Qualifier("aiMockingRestClient") RestClient aiRestClient1) { - this.hashUtil = hashUtil; - this.aiRestClient = aiRestClient1; - } - - public GenerationResponse processGenerationRequest( - FeGenerationRequest feGenerationRequest) { - AiGenerationResponse aiResponse = callAiServer(feGenerationRequest); - - ProblemSet.of(aiResponse); - - return new GenerationResponse( - hashUtil.encode(DUMMY_PROBLEM_SET_ID) - ); - } - - - private AiGenerationResponse callAiServer(FeGenerationRequest feGenerationRequest) { - try { - return aiRestClient.post() - .uri("/generation") - .body(feGenerationRequest) - .retrieve() - .body(AiGenerationResponse.class); - } catch (HttpClientErrorException.TooManyRequests e) { - throw new CustomException(ExceptionMessage.AI_SERVER_TO_MANY_REQUEST); - } catch (ResourceAccessException e) { - if (e.getCause() instanceof java.net.SocketTimeoutException) { - throw new CustomException(ExceptionMessage.AI_SERVER_TIMEOUT); - } - throw new CustomException(ExceptionMessage.AI_SERVER_CONNECTION_FAILED); - } catch (Exception e) { - throw new CustomException(ExceptionMessage.AI_SERVER_RESPONSE_ERROR); - } - } -} diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ProblemSetServiceImpl.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ProblemSetServiceImpl.java index 58be2f0c..eb5f39d6 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ProblemSetServiceImpl.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/ProblemSetServiceImpl.java @@ -1,10 +1,10 @@ package com.icc.qasker.quiz.service; +import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.util.HashUtil; import com.icc.qasker.quiz.ProblemSetService; -import com.icc.qasker.quiz.dto.response.ProblemSetResponse; +import com.icc.qasker.quiz.dto.feResponse.ProblemSetResponse; import com.icc.qasker.quiz.entity.ProblemSet; import com.icc.qasker.quiz.mapper.ProblemSetResponseMapper; import com.icc.qasker.quiz.repository.ProblemSetRepository; @@ -16,6 +16,7 @@ @AllArgsConstructor public class ProblemSetServiceImpl implements ProblemSetService { + private final ProblemSetResponseMapper problemSetResponseMapper; private final ProblemSetRepository problemSetRepository; private final HashUtil hashUtil; @@ -25,7 +26,7 @@ public ProblemSetResponse getProblemSet(String problemSetId) { long id = hashUtil.decode(problemSetId); ProblemSet problemSet = getProblemSetEntity(id); - return toResponse(problemSet); + return problemSetResponseMapper.fromEntity(problemSet); } private ProblemSet getProblemSetEntity(long id) { @@ -34,9 +35,5 @@ private ProblemSet getProblemSetEntity(long id) { () -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND) ); } - - private ProblemSetResponse toResponse(ProblemSet problemSet) { - return ProblemSetResponseMapper.fromEntity(problemSet); - } } diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/SpecificExplanationServiceImpl.java b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/SpecificExplanationServiceImpl.java index 6ea2e7a6..691b65fe 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/SpecificExplanationServiceImpl.java +++ b/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/SpecificExplanationServiceImpl.java @@ -3,13 +3,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; -import com.icc.qasker.global.util.HashUtil; import com.icc.qasker.quiz.SpecificExplanationService; -import com.icc.qasker.quiz.dto.request.SpecificExplanationRequest; -import com.icc.qasker.quiz.dto.response.QuizGeneratedByAI.SelectionsOfAi; -import com.icc.qasker.quiz.dto.response.SpecificExplanationResponse; +import com.icc.qasker.quiz.dto.aiRequest.SpecificExplanationRequestToAI; +import com.icc.qasker.quiz.dto.aiResponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; +import com.icc.qasker.quiz.dto.feResponse.SpecificExplanationResponse; import com.icc.qasker.quiz.entity.Problem; import com.icc.qasker.quiz.repository.ProblemRepository; import java.util.Objects; @@ -30,7 +30,7 @@ public class SpecificExplanationServiceImpl implements SpecificExplanationServic private final ProblemRepository problemRepository; public SpecificExplanationServiceImpl( - @Qualifier("aiFindRestClient") RestClient aiRestClient, + @Qualifier("aiRestClient") RestClient aiRestClient, ProblemRepository problemRepository, HashUtil hashUtil ) { @@ -46,13 +46,13 @@ public SpecificExplanationResponse getSpecificExplanation(String encodedProblemS Long problemSetId = hashUtil.decode(encodedProblemSetId); Problem problem = problemRepository.findByIdProblemSetIdAndIdNumber(problemSetId, number) .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); - SpecificExplanationRequest aiRequest = new SpecificExplanationRequest( + SpecificExplanationRequestToAI aiRequest = new SpecificExplanationRequestToAI( problem.getTitle(), problem.getSelections().stream().map(selection -> { - SelectionsOfAi s = new SelectionsOfAi(); - s.setContent(selection.getContent()); - s.setCorrect(selection.isCorrect()); - return s; + SelectionsOfAI selectionOfAI = new SelectionsOfAI(); + selectionOfAI.setContent(selection.getContent()); + selectionOfAI.setCorrect(selection.isCorrect()); + return selectionOfAI; }).toList() ); String aiExplanationRaw = Objects.requireNonNull( diff --git a/modules/util/api/build.gradle b/modules/util/api/build.gradle new file mode 100644 index 00000000..620edb71 --- /dev/null +++ b/modules/util/api/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java-library' +} \ No newline at end of file diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/UpdateLogApiDocs.java b/modules/util/api/src/main/java/com/icc/qasker/aws/doc/UpdateLogApiDocs.java similarity index 82% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/UpdateLogApiDocs.java rename to modules/util/api/src/main/java/com/icc/qasker/aws/doc/UpdateLogApiDocs.java index 1dd1472f..fc2bcf8b 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/doc/UpdateLogApiDocs.java +++ b/modules/util/api/src/main/java/com/icc/qasker/aws/doc/UpdateLogApiDocs.java @@ -1,8 +1,8 @@ -package com.icc.qasker.quiz.controller.doc; +package com.icc.qasker.aws.doc; -import com.icc.qasker.quiz.dto.request.UpdateLogRequest; -import com.icc.qasker.quiz.dto.response.UpdateLogResponse; +import com.icc.qasker.aws.dto.request.UpdateLogRequest; +import com.icc.qasker.aws.dto.response.UpdateLogResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; @@ -20,5 +20,4 @@ public interface UpdateLogApiDocs { @Operation(summary = "변경사항 업데이트를 보낸다") @PostMapping ResponseEntity createUpdateLog(@RequestBody UpdateLogRequest request); - } diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/UpdateLogRequest.java b/modules/util/api/src/main/java/com/icc/qasker/aws/dto/request/UpdateLogRequest.java similarity index 57% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/UpdateLogRequest.java rename to modules/util/api/src/main/java/com/icc/qasker/aws/dto/request/UpdateLogRequest.java index d1e21aa7..585d92d8 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/request/UpdateLogRequest.java +++ b/modules/util/api/src/main/java/com/icc/qasker/aws/dto/request/UpdateLogRequest.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.request; +package com.icc.qasker.aws.dto.request; public record UpdateLogRequest(String updateText) { diff --git a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/UpdateLogResponse.java b/modules/util/api/src/main/java/com/icc/qasker/aws/dto/response/UpdateLogResponse.java similarity index 81% rename from modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/UpdateLogResponse.java rename to modules/util/api/src/main/java/com/icc/qasker/aws/dto/response/UpdateLogResponse.java index 63364852..9e8890c1 100644 --- a/modules/quiz/api/src/main/java/com/icc/qasker/quiz/dto/response/UpdateLogResponse.java +++ b/modules/util/api/src/main/java/com/icc/qasker/aws/dto/response/UpdateLogResponse.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.dto.response; +package com.icc.qasker.aws.dto.response; import java.time.Instant; import java.util.List; diff --git a/modules/util/impl/build.gradle b/modules/util/impl/build.gradle new file mode 100644 index 00000000..946b6a7f --- /dev/null +++ b/modules/util/impl/build.gradle @@ -0,0 +1,8 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(":global") + implementation project(":util:util-api") +} \ No newline at end of file diff --git a/app/src/main/java/com/icc/qasker/HelloController.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/controller/HelloController.java similarity index 93% rename from app/src/main/java/com/icc/qasker/HelloController.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/controller/HelloController.java index d54958ca..fe243430 100644 --- a/app/src/main/java/com/icc/qasker/HelloController.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/controller/HelloController.java @@ -1,4 +1,4 @@ -package com.icc.qasker; +package com.icc.qasker.aws.controller; import java.util.HashMap; import java.util.Map; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/UpdateLogController.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/controller/UpdateLogController.java similarity index 77% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/UpdateLogController.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/controller/UpdateLogController.java index ec08a958..00255626 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/controller/UpdateLogController.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/controller/UpdateLogController.java @@ -1,9 +1,9 @@ -package com.icc.qasker.quiz.controller; +package com.icc.qasker.aws.controller; -import com.icc.qasker.quiz.controller.doc.UpdateLogApiDocs; -import com.icc.qasker.quiz.dto.request.UpdateLogRequest; -import com.icc.qasker.quiz.dto.response.UpdateLogResponse; -import com.icc.qasker.quiz.service.UpdateLogService; +import com.icc.qasker.aws.doc.UpdateLogApiDocs; +import com.icc.qasker.aws.dto.request.UpdateLogRequest; +import com.icc.qasker.aws.dto.response.UpdateLogResponse; +import com.icc.qasker.aws.service.UpdateLogService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -24,6 +24,7 @@ public ResponseEntity getUpdateLog() { return ResponseEntity.ok(updateService.getUpdateLog()); } + @PostMapping public ResponseEntity createUpdateLog( @RequestBody UpdateLogRequest request) { diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/UpdateLog.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/entity/UpdateLog.java similarity index 94% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/UpdateLog.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/entity/UpdateLog.java index a48a94ce..311518d6 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/entity/UpdateLog.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/entity/UpdateLog.java @@ -1,4 +1,4 @@ -package com.icc.qasker.quiz.entity; +package com.icc.qasker.aws.entity; import com.icc.qasker.global.entity.CreatedAt; import jakarta.persistence.Column; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/UpdateLogResponseMapper.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/mapper/UpdateLogResponseMapper.java similarity index 75% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/UpdateLogResponseMapper.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/mapper/UpdateLogResponseMapper.java index 4aec06b0..4dd17531 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/mapper/UpdateLogResponseMapper.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/mapper/UpdateLogResponseMapper.java @@ -1,7 +1,8 @@ -package com.icc.qasker.quiz.mapper; +package com.icc.qasker.aws.mapper; -import com.icc.qasker.quiz.dto.response.UpdateLogResponse; -import com.icc.qasker.quiz.entity.UpdateLog; + +import com.icc.qasker.aws.dto.response.UpdateLogResponse; +import com.icc.qasker.aws.entity.UpdateLog; import java.util.List; public final class UpdateLogResponseMapper { diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/UpdateLogRepository.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/repository/UpdateLogRepository.java similarity index 72% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/UpdateLogRepository.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/repository/UpdateLogRepository.java index 4be4740d..31ba8b75 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/repository/UpdateLogRepository.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/repository/UpdateLogRepository.java @@ -1,6 +1,6 @@ -package com.icc.qasker.quiz.repository; +package com.icc.qasker.aws.repository; -import com.icc.qasker.quiz.entity.UpdateLog; +import com.icc.qasker.aws.entity.UpdateLog; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/UpdateLogService.java b/modules/util/impl/src/main/java/com/icc/qasker/aws/service/UpdateLogService.java similarity index 71% rename from modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/UpdateLogService.java rename to modules/util/impl/src/main/java/com/icc/qasker/aws/service/UpdateLogService.java index d8eaf88d..eed2816a 100644 --- a/modules/quiz/impl/src/main/java/com/icc/qasker/quiz/service/UpdateLogService.java +++ b/modules/util/impl/src/main/java/com/icc/qasker/aws/service/UpdateLogService.java @@ -1,10 +1,11 @@ -package com.icc.qasker.quiz.service; +package com.icc.qasker.aws.service; -import com.icc.qasker.quiz.dto.request.UpdateLogRequest; -import com.icc.qasker.quiz.dto.response.UpdateLogResponse; -import com.icc.qasker.quiz.entity.UpdateLog; -import com.icc.qasker.quiz.mapper.UpdateLogResponseMapper; -import com.icc.qasker.quiz.repository.UpdateLogRepository; + +import com.icc.qasker.aws.dto.request.UpdateLogRequest; +import com.icc.qasker.aws.dto.response.UpdateLogResponse; +import com.icc.qasker.aws.entity.UpdateLog; +import com.icc.qasker.aws.mapper.UpdateLogResponseMapper; +import com.icc.qasker.aws.repository.UpdateLogRepository; import lombok.AllArgsConstructor; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; @@ -26,7 +27,8 @@ public UpdateLogResponse getUpdateLog() { @Transactional @CachePut(value = "recentUpdateLog", key = "'root'") - public UpdateLogResponse createUpdateLog(UpdateLogRequest request) { + public UpdateLogResponse createUpdateLog( + UpdateLogRequest request) { updateLogRepository.save(UpdateLog.builder() .updateText(request.updateText()) .build()); diff --git a/settings.gradle b/settings.gradle index 423c6734..242b8125 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,7 +3,8 @@ rootProject.name = 'q-asker' include ':app', ':global', ':auth:auth-api', ':auth:auth-impl', ':aws:aws-api', ':aws:aws-impl', - ':quiz:quiz-api', ':quiz:quiz-impl' + ':quiz:quiz-api', ':quiz:quiz-impl', + ':util:util-api', ':util:util-impl' project(':app').projectDir = file('app') project(':global').projectDir = file('modules/global') @@ -16,3 +17,6 @@ project(':aws:aws-impl').projectDir = file('modules/aws/impl') project(':quiz:quiz-api').projectDir = file('modules/quiz/api') project(':quiz:quiz-impl').projectDir = file('modules/quiz/impl') + +project(':util:util-api').projectDir = file('modules/util/api') +project(':util:util-impl').projectDir = file('modules/util/impl')