diff --git a/.DS_Store b/.DS_Store index 66a022a1..cd5117de 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 724e4b9b..8616aa55 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -7,24 +7,8 @@ on: branches: [ main, develop ] jobs: - test: + build-and-push: runs-on: ubuntu-latest - permissions: - checks: write - - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: dondothat1234 - MYSQL_DATABASE: dondothat - ports: - - 3306:3306 - options: >- - --health-cmd="mysqladmin ping -h localhost" - --health-interval=10s - --health-timeout=5s - --health-retries=5 steps: - uses: actions/checkout@v3 @@ -48,70 +32,17 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Wait for MySQL to be ready - run: | - for i in {30..0}; do - if mysqladmin ping -h"127.0.0.1" --silent; then - echo "MySQL is ready" - break - fi - echo "Waiting for MySQL... ($i seconds remaining)" - sleep 1 - done - if [ "$i" = 0 ]; then - echo "MySQL failed to start" - exit 1 - fi - - - name: Verify MySQL connection and create test database - run: | - mysql -h127.0.0.1 -uroot -pdondothat1234 -e "SELECT VERSION();" # 수정 - mysql -h127.0.0.1 -uroot -pdondothat1234 -e "CREATE DATABASE IF NOT EXISTS dondothat CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 수정 - mysql -h127.0.0.1 -uroot -pdondothat1234 -e "SHOW DATABASES;" # 수정 - - - name: Run tests + - name: Install Gradle manually run: | - set -x - ./gradlew --version - java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain tasks - java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain test - env: - SPRING_PROFILES_ACTIVE: test - - - name: Generate test report - uses: dorny/test-reporter@v1 - if: success() || failure() - with: - name: Gradle Tests - path: build/test-results/test/*.xml - reporter: java-junit - fail-on-error: true - - build-and-push: - needs: test - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + wget -q https://services.gradle.org/distributions/gradle-8.8-bin.zip + unzip -q gradle-8.8-bin.zip + export PATH=$PATH:$(pwd)/gradle-8.8/bin + gradle --version - name: Build with Gradle - run: java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain build -x test + run: | + export PATH=$PATH:$(pwd)/gradle-8.8/bin + gradle build -x test - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -133,12 +64,14 @@ jobs: cache-to: type=gha,mode=max deploy: - needs: [test, build-and-push] + needs: [build-and-push] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' steps: - - name: Deploy with Docker Compose + - uses: actions/checkout@v3 + + - name: Deploy with Docker Compose and Nginx uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.EC2_HOST }} @@ -146,29 +79,114 @@ jobs: key: ${{ secrets.EC2_SSH_KEY }} timeout: 300s script: | - # Docker Compose 파일 생성 (Spring Legacy + Tomcat) - cat > docker-compose.yml << 'EOF' + # 환경 변수 설정 + export GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" + export GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" + export NAVER_CLIENT_ID="${{ secrets.NAVER_CLIENT_ID }}" + export NAVER_CLIENT_SECRET="${{ secrets.NAVER_CLIENT_SECRET }}" + export BASE_URL="${{ secrets.BASE_URL }}" + export JWT_SECRET="${{ secrets.JWT_SECRET }}" + export AES_SECRET_KEY="${{ secrets.AES_SECRET_KEY }}" + export CODEF_CLIENT_ID="${{ secrets.CODEF_CLIENT_ID }}" + export CODEF_CLIENT_SECRET="${{ secrets.CODEF_CLIENT_SECRET }}" + export CODEF_PUBLIC_KEY="${{ secrets.CODEF_PUBLIC_KEY }}" + export API_KEY="${{ secrets.API_KEY }}" + export FSS_API_KEY="${{ secrets.FSS_API_KEY }}" + export FSS_API_URL="${{ secrets.FSS_API_URL }}" + export LLM_SERVER_URL="${{ secrets.LLM_SERVER_URL }}" + + # SSL 인증서 디렉토리 생성 (없다면) + mkdir -p ssl + + # SSL 인증서 생성 (없다면) + if [ ! -f ssl/server.key ] || [ ! -f ssl/server.crt ]; then + cd ssl + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout server.key -out server.crt \ + -subj "/C=KR/ST=Seoul/L=Seoul/O=DonDoThat/CN=54.208.50.238" + cd .. + fi + + # Docker Compose 파일 생성 + cat > docker-compose.yml << EOF version: "3.8" services: + nginx: + image: nginx:alpine + container_name: nginx-proxy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + depends_on: + - dondothat-server + - llm-server + networks: + - dondothat-network + restart: always + + redis-server: + image: redis:7-alpine + container_name: redis-server + restart: always + expose: + - "6379" + networks: + - dondothat-network + + llm-server: + image: ec2-user-llm-server + container_name: llm-server + expose: + - "8000" + environment: + - API_KEY=${{ secrets.API_KEY }} + networks: + - dondothat-network + restart: unless-stopped + dondothat-server: image: ghcr.io/${{ github.repository_owner }}/dondothat:latest container_name: dondothat-server - ports: - - "8080:8080" + expose: + - "8080" environment: - SPRING_PROFILES_ACTIVE=prod - DB_HOST=mysql-server - DB_USERNAME=root - DB_PASSWORD=dondothat1234 - DB_NAME=dondothat + - REDIS_HOST=redis-server + - SPRING_MAIL_HOST=${{ secrets.SMTP_HOST }} + - SPRING_MAIL_PORT=${{ secrets.SMTP_PORT }} + - SPRING_MAIL_USERNAME=${{ secrets.SMTP_USERNAME }} + - SPRING_MAIL_PASSWORD=${{ secrets.SMTP_PASSWORD }} + - SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=${{ secrets.SMTP_AUTH }} + - SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=${{ secrets.SMTP_STARTTLS_ENABLE }} + - GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID + - GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET + - NAVER_CLIENT_ID=$NAVER_CLIENT_ID + - NAVER_CLIENT_SECRET=$NAVER_CLIENT_SECRET + - BASE_URL=$BASE_URL + - JWT_SECRET=$JWT_SECRET + - AES_SECRET_KEY=$AES_SECRET_KEY + - CODEF_CLIENT_ID=$CODEF_CLIENT_ID + - CODEF_CLIENT_SECRET=$CODEF_CLIENT_SECRET + - CODEF_PUBLIC_KEY=$CODEF_PUBLIC_KEY + - FSS_API_KEY=${{ secrets.FSS_API_KEY }} + - FSS_API_URL=${{ secrets.FSS_API_URL }} + - LLM_SERVER_URL=${{ secrets.LLM_SERVER_URL }} depends_on: - - mysql + - mysql-server + - redis-server networks: - dondothat-network restart: unless-stopped - mysql: + mysql-server: image: mysql:8.0 container_name: mysql-server environment: @@ -192,6 +210,8 @@ jobs: # 최신 이미지 pull docker pull ghcr.io/${{ github.repository_owner }}/dondothat:latest + docker pull redis:7-alpine + docker pull nginx:alpine # 서비스 재시작 /usr/local/bin/docker-compose down || true @@ -200,4 +220,15 @@ jobs: # 상태 확인 sleep 15 /usr/local/bin/docker-compose ps - /usr/local/bin/docker-compose logs --tail=10 dondothat-server \ No newline at end of file + /usr/local/bin/docker-compose logs --tail=10 nginx + /usr/local/bin/docker-compose logs --tail=10 dondothat-server + + - name: Copy Nginx config to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "nginx/nginx.conf" + target: "." + strip_components: 1 diff --git a/build.gradle b/build.gradle index a536725f..ae7c41e1 100644 --- a/build.gradle +++ b/build.gradle @@ -24,11 +24,10 @@ test { } } -sourceCompatibility = '17' -targetCompatibility = '17' - -tasks.withType(JavaCompile) { - options.encoding = 'UTF-8' +java { + sourceCompatibility = '17' + targetCompatibility = '17' + [compileJava, compileTestJava, javadoc]*.options*.encoding = 'UTF-8' } dependencies { @@ -39,8 +38,40 @@ dependencies { implementation "org.springframework:spring-tx:${springVersion}" implementation "org.springframework:spring-jdbc:${springVersion}" + // Spring Security + implementation "org.springframework.security:spring-security-core:5.8.12" + implementation "org.springframework.security:spring-security-web:5.8.12" + implementation "org.springframework.security:spring-security-config:5.8.12" + + // OAuth2 Client + implementation 'org.springframework.security:spring-security-oauth2-client:5.8.12' + implementation 'org.springframework.security:spring-security-oauth2-core:5.8.12' + implementation 'org.springframework.security:spring-security-oauth2-jose:5.8.12' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // HTTP Client + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + // Web Socket implementation "org.springframework:spring-websocket:${springVersion}" + implementation "org.springframework:spring-messaging:${springVersion}" + + // Email 관련 의존성 + implementation 'org.springframework:spring-context-support:5.3.37' + implementation 'javax.mail:javax.mail-api:1.6.2' + implementation 'com.sun.mail:javax.mail:1.6.2' + + // Bean Validation (JSR-380) + implementation 'javax.validation:validation-api:2.0.1.Final' + implementation 'org.hibernate.validator:hibernate-validator:6.0.13.Final' + implementation 'org.glassfish:javax.el:3.0.1-b08' + + // @PostConstruct + implementation 'javax.annotation:javax.annotation-api:1.3.2' // AOP implementation 'org.aspectj:aspectjrt:1.9.20' @@ -51,7 +82,6 @@ dependencies { implementation('javax.servlet:javax.servlet-api:4.0.1') compileOnly 'javax.servlet.jsp:javax.servlet.jsp-api:2.3.3' implementation 'javax.servlet:jstl:1.2' - compileOnly('jakarta.servlet:jakarta.servlet-api:6.1.0') // Logging implementation 'org.apache.logging.log4j:log4j-api:2.18.0' @@ -59,14 +89,13 @@ dependencies { implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.18.0' implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4:1.16' -// implementation 'org.apache.logging.log4j:log4j-api:2.0.1' -// implementation 'org.apache.logging.log4j:log4j-core:2.0.1' - // XML 내 한글 처리 implementation 'xerces:xercesImpl:2.12.2' // Jackson - implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4' +// implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.4' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // Database implementation 'com.mysql:mysql-connector-j:8.1.0' @@ -74,25 +103,27 @@ dependencies { implementation 'org.mybatis:mybatis:3.4.6' implementation 'org.mybatis:mybatis-spring:1.3.2' + // Redis + implementation 'org.springframework.data:spring-data-redis:2.7.18' + implementation 'io.lettuce:lettuce-core:6.2.7.RELEASE' + // Lombok compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok:${lombokVersion}" // 테스트 testImplementation "org.springframework:spring-test:${springVersion}" + testImplementation "org.springframework:spring-websocket:${springVersion}" + testImplementation "org.springframework:spring-messaging:${springVersion}" testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" testImplementation 'com.mysql:mysql-connector-j:8.1.0' testImplementation 'org.mockito:mockito-core:5.12.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.12.0' + testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation 'org.hamcrest:hamcrest:2.2' + testImplementation 'org.hamcrest:hamcrest-core:2.2' + testImplementation 'org.hamcrest:hamcrest-library:2.2' testCompileOnly "org.projectlombok:lombok:${lombokVersion}" testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" -} - -test { - useJUnitPlatform() - testLogging { - events "passed", "skipped", "failed" - exceptionFormat "full" - } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a122c231..2356830f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,68 @@ version: "3.8" services: + nginx: + image: nginx:alpine + container_name: nginx-proxy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + depends_on: + - dondothat-server + - llm-server + networks: + - backend + restart: always + + redis-server: + image: redis:7-alpine + container_name: redis-server + restart: always + ports: + - "6379:6379" + networks: + - backend + dondothat-server: build: context: ./ dockerfile: Dockerfile container_name: dondothat-server - ports: - - "8080:8080" + # 포트를 내부 네트워크에서만 접근 가능하도록 변경 + expose: + - "8080" environment: - DB_HOST=mysql-server - DB_PORT=3306 - DB_NAME=dondothat - DB_USER=root - DB_PASSWORD=dondothat1234 + - REDIS_HOST=redis-server + - SPRING_MAIL_HOST=${SPRING_MAIL_HOST} + - SPRING_MAIL_PORT=${SPRING_MAIL_PORT} + - SPRING_MAIL_USERNAME=${SPRING_MAIL_USERNAME} + - SPRING_MAIL_PASSWORD=${SPRING_MAIL_PASSWORD} + - SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH} + - SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - NAVER_CLIENT_ID=${NAVER_CLIENT_ID} + - NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET} + - BASE_URL=${BASE_URL} + - JWT_SECRET=${JWT_SECRET} + - AES_SECRET_KEY=${AES_SECRET_KEY} + - CODEF_CLIENT_ID=${CODEF_CLIENT_ID} + - CODEF_CLIENT_SECRET=${CODEF_CLIENT_SECRET} + - CODEF_PUBLIC_KEY=${CODEF_PUBLIC_KEY} + - FSS_API_KEY=${FSS_API_KEY} networks: - backend depends_on: - - mysql-server + - mysql + - redis-server llm-server: build: @@ -26,8 +71,8 @@ services: container_name: llm-server env_file: - ./llm-server/.env - ports: - - "8000:8000" + expose: + - "8000" networks: - backend diff --git a/llm-server/server.py b/llm-server/server.py index 114b2094..c0c2d55a 100644 --- a/llm-server/server.py +++ b/llm-server/server.py @@ -3,83 +3,399 @@ from openai import OpenAI import asyncio from dotenv import load_dotenv -from typing import List +from typing import List, Dict, Any +from datetime import datetime import os +import re +import json load_dotenv() client = OpenAI(api_key=os.getenv("API_KEY")) -app=FastAPI() +app = FastAPI() -class Expenditure(BaseModel): +class Exp(BaseModel): expenditure_id: int - user_id: int + description: str + +class ExpsList(BaseModel): + exps: List[Exp] + +class ExpForAnalytics(BaseModel): category_id: int - asset_id: int amount: int - description: str - expenditure_date: str - created_at: str - updated_at: str - -class ExpenditureBatch(BaseModel): - exps:List[Expenditure] - -def classify_category(desc): - prompt = f''' - 소비내역: {desc} - 소비내역을 아래 카테고리 번호 중 하나로 분류하세요. 설명없이 카테고리 번호만 출력하세요. - (카페/간식:1, 편의점:2, 식비:3, 택시:5, 쇼핑:6, - 술/유흥:7, 문화(영화관, 티켓, 공연, 스포츠):8, - 의료(병원/약국):9, 생활(마트/생활/주거):10, 기타:11, 대중교통:12) - 우아한형제들과 요기요만 4로 분류하세요.: - ''' + expenditure_date: datetime + +class ExpsAnalytics(BaseModel): + exps: List[ExpForAnalytics] + +class SavingProduct(BaseModel): + finPrdtCd: str + korCoNm: str + finPrdtNm: str + spclCnd: str + joinMember: str + intrRate: float + intrRate2: float + +class SavingRecommendRequest(BaseModel): + savings: List[SavingProduct] + userAge: int + userJob: str + mainBankName: str = None + +# classify 키워드 +category_keyword_map = { + 1: ["우아한형제들", "요기요", "배달의민족", "배민", "쿠팡이츠"], + 2: ["스타벅스", "투썸", "컴포즈", "매머드", "커피", "카페", "베이커리","파리바게뜨","공차", + "이디야", "빽다방","쥬씨","배스킨라빈스","요거트","던킨","노티드","설빙","젤라또"], + 4: ["카카오T", "택시", "우버","T블루"], + 5: ["CU", "씨유", "GS25", "지에스25", "세븐일레븐", "이마트24", "미니스톱"], + 6: ["CGV", "메가박스", "시네마", "예스24", "인터파크", "도서", "공연", "문화", "티켓", "문고", "알라딘","책방","씨어터","미술관"], + 7: ["맥주", "소주", "술집", "포차", "와인", "호프","펍","주막"], + 8: ["버스", "지하철", "교통카드", "티머니", "T머니", "코레일", "KTX", "공항철도"], + 9: ["약국", "병원", "의원", "치과", "내과","외과", "이비인후과","안과", "피부과","정형외과"], + 10: ["이마트", "홈플러스","마트", "다이소", "코스트코", "마켓컬리", "로켓프레시","안경","전기","수도","가스"], + 11: ["김밥", "분식", "식당", "라멘", "파스타", "카츠", "맥도날드","KFC" ,"우동", "텐동","써브웨이","샤브","해화로", + "칼국수", "버거", "스시", "포케","롯데리아","맘스터치" ,"떡볶이", "도시락", "순대","육회","세종원","카레", + "치킨","통닭", "피자","해장","국수","국밥","토스트","감자탕", "갈비","브런치" ,"고기","한우","삼겹","프레퍼스", + "다이닝","타코","비스트로","레스토랑","반점","키친","그릴","정성","할머니","숯불","리필","원조","전통","샹츠마라"], + 3: ["백화점", "아울렛","면세점","올리브영", "무신사", "에이블리","지그재그" ,"쿠팡", "11번가", "G마켓", + "SSG", "롯데온", "네이버","나이스페이먼츠","ALIPAY","ARS","KCP"], +} + +# 키워드 필터링 함수 +def keyword_filtering(desc: str) -> int: + for id, keywords in category_keyword_map.items(): + for k in keywords: + if k in desc: + return id + return -1 + +# 한 번에 여러 건을 분류하는 LLM 함수 +def classify_batch_llm(items: List[Dict]) -> Dict[int, int]: + + system_prompt = ( + "너는 영수증의 상호명을 정확히 하나의 카테고리로 분류하는 분류기다. " + "반드시 제공된 ENUM(키) 중 하나만 선택한다. " + "입력은 [expenditure_id:int, description:str]의 JSON 배열이다. " + '반드시 JSON 객체 하나로만 응답하고, "results" 키 아래에 ' + "[expenditure_id:int, category_id:int] 쌍의 배열만 담아라. " + '예: {"results": [[1, 11], [2, 2], ...]}' + ) + + # LLM에 보내는 inputs를 [id, desc]의 compact 배열로 구성 + compact_inputs = [[it["expenditure_id"], it["description"]] for it in items] + + user_payload = { + "inputs": compact_inputs, + "enum": { + "2": "카페/간식/디저트", + "3": "쇼핑", + "5": "편의점", + "6": "문화(영화,공연,도서,전시)", + "7": "술/유흥", + "8": "대중교통", + "9": "의료", + "10": "생활(마트,주거,통신,이발)", + "11": "식비(식당,음식점)", + "12": "기타" + }, + "rules": [ + "브랜드/장소가 명확하면 해당 카테고리 우선(예: 스타벅스=2).", + "애매하면 문자열 주요 키워드만 분석하여, 대한민국 기준으로 가장 가능성 높은 카테고리로 추정.", + "전혀 예측할 수 없는 경우에만 불가피하게 12로 지정.(최대한 피한다.)" + ], + "output_schema": {"results": [["int", "int"]]} + } + response = client.chat.completions.create( - model="gpt-4.1-nano", - messages=[{"role": "user", "content": prompt}], - temperature=0 + model="gpt-4o", + temperature=0, + top_p=0, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)}, + ] ) - return response.choices[0].message.content.strip() -async def classify_category_async(desc): - loop = asyncio.get_event_loop() - # OpenAI 동기 호출을 스레드 풀로 감싸서 비동기처럼 동작 - return await loop.run_in_executor(None, lambda: classify_category(desc)) + raw = response.choices[0].message.content + try: + data = json.loads(raw) + pairs = data.get("results", []) + except Exception: + # 비정상 출력 시 [id, 12] 쌍 배열로 방어 + pairs = [[it["expenditure_id"], 12] for it in items] + + # CHANGED: [id, cat] → {id: cat} 매핑으로 변환 (반환 타입은 기존과 동일) + out: Dict[int, int] = {} + for p in pairs: + try: + eid, cid = int(p[0]), int(p[1]) + except Exception: + # 방어적 캐스팅 + try: + eid = int(p[0]) + except Exception: + continue + cid = 12 + out[eid] = cid + + return out + +# 동시 배치 수 제한을 위한 세마포어 +_SEMAPHORE = asyncio.Semaphore(16) # 병렬 갯수 +_BATCH_SIZE = 16 # 배치 크기 -# 단일 분류 API +# 배치 호출을 비동기로 감싸기 (블로킹 SDK 호출을 스레드로 우회) +async def _classify_batch_llm_async(items: List[Dict]) -> Dict[int, int]: + async with _SEMAPHORE: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: classify_batch_llm(items)) + +# 분류 API @app.post("/classify") -async def classify(exp: Expenditure): - category = classify_category(exp.description) - return {"expenditure_id": exp.expenditure_id, "category_id": int(category)} - -# 여러 개 한 번에 분류 (배치) -@app.post("/classify_batch") -async def classify_batch(batch: ExpenditureBatch): - tasks = [classify_category_async(e.description) for e in batch.exps] - categories = await asyncio.gather(*tasks) - results = [ - {"expenditure_id": e.expenditure_id, "category_id": int(c)} - for e, c in zip(batch.exps, categories) - ] - return {"results": results} +async def classify(list: ExpsList): + # 1) 키워드 필터로 즉시 결정 가능한 것 선별 + decided: Dict[int, int] = {} + undecided: List[Dict] = [] + + for e in list.exps: + k = keyword_filtering(e.description or "") + if k != -1: + decided[e.expenditure_id] = k + else: + undecided.append({"expenditure_id": e.expenditure_id, "description": e.description}) + + # 2) 필터링 후 남은 것들 + + # 같은 description은 한 번만 LLM에 보내게끔 설정 + by_desc: Dict[str, List[int]] = {} + for it in undecided: + d = it["description"] + by_desc.setdefault(d, []).append(it["expenditure_id"]) + # 대표 id 하나만 들고 가서 LLM 호출 + uniq_items = [{"expenditure_id": ids[0], "description": desc} + for desc, ids in by_desc.items()] + + # 배치 LLM 호출(세마포어로 동시성 제한) + tasks = [] + for i in range(0, len(uniq_items), _BATCH_SIZE): + batch = uniq_items[i:i + _BATCH_SIZE] + tasks.append(_classify_batch_llm_async(batch)) + + results_llm: Dict[int, int] = {} + if tasks: + for chunk in await asyncio.gather(*tasks): + results_llm.update(chunk) + + # 대표 id의 예측을 동일 description의 나머지 id에 전파 + for desc, ids in by_desc.items(): + rep_id = ids[0] + pred = int(results_llm.get(rep_id, 12)) + for eid in ids: # 대표 포함 전체에 동일 적용 + results_llm[eid] = pred + + # 3) 결합 결과 생성 + final = [] + for e in list.exps: + cid = decided.get(e.expenditure_id) or results_llm.get(e.expenditure_id) or 12 # 방어적 기본값 + final.append({"expenditure_id": e.expenditure_id, "category_id": int(cid)}) + + return {"results": final} + +# 분석 API @app.post("/analysis") -async def analysis(batch: ExpenditureBatch): - simplified = [{"category_id": e.category_id, "amount": e.amount} for e in batch.exps] - prompt = f''' - 소비내역:{simplified} - 아래 조건을 기준으로 '과소비' 카테고리 상위 3개를 선정하세요: - 1. 전체 소비 중 비율이 높은 카테고리. - 2. 카페/간식(1), 편의점(2), 배달음식(4), 택시(5), 쇼핑(6), 술/유흥(7), 문화(8) 등 사치성 소비에 가중치를 둠. - 3. 절대 지출 금액이 높을 경우 우선 고려. - 설명 없이 과소비 카테고리 번호만 쉼표로 구분하여 출력하세요. - ''' +async def analysis(list: ExpsAnalytics): + + # compact 형태로 변환 ([category_id, amount, date]) + simplified = [ + [e.category_id, e.amount, e.expenditure_date.strftime("%Y-%m-%d")] # CHANGED + for e in list.exps + ] + + SYS = ( + "당신은 개인 금융 분석가. " + "user가 준 rules만 근거로 판단하고, 정수 배열로만 출력하라. " + "입력 데이터는 [category_id:int, amount:int, date:str] 배열 형식이다." + ) + + USER = { + "task":"과소비 카테고리 상위 3개 선정", + "rules":[ + "최근 60~30일의 지출 대비 최근 30일의 지출 증가율을 최우선 고려.", + "증가율이 같다면 사치성 소비를 우선. ", + "절대 지출 금액 우선. ", + "최근 30일에 새로 등장한 카테고리는 가산점.", + ], + "categories":{ + 1: "배달음식", 2: "카페/간식", 3: "쇼핑", 4: "택시", + 5: "편의점", 6: "문화", 7: "술/유흥", + }, + "data":simplified, + "output": "중복없는 3개의 정수 배열(예: [1,3,7])" + } + response = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": prompt}], - temperature=0 + model="gpt-4o", + messages=[ + {"role": "system", "content": SYS}, + {"role": "user", "content": json.dumps(USER, ensure_ascii=False)}, + ], + response_format={ + "type": "json_schema", + "json_schema": { + "name": "top3", + "strict": True, + "schema": { + "type": "object", # 루트는 object + "properties": { + "results": { + "type": "array", + "items": {"type": "integer", "minimum": 1, "maximum": 7}, + "minItems": 3, + "maxItems": 3, + } + }, + "required": ["results"], + "additionalProperties": False + } + } + }, + temperature=0, ) + res = response.choices[0].message.content.strip() - result_list = [int(x.strip()) for x in res.split(",") if x.strip()] + # 응답에서 숫자 추출 및 유효성 검사 + numbers = re.findall(r'\b([1-7])\b', res) # 1~7만 매치 + result_list = [int(x) for x in numbers[:3]] # 최대 3개만 + + # 결과가 없으면 기본값 반환 + if not result_list: + result_list = [1, 3, 7] # 기본 과소비 카테고리 + return {"results": result_list} + +# 적금 상품 추천 API +@app.post("/recommend-savings") +async def recommend_savings(request: SavingRecommendRequest): + # 사용자 정보와 적금 상품 리스트를 기반으로 추천 + user_info = { + "age": request.userAge, + "job": request.userJob, + "mainBank": request.mainBankName or "없음" + } + + savings_data = [ + { + "상품코드": s.finPrdtCd, + "은행명": s.korCoNm, + "상품명": s.finPrdtNm, + "특별조건": s.spclCnd, + "가입대상": s.joinMember, + "기본금리": f"{s.intrRate}%", + "우대금리": f"{s.intrRate2}%" + } for s in request.savings + ] + + messages = [ + { + "role": "system", + "content": """당신은 금융 상품 추천 전문가입니다. 사용자의 상황에 가장 적합한 적금 상품 3개를 추천하는 것이 당신의 역할입니다. + +추천 기준: +1. 사용자 나이와 가입대상 적합성 +2. 사용자 직업과 상품 특성 매칭 +3. 금리 경쟁력 (기본금리 + 우대금리) +4. 특별조건의 달성 가능성 +5. 은행 다양성 (가능한 다른 은행 상품 포함) + +추천 원칙: +- 주거래은행 상품이 있고 조건이 좋다면 2개까지 포함 가능 +- 나머지는 다른 은행의 경쟁력 있는 상품으로 구성 +- 최대한 다양한 은행에서 선택하여 포트폴리오 다양화 + +출력 형식: 상품코드 3개를 쉼표로 구분하여 출력 (예: 00266451,00123456,00789012) +반드시 제공된 상품 목록에서만 선택하세요.""" + }, + { + "role": "user", + "content": f"""다음 사용자 정보를 바탕으로 가장 적합한 적금 상품 3개를 추천하세요: + +사용자 정보: +- 나이: {user_info['age']}세 +- 직업: {user_info['job']} +- 주거래은행: {user_info['mainBank']} + +추천 대상 적금 상품 목록: +{savings_data} + +추천 조건: +- 사용자가 실제 가입 가능한 상품만 선택 +- 금리가 높고 조건이 유리한 상품 우선 +- 주거래은행 상품은 최대 2개까지 포함 (다른 은행 상품도 반드시 포함) +- 사용자 직업/연령대에 맞는 상품 우선 +- 서로 다른 은행 상품으로 구성하여 다양성 확보 + +상위 3개 추천 상품의 상품코드만 쉼표로 구분하여 출력:""" + } + ] + + response = client.chat.completions.create( + model="gpt-4o", + messages=messages, + temperature=0, + max_tokens=50 + ) + + res = response.choices[0].message.content.strip() + + # 응답에서 상품코드 추출 + product_codes = [code.strip() for code in res.split(',')] + + # 유효한 상품코드만 필터링 + valid_codes = [] + available_codes = [s.finPrdtCd for s in request.savings] + + for code in product_codes: + if code in available_codes and len(valid_codes) < 3: + valid_codes.append(code) + + # 추천된 상품 정보 반환 + recommended_products = [] + for code in valid_codes: + for saving in request.savings: + if saving.finPrdtCd == code: + recommended_products.append({ + "finPrdtCd": saving.finPrdtCd, + "korCoNm": saving.korCoNm, + "finPrdtNm": saving.finPrdtNm, + "spclCnd": saving.spclCnd, + "joinMember": saving.joinMember, + "intrRate": saving.intrRate, + "intrRate2": saving.intrRate2 + }) + break + + # 추천 결과가 부족하면 상위 상품으로 채우기 + if len(recommended_products) < 3: + remaining_needed = 3 - len(recommended_products) + recommended_codes = [p["finPrdtCd"] for p in recommended_products] + + for saving in request.savings: + if len(recommended_products) >= 3: + break + if saving.finPrdtCd not in recommended_codes: + recommended_products.append({ + "finPrdtCd": saving.finPrdtCd, + "korCoNm": saving.korCoNm, + "finPrdtNm": saving.finPrdtNm, + "spclCnd": saving.spclCnd, + "joinMember": saving.joinMember, + "intrRate": saving.intrRate, + "intrRate2": saving.intrRate2 + }) + return {"recommendations": recommended_products} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..b16524d0 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,77 @@ +events { + worker_connections 1024; +} + +http { + resolver 127.0.0.11 valid=30s; + + # WebSocket 연결 타임아웃 설정 + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 443 ssl; + server_name dondothat.store www.dondothat.store; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # WebSocket 전용 location + location /api/ws/ { + set $upstream_server dondothat-server:8080; + proxy_pass http://$upstream_server; + + # WebSocket 필수 헤더들 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # 기본 프록시 헤더들 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 타임아웃 설정 + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 86400s; + } + + # 일반 HTTP 요청 처리 + location / { + set $upstream_server dondothat-server:8080; + proxy_pass http://$upstream_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + server { + listen 80; + server_name dondothat.store www.dondothat.store; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl; + server_name 54.208.50.238; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + return 301 https://dondothat.store$request_uri; + } + + server { + listen 80; + server_name 54.208.50.238; + return 301 https://dondothat.store$request_uri; + } +} diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 00000000..ff4bcfd7 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,96 @@ +DROP DATABASE IF EXISTS dondothat; +CREATE DATABASE dondothat; +USE dondothat; + +-- 사용자 테이블 +CREATE TABLE `user` ( + `user_id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `password` VARCHAR(255) NOT NULL, + `point` BIGINT NOT NULL, + `nickname` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`) +); + +-- 카테고리 테이블 (챌린지용) +CREATE TABLE `category` ( + `category_id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `icon_url` VARCHAR(255) NOT NULL, + PRIMARY KEY (`category_id`) +); + +-- 챌린지 테이블 +CREATE TABLE `challenge` ( + `challenge_id` BIGINT NOT NULL AUTO_INCREMENT, + `category_id` BIGINT NOT NULL, + `title` VARCHAR(255) NOT NULL, + `summary` VARCHAR(255) NOT NULL, + `description` VARCHAR(255) NOT NULL, + PRIMARY KEY (`challenge_id`), + FOREIGN KEY (`category_id`) REFERENCES `category`(`category_id`) ON DELETE CASCADE +); + +-- 챌린지 참여 테이블 +CREATE TABLE `user_challenge` ( + `user_challenge_id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `challenge_id` BIGINT NOT NULL, + `status` ENUM('ongoing', 'completed', 'failed') NOT NULL, + `period` BIGINT NOT NULL, + `progress` BIGINT NOT NULL, + `start_date` TIMESTAMP NOT NULL, + `end_date` TIMESTAMP NOT NULL, + `point` BIGINT NOT NULL, + PRIMARY KEY (`user_challenge_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE, + FOREIGN KEY (`challenge_id`) REFERENCES `challenge`(`challenge_id`) ON DELETE CASCADE +); + +-- 채팅 메시지 테이블 +CREATE TABLE `chat_message` ( + `message_id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `challenge_id` BIGINT NOT NULL, + `message` VARCHAR(255) NOT NULL, + `sent_at` TIMESTAMP NOT NULL, + `message_type` VARCHAR(20) DEFAULT 'MESSAGE' NOT NULL, + PRIMARY KEY (`message_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE, + FOREIGN KEY (`challenge_id`) REFERENCES `challenge`(`challenge_id`) ON DELETE CASCADE +); + +-- expenditure 테이블 추가 (테스트에서 필요) +CREATE TABLE `expenditure` ( + `expenditure_id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `category_id` BIGINT NOT NULL, + `asset_id` BIGINT NOT NULL, + `amount` BIGINT NOT NULL, + `description` VARCHAR(255), + `expenditure_date` TIMESTAMP NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`expenditure_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE, + FOREIGN KEY (`category_id`) REFERENCES `category`(`category_id`) ON DELETE CASCADE +); + +-- user_asset 테이블 추가 +CREATE TABLE `user_asset` ( + `asset_id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `asset_name` VARCHAR(255) NOT NULL, + `balance` BIGINT NOT NULL, + `bank_name` VARCHAR(255) NOT NULL, + `bank_account` VARCHAR(255) NOT NULL, + `bank_id` VARCHAR(255), + `bank_pw` VARCHAR(255), + `connected_id` VARCHAR(255), + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`asset_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/test-data.sql b/sql/test-data.sql new file mode 100644 index 00000000..b43588df --- /dev/null +++ b/sql/test-data.sql @@ -0,0 +1,38 @@ +-- 기존 데이터 삭제 (스크립트를 여러 번 실행할 경우를 대비) +-- 참조 무결성을 위해 자식 테이블부터 삭제합니다. +SET FOREIGN_KEY_CHECKS = 0; -- 외래 키 제약 조건 비활성화 +TRUNCATE TABLE expenditure; +TRUNCATE TABLE user_asset; +TRUNCATE TABLE category; +TRUNCATE TABLE user; +TRUNCATE TABLE chat_message; +TRUNCATE TABLE user_challenge; +TRUNCATE TABLE challenge; +SET FOREIGN_KEY_CHECKS = 1; -- 외래 키 제약 조건 다시 활성화 + +-- 테스트에 필요한 데이터 삽입 +-- 사용자 (user_id = 1) +INSERT INTO `user` (name, email, password, point, nickname) +VALUES ('testuser', 'test@example.com', 'password123', 0, '테스트유저'); + +-- 추가 사용자 데이터 +INSERT INTO `user` (name, email, password, point, nickname) +VALUES ('testuser2', 'test2@example.com', 'password123', 0, '테스트유저2'); + +-- 카테고리 (category_id = 1, 2) +INSERT INTO `category` (category_id, name, icon_url) +VALUES (1, '식비', 'default_icon_url'),(2, '교통비', 'default_icon_url'); + +-- 자산 (asset_id = 1, user_id = 1) +INSERT INTO `user_asset` (user_id, asset_name, balance, bank_name, created_at, bank_account, bank_id, bank_pw, connected_id) +VALUES (1, '테스트은행 계좌', 1000000, '테스트은행', NOW(), '110-123-456789', 'test_bank_id', 'test_pw', 'test_conn_id'); + +-- 챌린지 1개 +INSERT INTO challenge (category_id, title, summary, description) +VALUES (1, '매일 1시간 걷기', '건강을 위한 첫 걸음', '매일 1시간씩 걸으며 건강한 습관을 기릅니다.'); + +-- 챌린지 참여 +INSERT INTO user_challenge (user_id, challenge_id, status, period, progress, start_date, end_date, point) +VALUES +(1, 1, 'ongoing', 30, 5, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 100), +(2, 1, 'ongoing', 30, 2, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 100); \ No newline at end of file diff --git a/src/main/java/org/bbagisix/HelloServlet.java b/src/main/java/org/bbagisix/HelloServlet.java index d95c2c41..0b0a7547 100644 --- a/src/main/java/org/bbagisix/HelloServlet.java +++ b/src/main/java/org/bbagisix/HelloServlet.java @@ -2,8 +2,13 @@ import java.io.*; -import jakarta.servlet.http.*; -import jakarta.servlet.annotation.*; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +// import jakarta.servlet.http.*; +// import jakarta.servlet.annotation.*; @WebServlet(name = "helloServlet", value = "/hello-servlet") public class HelloServlet extends HttpServlet { diff --git a/src/main/java/org/bbagisix/analytics/service/AnalyticsService.java b/src/main/java/org/bbagisix/analytics/service/AnalyticsService.java index 20c7685a..f8c669b1 100644 --- a/src/main/java/org/bbagisix/analytics/service/AnalyticsService.java +++ b/src/main/java/org/bbagisix/analytics/service/AnalyticsService.java @@ -1,4 +1,7 @@ package org.bbagisix.analytics.service; -public class AnalyticsService { +import java.util.List; + +public interface AnalyticsService { + List getTopCategories(Long userId); } diff --git a/src/main/java/org/bbagisix/analytics/service/AnalyticsServiceImpl.java b/src/main/java/org/bbagisix/analytics/service/AnalyticsServiceImpl.java new file mode 100644 index 00000000..b1a82d86 --- /dev/null +++ b/src/main/java/org/bbagisix/analytics/service/AnalyticsServiceImpl.java @@ -0,0 +1,70 @@ +package org.bbagisix.analytics.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.bbagisix.category.service.CategoryService; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.service.ExpenseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AnalyticsServiceImpl implements AnalyticsService { + + private final ExpenseService expenseService; + private final CategoryService categoryService; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${LLM_SERVER_URL}") + private String llmServerUrl; + + @Override + public List getTopCategories(Long userId) { + + try { + List expenses = expenseService.getRecentExpenses(userId); // 최근 2달 소비내역 + + // FastAPI 서버가 요구하는 형태로 변환 + List> expsForAnalytics = expenses.stream() + .map(e -> Map.of( + "category_id", e.getCategoryId(), + "amount", e.getAmount(), + "expenditure_date", e.getExpenditureDate() + )) + .collect(Collectors.toList()); + + Map payload = Map.of("exps", expsForAnalytics); + + String analysisUrl = llmServerUrl + "/analysis"; + Map response = restTemplate.postForObject(analysisUrl, payload, Map.class); + if (response == null || !response.containsKey("results")) { + throw new BusinessException(ErrorCode.LLM_ANALYTICS_ERROR); + } + + List resultsRaw = (List)response.get("results"); + List results = resultsRaw.stream() + .map(Number::longValue) + .toList(); + + return results; + + } catch (BusinessException e) { + log.warn("비즈니스 예외 발생: code={}, message={}", e.getCode(), e.getMessage()); + throw e; + } catch (Exception e) { + log.error("메시지 저장 중 예상하지 못한 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + + } +} diff --git a/src/main/java/org/bbagisix/asset/controller/AssetController.java b/src/main/java/org/bbagisix/asset/controller/AssetController.java index ba762922..b1bb9ebf 100644 --- a/src/main/java/org/bbagisix/asset/controller/AssetController.java +++ b/src/main/java/org/bbagisix/asset/controller/AssetController.java @@ -1,4 +1,131 @@ package org.bbagisix.asset.controller; +import java.util.Map; + +import org.bbagisix.asset.dto.AssetDTO; +import org.bbagisix.asset.service.AssetService; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.mapper.UserMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("/api/assets") +@RequiredArgsConstructor +@Log4j2 public class AssetController { -} + + private final AssetService assetService; + + private final UserMapper userMapper; + + @PostMapping("/connect") + public ResponseEntity> connectMainAsset( + @RequestBody AssetDTO assetDTO, + Authentication authentication + ) { + try { + Long userId = getUserId(authentication); + + String userName = userMapper.getNameByUserId(userId); + + String accountName = assetService.connectMainAsset(userId, assetDTO); + + String returnStr = userName + "님 " + accountName; + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "메인 계좌 연결이 완료되었습니다.", + "accountName", returnStr + )); + } catch (BusinessException e) { + return ResponseEntity.ok(Map.of( + "success", false, + "message", e.getErrorCode() + )); + } + + } + + @PostMapping("/connect/sub") + public ResponseEntity> connectSubAsset( + @RequestBody AssetDTO assetDTO, + Authentication authentication + ) { + try { + Long userId = getUserId(authentication); + + assetService.connectSubAsset(userId, assetDTO); + + String userName = userMapper.getNameByUserId(userId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "서브 계좌 연결이 완료되었습니다.", + "accountName", userName + "님 저축계좌" + )); + } catch (BusinessException e) { + return ResponseEntity.ok(Map.of( + "success", false, + "message", e.getErrorCode() + )); + } + + } + + @DeleteMapping + public ResponseEntity> deleteAsset( + @RequestParam String status, // main or sub + Authentication authentication + ) { + try { + Long userId = getUserId(authentication); + // 입력값 검증 + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + assetService.deleteAsset(userId, status); + + String message = "main".equals(status) ? "메인 계좌가" : "서브 계좌가"; + return ResponseEntity.ok(Map.of( + "success", true, + "message", message + " 성공적으로 삭제되었습니다." + )); + } catch (BusinessException e) { + return ResponseEntity.ok(Map.of( + "success", false, + "message", e.getErrorCode() + )); + } + + } + + // 사용자 ID 추출 및 검증 + private Long getUserId(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + } + + CustomOAuth2User curUser = (CustomOAuth2User)authentication.getPrincipal(); + + if (curUser == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + Long userId = curUser.getUserId(); + + return userId; + } + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/asset/domain/AssetVO.java b/src/main/java/org/bbagisix/asset/domain/AssetVO.java index f4c7cd09..96477f24 100644 --- a/src/main/java/org/bbagisix/asset/domain/AssetVO.java +++ b/src/main/java/org/bbagisix/asset/domain/AssetVO.java @@ -1,4 +1,31 @@ package org.bbagisix.asset.domain; +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@Builder(toBuilder = true) +@ToString +@AllArgsConstructor +@NoArgsConstructor public class AssetVO { + // asset db + private Long assetId; + private Long userId; + private String assetName; + private Long balance; + private String bankName; + private Date createdAt; + private String bankAccount; + private String bankId; + private String bankPw; + private String connectedId; + private String status; // main, sub } diff --git a/src/main/java/org/bbagisix/asset/dto/AssetDTO.java b/src/main/java/org/bbagisix/asset/dto/AssetDTO.java index 1ab9a9da..b54aeb0e 100644 --- a/src/main/java/org/bbagisix/asset/dto/AssetDTO.java +++ b/src/main/java/org/bbagisix/asset/dto/AssetDTO.java @@ -1,4 +1,17 @@ package org.bbagisix.asset.dto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor public class AssetDTO { + private String bankName; + private String bankId; + private String bankpw; + private String bankAccount; } diff --git a/src/main/java/org/bbagisix/asset/encryption/AESEncryptedTypeHandler.java b/src/main/java/org/bbagisix/asset/encryption/AESEncryptedTypeHandler.java new file mode 100644 index 00000000..57d00567 --- /dev/null +++ b/src/main/java/org/bbagisix/asset/encryption/AESEncryptedTypeHandler.java @@ -0,0 +1,109 @@ +package org.bbagisix.asset.encryption; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +// MyBatis TypeHandler : DB 저장 시 자동 암호화, 조회 시 자동 복호화 +@Component +public class AESEncryptedTypeHandler extends BaseTypeHandler implements ApplicationContextAware { + + private static EncryptionUtil encryptionUtil; + private static ApplicationContext applicationContext; + + // Spring ApplicationContext 설정 + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + AESEncryptedTypeHandler.applicationContext = applicationContext; + AESEncryptedTypeHandler.encryptionUtil = applicationContext.getBean(EncryptionUtil.class); + } + + // DB에 저장할 때 자동 암호화 + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) { + try { + validateEncryptionUtil(); + String encryptedValue = encryptionUtil.encryptAES(parameter); + ps.setString(i, encryptedValue); + } catch (SQLException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "DB 저장 시 SQL 오류가 발생했습니다: " + err.getMessage()); + } catch (Exception err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "DB 저장 시 암호화 처리 중 예상치 못한 오류가 발생했습니다: " + err.getMessage()); + } + } + + // DB에서 조회 할 때 자동 복호화 + @Override + public String getNullableResult(ResultSet rs, String columnName) { + try { + validateEncryptionUtil(); + String encryptedValue = rs.getString(columnName); + if (encryptedValue == null) { + return null; + } + return encryptionUtil.decryptAES(encryptedValue); + } catch (SQLException e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "DB 조회 시 SQL 오류가 발생했습니다 (컬럼: " + columnName + "): " + e.getMessage()); + } catch (Exception e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "DB 조회 시 복호화 처리 중 예상치 못한 오류가 발생했습니다 (컬럼: " + columnName + "): " + e.getMessage()); + } + } + + // DB에서 조회할 때 자동 복호화 + @Override + public String getNullableResult(ResultSet rs, int columnIndex) { + try { + validateEncryptionUtil(); + String encryptedValue = rs.getString(columnIndex); + if (encryptedValue == null) { + return null; + } + return encryptionUtil.decryptAES(encryptedValue); + } catch (SQLException e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "DB 조회 시 SQL 오류가 발생했습니다 (컬럼 인덱스: " + columnIndex + "): " + e.getMessage()); + } catch (Exception e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "DB 조회 시 복호화 처리 중 예상치 못한 오류가 발생했습니다 (컬럼 인덱스: " + columnIndex + "): " + e.getMessage()); + } + } + + // CallableStatement에서 조회할 때 자동 복호화 + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) { + try { + validateEncryptionUtil(); + String encryptedValue = cs.getString(columnIndex); + if (encryptedValue == null) { + return null; + } + return encryptionUtil.decryptAES(encryptedValue); + } catch (SQLException e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "CallableStatement 조회 시 SQL 오류가 발생했습니다 (컬럼 인덱스: " + columnIndex + "): " + e.getMessage()); + } catch (Exception e) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "CallableStatement 조회 시 복호화 처리 중 예상치 못한 오류가 발생했습니다 (컬럼 인덱스: " + columnIndex + "): " + e.getMessage()); + } + } + + // EncryptionUtil 주입 상태 검증 + private void validateEncryptionUtil() { + if (encryptionUtil == null) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "EncryptionUtil이 AESEncryptedTypeHandler에 주입되지 않았습니다. Spring 설정을 확인해주세요."); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/asset/encryption/EncryptionUtil.java b/src/main/java/org/bbagisix/asset/encryption/EncryptionUtil.java new file mode 100644 index 00000000..0f361a1d --- /dev/null +++ b/src/main/java/org/bbagisix/asset/encryption/EncryptionUtil.java @@ -0,0 +1,199 @@ +package org.bbagisix.asset.encryption; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.UnsupportedEncodingException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +// 암호화 +@Component +public class EncryptionUtil { + // RSA 관련 상수 + private static final String RSA_ALGORITHM = "RSA"; + private static final String RSA_CIPHER = "RSA/ECB/PKCS1Padding"; + + // AES 관련 상수 + private static final String AES_ALGORITHM = "AES"; + private static final String AES_CIPHER = "AES/CBC/PKCS5Padding"; + private static final int AES_KEY_LENGTH = 256; + private static final int AES_IV_LENGTH = 16; + private static final String ENCRYPTION_PREFIX = "ENC:"; // 암호화 데이터 식별용 + + @Value("${AES_SECRET_KEY}") + private String aesBase64SecretKey; + + // RSA 암호화 + public String encryptRSA(String plainText, String rsaBase64PublicKey) { + try { + byte[] bytePublicKey = Base64.getDecoder().decode(rsaBase64PublicKey); + KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(bytePublicKey)); + + Cipher cipher = Cipher.getInstance(RSA_CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] bytePlain = cipher.doFinal(plainText.getBytes()); + return Base64.getEncoder().encodeToString(bytePlain); + } catch (NoSuchPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 패딩 방식을 찾을 수 없습니다: " + err.getMessage()); + } catch (IllegalBlockSizeException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 암호화 블록 크기가 유효하지 않습니다: " + err.getMessage()); + } catch (NoSuchAlgorithmException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 알고리즘을 찾을 수 없습니다: " + err.getMessage()); + } catch (InvalidKeySpecException | InvalidKeyException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 암호화 키가 유효하지 않습니다: " + err.getMessage()); + } catch (BadPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 암호화 패딩 처리 중 오류가 발생했습니다: " + err.getMessage()); + } catch (Exception err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "RSA 암호화 중 예상치 못한 오류가 발생했습니다: " + err.getMessage()); + } + } + + // AES 키 생성 + public static String generateAESKey() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM); + keyGenerator.init(AES_KEY_LENGTH); + SecretKey secretKey = keyGenerator.generateKey(); + + // 원시 바이트 -> base64 인코딩 -> String + return Base64.getEncoder().encodeToString(secretKey.getEncoded()); + + } catch (NoSuchAlgorithmException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 알고리즘을 찾을 수 없습니다: " + err.getMessage()); + } + } + + // Base64 문자열을 SecretKey로 변환 + private SecretKey base64ToKey(String base64KeyString) { + if (!StringUtils.hasText(base64KeyString)) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "암호화 키가 설정되지 않았습니다."); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(base64KeyString); + return new SecretKeySpec(keyBytes, AES_ALGORITHM); + } catch (IllegalArgumentException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "유효하지 않은 Base64 암호화 키 형식입니다: " + err.getMessage()); + } catch (Exception err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "암호화 키 변환 중 오류가 발생했습니다: " + err.getMessage()); + } + } + + // AES 암호화 + public String encryptAES(String plainText) { + if (!StringUtils.hasText(plainText) || plainText.startsWith(ENCRYPTION_PREFIX)) { + return plainText; + } + try { + SecretKey secretKey = base64ToKey(aesBase64SecretKey); + + // IV 생성 + byte[] iv = new byte[AES_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + // cipher + Cipher cipher = Cipher.getInstance(AES_CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes("UTF-8")); + + // IV + 암호화된 데이터를 결합 + byte[] encryptedWithIv = new byte[AES_IV_LENGTH + encryptedBytes.length]; + System.arraycopy(iv, 0, encryptedWithIv, 0, AES_IV_LENGTH); + System.arraycopy(encryptedBytes, 0, encryptedWithIv, AES_IV_LENGTH, encryptedBytes.length); + + // 접두사 붙여서 반환 + return ENCRYPTION_PREFIX + Base64.getEncoder().encodeToString(encryptedWithIv); + } catch (NoSuchAlgorithmException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 알고리즘을 찾을 수 없습니다: " + err.getMessage()); + } catch (NoSuchPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 패딩 방식을 찾을 수 없습니다: " + err.getMessage()); + } catch (InvalidKeyException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 암호화 키가 유효하지 않습니다: " + err.getMessage()); + } catch (InvalidAlgorithmParameterException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 암호화 알고리즘 파라미터가 유효하지 않습니다: " + err.getMessage()); + } catch (IllegalBlockSizeException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 암호화 블록 크기가 유효하지 않습니다: " + err.getMessage()); + } catch (BadPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 암호화 패딩 처리 중 오류가 발생했습니다: " + err.getMessage()); + } catch (UnsupportedEncodingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "UTF-8 인코딩을 지원하지 않습니다: " + err.getMessage()); + } catch (Exception err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 암호화 중 예상치 못한 오류가 발생했습니다: " + err.getMessage()); + } + } + + // Static AES 복호화 메서드 + public String decryptAES(String encryptedText) { + // 빈 문자열이거나 암호화 접두사가 없는 문자열은 그대로 반환 + if (!StringUtils.hasText(encryptedText) || !encryptedText.startsWith(ENCRYPTION_PREFIX)) { + return encryptedText; + } + try { + SecretKey secretKey = base64ToKey(aesBase64SecretKey); + if (secretKey == null) { + return encryptedText; + } + + // 접두사 제거 후 Base64로 디코딩 + String base64Data = encryptedText.substring(ENCRYPTION_PREFIX.length()); + byte[] encryptedWithIv = Base64.getDecoder().decode(base64Data); + + // IV 추출 + byte[] iv = new byte[AES_IV_LENGTH]; + System.arraycopy(encryptedWithIv, 0, iv, 0, AES_IV_LENGTH); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + // 암호화된 데이터 추출 + byte[] encryptedBytes = new byte[encryptedWithIv.length - AES_IV_LENGTH]; + System.arraycopy(encryptedWithIv, AES_IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); + + Cipher cipher = Cipher.getInstance(AES_CIPHER); + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); + + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, "UTF-8"); + } catch (IllegalArgumentException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "Base64 디코딩에 실패했습니다. 잘못된 암호화 데이터 형식입니다: " + err.getMessage()); + } catch (NoSuchAlgorithmException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 알고리즘을 찾을 수 없습니다: " + err.getMessage()); + } catch (NoSuchPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 패딩 방식을 찾을 수 없습니다: " + err.getMessage()); + } catch (InvalidKeyException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 복호화 키가 유효하지 않습니다: " + err.getMessage()); + } catch (InvalidAlgorithmParameterException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 복호화 알고리즘 파라미터가 유효하지 않습니다: " + err.getMessage()); + } catch (IllegalBlockSizeException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 복호화 블록 크기가 유효하지 않습니다: " + err.getMessage()); + } catch (BadPaddingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, + "AES 복호화 중 패딩 오류가 발생했습니다. 잘못된 키이거나 손상된 데이터일 수 있습니다: " + err.getMessage()); + } catch (UnsupportedEncodingException err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "UTF-8 인코딩을 지원하지 않습니다: " + err.getMessage()); + } catch (Exception err) { + throw new BusinessException(ErrorCode.ENCRYPTION_FAIL, "AES 복호화 중 예상치 못한 오류가 발생했습니다: " + err.getMessage()); + } + } +} diff --git a/src/main/java/org/bbagisix/asset/mapper/AssetMapper.java b/src/main/java/org/bbagisix/asset/mapper/AssetMapper.java index b8708c75..1fda54fe 100644 --- a/src/main/java/org/bbagisix/asset/mapper/AssetMapper.java +++ b/src/main/java/org/bbagisix/asset/mapper/AssetMapper.java @@ -1,4 +1,40 @@ package org.bbagisix.asset.mapper; +import java.util.Date; +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.expense.domain.ExpenseVO; + +@Mapper public interface AssetMapper { + void insertUserAsset(AssetVO assetVO); + + AssetVO selectAssetByUserIdAndStatus(@Param("userId") Long userId, @Param("status") String status); + + AssetVO selectAssetById(@Param("assetId") Long assetId); + + int deleteUserAssetByUserIdAndStatus(@Param("userId") Long userId, @Param("status") String status); + + int deleteExpensesByUserId(Long userId); + + // 모든 main 계좌 조회 + List selectAllMainAssets(); + + // 계좌 잔액 업데이트 + void updateAssetBalance(@Param("assetId") Long assetId, @Param("newBalance") Long newBalance); + + // 중복 거래내역 개수 조회 + int countDuplicateTransaction( + @Param("userId") Long userId, + @Param("assetId") Long assetId, + @Param("amount") Long amount, + @Param("description") String description, + @Param("expenditureDate") Date expenditureDate + ); + + // 저금통 계좌 잔액 업데이트(아낀금액만큼 증가) + int updateSavingAssetBalance(@Param("assetId") Long assetId, @Param("totalSaving") Long totalSaving); } diff --git a/src/main/java/org/bbagisix/asset/service/AssetService.java b/src/main/java/org/bbagisix/asset/service/AssetService.java index 3940cf90..c497ffbf 100644 --- a/src/main/java/org/bbagisix/asset/service/AssetService.java +++ b/src/main/java/org/bbagisix/asset/service/AssetService.java @@ -1,4 +1,331 @@ package org.bbagisix.asset.service; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.dto.AssetDTO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.classify.service.ClassifyService; +import org.bbagisix.common.codef.dto.CodefTransactionResDTO; +import org.bbagisix.common.codef.service.CodefApiService; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.mapper.ExpenseMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@Transactional +@RequiredArgsConstructor +@Log4j2 public class AssetService { -} + + private final AssetMapper assetMapper; + + private final CodefApiService codefApiService; + private final ClassifyService classifyService; + + private final ExpenseMapper expenseMapper; + + private static final int MONTH = 3; // 처음 3개월 소비내역 조회 + private static final Long TBC = 14L; // 📄 카테고리 id : TBC 미지정 + private static final Long INCOME = 13L; // 📄 카테고리 id : 수입 + + // 1. 계좌 연동 + 3개월 소비내역 저장 + // POST /api/assets/connect + @Transactional + public String connectMainAsset(Long userId, AssetDTO assetDTO) { + // 정보 누락 + if (assetDTO.getBankpw() == null || assetDTO.getBankId() == null || assetDTO.getBankAccount() == null) { + throw new BusinessException(ErrorCode.ASSET_FAIL, "필수 계좌 정보가 누락되었습니다."); + } + + // 이미 연결된 계좌 확인 + AssetVO existingAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "main"); + if (existingAsset != null) { + throw new BusinessException(ErrorCode.ASSET_ALREADY_EXISTS); + } + + // codef api 통한 연결 ID 생성 + String connectedId = codefApiService.getConnectedId(assetDTO); + if (connectedId == null) { + throw new BusinessException(ErrorCode.ASSET_FAIL, "외부 은행 API 연결 ID 생성에 실패했습니다."); + } + + // 조회 기간 설정 (3개월) + LocalDate today = LocalDate.now(); + LocalDate startMonth = today.minusMonths(MONTH); + LocalDate start = startMonth.withDayOfMonth(1); + + String todayStr = today.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String startStr = start.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // 거래 내역 조회 + CodefTransactionResDTO reqDTO = codefApiService.getTransactionList(assetDTO, connectedId, startStr, todayStr, + true); + if (reqDTO == null) { + log.error("❌ Codef API 응답이 null - 사용자ID: {}", userId); + throw new BusinessException(ErrorCode.TRANSACTION_FAIL, "외부 은행 API에서 거래내역을 조회하지 못했습니다."); + } + + // db 저장 + AssetVO assetVO = createUserAssetVO(userId, assetDTO, connectedId, reqDTO, "main"); + Long assetId = insertUserAsset(assetVO, "main"); + + saveTransactionHistory(assetId, userId, reqDTO); + String accountName = reqDTO.getResAccountName(); + return accountName; + } + + // 1-2. 서브 계좌 입력 + // 이건 그냥 db에 저장하는 것임 + public void connectSubAsset(Long userId, AssetDTO assetDTO) { + // 정보 누락 + if (assetDTO.getBankpw() == null || assetDTO.getBankId() == null || assetDTO.getBankAccount() == null) { + throw new BusinessException(ErrorCode.ASSET_FAIL, "필수 계좌 정보가 누락되었습니다."); + } + + // 이미 연결된 계좌 확인 + AssetVO existingAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "sub"); + if (existingAsset != null) { + throw new BusinessException(ErrorCode.ASSET_ALREADY_EXISTS); + } + + // db 저장 + AssetVO assetVO = createUserAssetVO(userId, assetDTO, null, null, "sub"); + Long assetId = insertUserAsset(assetVO, "sub"); + } + + // 2. 계좌 삭제 + public void deleteAsset(Long userId, String status) { + AssetVO asset = assetMapper.selectAssetByUserIdAndStatus(userId, status); + if (asset == null) { + throw new BusinessException(ErrorCode.ASSET_NOT_FOUND); + } + + // 1 main 계좌인 경우에만 Codef API 연결 해제 + if ("main".equals(status)) { + + // Codef API 연결해제 + boolean codefDeleted = codefApiService.deleteConnectedId(userId); + if (!codefDeleted) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "Codef API 연결 해제에 실패했습니다."); + } + + // main 계좌의 거래내역 삭제 + int deletedExpenses = assetMapper.deleteExpensesByUserId(userId); + log.info("삭제된 거래내역 수: {}", deletedExpenses); + } + + // 2 계좌 정보 삭제 + int deletedAssets = assetMapper.deleteUserAssetByUserIdAndStatus(userId, status); // status param + if (deletedAssets == 0) { + throw new BusinessException(ErrorCode.ASSET_FAIL, "계좌 정보 삭제에 실패했습니다."); + } + + } + + // AssetVO 생성 + private AssetVO createUserAssetVO(Long userId, AssetDTO assetDTO, String connectedId, CodefTransactionResDTO reqDTO, + String status) { + AssetVO assetVO = new AssetVO(); + assetVO.setUserId(userId); + if (reqDTO == null) { + assetVO.setBalance(0L); + String assetName = assetDTO.getBankName() + " 계좌"; + assetVO.setAssetName(assetName); + assetVO.setConnectedId(null); + } else { + assetVO.setAssetName(reqDTO.getResAccountName()); + assetVO.setConnectedId(connectedId); + if (reqDTO.getResAccountBalance() != null) { + Long balance = amountToLong(reqDTO.getResAccountBalance()); + assetVO.setBalance(balance); + } else { + assetVO.setBalance(0L); + } + + } + assetVO.setBankName(assetDTO.getBankName()); + assetVO.setBankAccount(assetDTO.getBankAccount()); + assetVO.setBankId(assetDTO.getBankId()); + String encryptedPassword = codefApiService.encryptPw(assetDTO.getBankpw()); + assetVO.setBankPw(encryptedPassword); + assetVO.setStatus(status); + + return assetVO; + } + + // 계좌 정보 DB 저장 + private Long insertUserAsset(AssetVO assetVO, String status) { + // 들어가기 전에 암호화! + assetMapper.insertUserAsset(assetVO); + + AssetVO insertedAsset = assetMapper.selectAssetByUserIdAndStatus(assetVO.getUserId(), status); + if (insertedAsset == null || insertedAsset.getAssetId() == null) { + throw new BusinessException(ErrorCode.ASSET_FAIL, "계좌 정보 저장에 실패했습니다."); + } + return insertedAsset.getAssetId(); + } + + // 거래 내역 저장 + private void saveTransactionHistory(Long assetId, Long userId, CodefTransactionResDTO resDTO) { + List expenseVOList = toExpenseVOList(assetId, userId, resDTO); + + if (!expenseVOList.isEmpty()) { + // log.info("llm start.." + expenseVOList.stream().toList()); + try { + expenseVOList = classifyService.classify(expenseVOList); + } catch (Exception e) { + log.info("llm err.." + e.getMessage()); + } + // log.info("llm end.." + expenseVOList.stream().toList()); + int insertedCount = expenseMapper.insertExpenses(expenseVOList); + if (insertedCount != expenseVOList.size()) { + throw new BusinessException(ErrorCode.TRANSACTION_FAIL, + "일부 거래내역 저장에 실패했습니다. 예상: " + expenseVOList.size() + ", 실제: " + insertedCount); + } + + } else { + throw new BusinessException(ErrorCode.ASSET_FAIL); + } + } + + // 거래 내역을 ExpenseVO 리스트로 변환 + public List toExpenseVOList(Long assetId, Long userId, CodefTransactionResDTO responseDTO) { + List expenses = new ArrayList<>(); + + if (responseDTO.getResTrHistoryList() != null) { + for (CodefTransactionResDTO.HistoryItem item : responseDTO.getResTrHistoryList()) { + ExpenseVO expenseVO = new ExpenseVO(); + expenseVO.setUserId(userId); + expenseVO.setAssetId(assetId); + + Long withdrawAmount = amountToLong(item.getResAccountOut()); + Long depositAmount = amountToLong(item.getResAccountIn()); + + if (withdrawAmount > 0) { + expenseVO.setCategoryId(TBC); + expenseVO.setAmount(withdrawAmount); + } else if (depositAmount > 0) { + expenseVO.setCategoryId(INCOME); + expenseVO.setAmount(depositAmount); + } else { + continue; // 금액이 0인 경우 스킵 + } + + expenseVO.setDescription(item.getResAccountDesc3()); + + Date expenditureDate = parseTransactionDateTime( + item.getResAccountTrDate(), + item.getResAccountTrTime() + ); + expenseVO.setExpenditureDate(expenditureDate); + + // Codef 자동 생성 거래 설정 + expenseVO.setUserModified(false); + // Codef transaction ID 생성 (날짜+시간+금액+설명 기반) + String codefTransactionId = generateCodefTransactionId(item); + expenseVO.setCodefTransactionId(codefTransactionId); + + expenses.add(expenseVO); + } + } + return expenses; + } + + // Codef transaction ID 생성 + private String generateCodefTransactionId(CodefTransactionResDTO.HistoryItem item) { + return String.format("CODEF_%s_%s_%s_%s", + item.getResAccountTrDate(), + item.getResAccountTrTime(), + item.getResAccountOut() != null ? item.getResAccountOut() : item.getResAccountIn(), + item.getResAccountDesc3() != null ? item.getResAccountDesc3().hashCode() : "0"); + } + + // 금액 문자열을 Long으로 변환 + public Long amountToLong(String amountStr) { + if (amountStr == null || amountStr.trim().isEmpty()) { + return 0L; + } + try { + String numericStr = amountStr.replaceAll("[^0-9]", ""); + return numericStr.isEmpty() ? 0L : Long.parseLong(numericStr); + } catch (NumberFormatException e) { + return 0L; + } + } + + // 거래 일시 파싱 + private Date parseTransactionDateTime(String dateStr, String timeStr) { + + LocalDate date = parseTransactionDate(dateStr); + if (date == null) { + throw new BusinessException(ErrorCode.TRANSACTION_FAIL, "거래 날짜 파싱에 실패했습니다"); + } + + LocalTime time = parseTransactionTime(timeStr); + + LocalDateTime dateTime = LocalDateTime.of(date, time); + + return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + // 거래 날짜 파싱 + private LocalDate parseTransactionDate(String dateStr) { + if (dateStr != null && dateStr.length() >= 8) { + String year = dateStr.substring(0, 4); + String month = dateStr.substring(4, 6); + String day = dateStr.substring(6, 8); + + return LocalDate.of( + Integer.parseInt(year), + Integer.parseInt(month), + Integer.parseInt(day) + ); + } + return null; + } + + // 거래 시간 파싱 + private LocalTime parseTransactionTime(String timeStr) { + if (timeStr != null && !timeStr.trim().isEmpty()) { + String cleanTimeStr = timeStr.replaceAll("[^0-9]", ""); + + if (cleanTimeStr.length() >= 6) { + String hour = cleanTimeStr.substring(0, 2); + String minute = cleanTimeStr.substring(2, 4); + String second = cleanTimeStr.substring(4, 6); + + return LocalTime.of( + Integer.parseInt(hour), + Integer.parseInt(minute), + Integer.parseInt(second) + ); + } else if (cleanTimeStr.length() >= 4) { + // HHMM 형식 + String hour = cleanTimeStr.substring(0, 2); + String minute = cleanTimeStr.substring(2, 4); + + return LocalTime.of( + Integer.parseInt(hour), + Integer.parseInt(minute), + 0 + ); + } + } + return LocalTime.of(0, 0, 0); + } + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/category/service/CategoryService.java b/src/main/java/org/bbagisix/category/service/CategoryService.java index ef5cb7a4..3ac9806e 100644 --- a/src/main/java/org/bbagisix/category/service/CategoryService.java +++ b/src/main/java/org/bbagisix/category/service/CategoryService.java @@ -6,4 +6,5 @@ public interface CategoryService { List getAllCategories(); + CategoryDTO getCategoryById(Long id); } diff --git a/src/main/java/org/bbagisix/category/service/CategoryServiceImpl.java b/src/main/java/org/bbagisix/category/service/CategoryServiceImpl.java index 38b17346..d74b3bc6 100644 --- a/src/main/java/org/bbagisix/category/service/CategoryServiceImpl.java +++ b/src/main/java/org/bbagisix/category/service/CategoryServiceImpl.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Collectors; +import org.bbagisix.category.domain.CategoryVO; import org.bbagisix.category.dto.CategoryDTO; import org.bbagisix.category.mapper.CategoryMapper; import org.springframework.stereotype.Service; @@ -22,4 +23,11 @@ public List getAllCategories() { .map(vo -> new CategoryDTO(vo.getCategoryId(), vo.getName(), vo.getIcon())) .collect(Collectors.toList()); } + + @Override + public CategoryDTO getCategoryById(Long id) { + + CategoryVO vo = categoryMapper.findById(id); + return new CategoryDTO(vo.getCategoryId(), vo.getName(), vo.getIcon()); + } } diff --git a/src/main/java/org/bbagisix/challenge/controller/ChallengeController.java b/src/main/java/org/bbagisix/challenge/controller/ChallengeController.java index 86397367..dc7cc77b 100644 --- a/src/main/java/org/bbagisix/challenge/controller/ChallengeController.java +++ b/src/main/java/org/bbagisix/challenge/controller/ChallengeController.java @@ -1,4 +1,118 @@ package org.bbagisix.challenge.controller; +import java.util.List; + +import org.bbagisix.challenge.dto.ChallengeDTO; +import org.bbagisix.challenge.dto.ChallengeFailDTO; +import org.bbagisix.challenge.dto.ChallengeProgressDTO; +import org.bbagisix.challenge.service.ChallengeService; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/challenges") +@RequiredArgsConstructor public class ChallengeController { + + private final ChallengeService challengeService; + + // 1. challengeId가 없는 경우: /api/challenges 또는 /api/challenges/ + @GetMapping + public ResponseEntity handleMissingChallengeId() { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + // 2. challengeId가 명시되었지만 "null", 빈값, 숫자 아님 등 예외 처리 포함 + @GetMapping("/{challengeId}") + public ResponseEntity getChallengeById(@PathVariable Long challengeId) { + try { + ChallengeDTO result = challengeService.getChallengeById(challengeId); + return ResponseEntity.ok(result); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + } + + // 2-1. 추천 챌린지 3개 조회 API + @GetMapping("/recommendations") + public ResponseEntity> getRecommendedChallenges(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + List recommendedChallenges = challengeService.getRecommendedChallenges(currentUser.getUserId()); + return ResponseEntity.ok(recommendedChallenges); + } + + // 2-2. 챌린지 진척도 조회 API + // ChallengeController.java에 추가 + @GetMapping("/progress") + public ResponseEntity getChallengeProgress(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + ChallengeProgressDTO result = challengeService.getChallengeProgress(currentUser.getUserId()); + return ResponseEntity.ok(result); + } + + // 3. 챌린지 참여 API (ExpenseController 패턴 적용) + @PostMapping("/join/{challengeId}/{period}") + public ResponseEntity joinChallenge( + @PathVariable Long challengeId, + @PathVariable Long period, + Authentication authentication) { + + // challengeId 유효성 검사 + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + try { + // ExpenseController와 동일한 패턴으로 사용자 정보 추출 + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + challengeService.joinChallenge(currentUser.getUserId(), challengeId, period); + return ResponseEntity.ok("챌린지 참여가 완료되었습니다."); + + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + } + + // 챌린지 실패 POST /api/challenges/{challenge_id}/fail + @PostMapping("/{challengeId}/fail") + public ResponseEntity> failChallenge( + @PathVariable Long challengeId, + Authentication authentication) { + + // challengeId 유효성 검사 + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + try { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + List failDTOList = challengeService.failChallenge(currentUser.getUserId(), challengeId); + return ResponseEntity.ok(failDTOList); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + } + + @PostMapping("/close/{userChallengeId}") + public ResponseEntity closeChallenge(@PathVariable Long userChallengeId) { + if (userChallengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + try { + challengeService.closeChallenge(userChallengeId); + return ResponseEntity.ok("챌린지 닫기가 완료되었습니다."); + } catch (Exception e) { + throw new BusinessException(ErrorCode.CHALLENGE_UPDATE_FAILED); + } + } } diff --git a/src/main/java/org/bbagisix/challenge/domain/ChallengeVO.java b/src/main/java/org/bbagisix/challenge/domain/ChallengeVO.java index dda30896..f2cc9e11 100644 --- a/src/main/java/org/bbagisix/challenge/domain/ChallengeVO.java +++ b/src/main/java/org/bbagisix/challenge/domain/ChallengeVO.java @@ -1,4 +1,15 @@ package org.bbagisix.challenge.domain; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public class ChallengeVO { + + private Long challengeId; + private Long categoryId; + private String title; + private String summary; + private String description; } diff --git a/src/main/java/org/bbagisix/challenge/domain/UserChallengeVO.java b/src/main/java/org/bbagisix/challenge/domain/UserChallengeVO.java new file mode 100644 index 00000000..2d3e9b79 --- /dev/null +++ b/src/main/java/org/bbagisix/challenge/domain/UserChallengeVO.java @@ -0,0 +1,31 @@ +package org.bbagisix.challenge.domain; + +import java.util.Date; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) +public class UserChallengeVO { + private Long userChallengeId; + private Long userId; + private Long challengeId; + private String status; + private Long period; + private Long progress; + private Date startDate; + private Date endDate; + private Long saving; + private Boolean isActive; +} diff --git a/src/main/java/org/bbagisix/challenge/dto/ChallengeDTO.java b/src/main/java/org/bbagisix/challenge/dto/ChallengeDTO.java index 8b7dbd44..06fb749c 100644 --- a/src/main/java/org/bbagisix/challenge/dto/ChallengeDTO.java +++ b/src/main/java/org/bbagisix/challenge/dto/ChallengeDTO.java @@ -1,4 +1,26 @@ package org.bbagisix.challenge.dto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.bbagisix.challenge.domain.ChallengeVO; + +@Getter +@AllArgsConstructor public class ChallengeDTO { + + private Long challengeId; + private Long categoryId; + private String title; + private String summary; + private String description; + + public static ChallengeDTO from(ChallengeVO vo) { + return new ChallengeDTO( + vo.getChallengeId(), + vo.getCategoryId(), + vo.getTitle(), + vo.getSummary(), + vo.getDescription() + ); + } } diff --git a/src/main/java/org/bbagisix/challenge/dto/ChallengeFailDTO.java b/src/main/java/org/bbagisix/challenge/dto/ChallengeFailDTO.java new file mode 100644 index 00000000..b18fddd8 --- /dev/null +++ b/src/main/java/org/bbagisix/challenge/dto/ChallengeFailDTO.java @@ -0,0 +1,17 @@ +package org.bbagisix.challenge.dto; + +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChallengeFailDTO { + private Long amount; + private String description; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + private Date expenditureDate; +} diff --git a/src/main/java/org/bbagisix/challenge/dto/ChallengeProgressDTO.java b/src/main/java/org/bbagisix/challenge/dto/ChallengeProgressDTO.java new file mode 100644 index 00000000..6b8318ed --- /dev/null +++ b/src/main/java/org/bbagisix/challenge/dto/ChallengeProgressDTO.java @@ -0,0 +1,16 @@ +package org.bbagisix.challenge.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ChallengeProgressDTO { + private Long user_challenge_id; + private Long challenge_id; + private String title; + private String status; + private Long period; + private Long progress; + private Long saving; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/challenge/mapper/ChallengeMapper.java b/src/main/java/org/bbagisix/challenge/mapper/ChallengeMapper.java index b5035b6a..0005178b 100644 --- a/src/main/java/org/bbagisix/challenge/mapper/ChallengeMapper.java +++ b/src/main/java/org/bbagisix/challenge/mapper/ChallengeMapper.java @@ -1,4 +1,44 @@ package org.bbagisix.challenge.mapper; +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.challenge.domain.ChallengeVO; +import org.bbagisix.challenge.domain.UserChallengeVO; +import org.bbagisix.challenge.dto.ChallengeFailDTO; +import org.bbagisix.challenge.dto.ChallengeProgressDTO; + +@Mapper public interface ChallengeMapper { -} + ChallengeVO findByChallengeId(@Param("challengeId") Long challengeId); + + boolean hasActiveChallenge(@Param("userId") Long userId); + + boolean existsUser(@Param("userId") Long userId); + + boolean existsUserChallenge(@Param("challengeId") Long challengeId, @Param("userId") Long userId); + + void joinChallenge(UserChallengeVO userChallenge); + + // 추천 카테고리에 해당하는 챌린지들 조회 + List findChallengesByCategoryIds(@Param("categoryIds") List categoryIds, + @Param("userId") Long userId); + + // 사용자 챌린지 진척도 조회 + ChallengeProgressDTO getChallengeProgress(@Param("userId") Long userId); + + List getOngoingChallenges(); + + Long getCategoryByChallengeId(Long challengeId); + + void updateChallenge(UserChallengeVO userChallenge); + + UserChallengeVO getUserChallengeById(Long userChallengeId); + + // 완료된 챌린지들만 조회 (completed + failed) + List getUserCompletedChallenges(@Param("userId") Long userId); + + List getFailExpenditures(@Param("userId") Long userId, @Param("challengeId") Long challengeId); + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/challenge/scheduler/ChallengeScheduler.java b/src/main/java/org/bbagisix/challenge/scheduler/ChallengeScheduler.java new file mode 100644 index 00000000..8b1de16d --- /dev/null +++ b/src/main/java/org/bbagisix/challenge/scheduler/ChallengeScheduler.java @@ -0,0 +1,19 @@ +package org.bbagisix.challenge.scheduler; + +import org.bbagisix.challenge.service.ChallengeService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ChallengeScheduler { + private final ChallengeService challengeService; + + @Scheduled(cron = "0 59 23 * * *") + public void DailyCheck() { + challengeService.dailyCheck(); + } + +} diff --git a/src/main/java/org/bbagisix/challenge/service/ChallengeService.java b/src/main/java/org/bbagisix/challenge/service/ChallengeService.java index a4e04c73..8ce08620 100644 --- a/src/main/java/org/bbagisix/challenge/service/ChallengeService.java +++ b/src/main/java/org/bbagisix/challenge/service/ChallengeService.java @@ -1,4 +1,170 @@ package org.bbagisix.challenge.service; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.bbagisix.analytics.service.AnalyticsService; +import org.bbagisix.challenge.domain.ChallengeVO; +import org.bbagisix.challenge.domain.UserChallengeVO; +import org.bbagisix.challenge.dto.ChallengeDTO; +import org.bbagisix.challenge.dto.ChallengeFailDTO; +import org.bbagisix.challenge.dto.ChallengeProgressDTO; +import org.bbagisix.challenge.mapper.ChallengeMapper; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.expense.mapper.ExpenseMapper; +import org.bbagisix.tier.service.TierService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor public class ChallengeService { -} + + private final ChallengeMapper challengeMapper; + private final ExpenseMapper expenseMapper; + private final AnalyticsService analyticsService; + private final TierService tierService; + + public ChallengeDTO getChallengeById(Long challengeId) { + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + ChallengeVO vo = challengeMapper.findByChallengeId(challengeId); + if (vo == null) { + throw new BusinessException(ErrorCode.CHALLENGE_NOT_FOUND); + } + + return ChallengeDTO.from(vo); + } + + public List getRecommendedChallenges(Long userId) { + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + // 사용자 존재 여부 확인 + if (!challengeMapper.existsUser(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + // AnalyticsService에서 LLM 기반 추천 카테고리 3개 조회 + List categoryIds = analyticsService.getTopCategories(userId); + + // 추천 카테고리에 해당하는 챌린지들 조회 + List recommendedChallenges = challengeMapper.findChallengesByCategoryIds(categoryIds, userId); + + // VO를 DTO로 변환하여 반환 + return recommendedChallenges.stream() + .map(ChallengeDTO::from) + .collect(Collectors.toList()); + } + + public ChallengeProgressDTO getChallengeProgress(Long userId) { + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + // 사용자 존재 여부 확인 + if (!challengeMapper.existsUser(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + return challengeMapper.getChallengeProgress(userId); + } + + public void joinChallenge(Long userId, Long challengeId, Long period) { + + // 사용자 존재 여부 확인 + if (!challengeMapper.existsUser(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + // 챌린지 존재 여부 확인 + ChallengeVO challenge = challengeMapper.findByChallengeId(challengeId); + if (challenge == null) { + throw new BusinessException(ErrorCode.CHALLENGE_NOT_FOUND); + } + + // 해당 사용자가 어떤 챌린지든 참여 중인지 확인 + if (challengeMapper.hasActiveChallenge(userId)) { + throw new BusinessException(ErrorCode.ALREADY_JOINED_CHALLENGE); + } + + // saving 계산 + Long categoryId = challengeMapper.getCategoryByChallengeId(challengeId); + Long total = Optional.ofNullable( + expenseMapper.getSumOfPeriodExpenses(userId, categoryId, period) + ).orElse(0L); + Long saving = total / period; + + // 챌린지 참여 + UserChallengeVO userChallenge = UserChallengeVO.builder() + .userId(userId) + .challengeId(challengeId) + .period(period) + .saving(saving) + .build(); + challengeMapper.joinChallenge(userChallenge); + } + + public void closeChallenge(Long userChallengeId) { + UserChallengeVO userChallenge = challengeMapper.getUserChallengeById(userChallengeId); + if (userChallenge == null) { + throw new BusinessException(ErrorCode.CHALLENGE_NOT_FOUND); + } + UserChallengeVO updated = userChallenge.toBuilder().status("closed").build(); + challengeMapper.updateChallenge(updated); + } + + // 챌린지 성공/실패 판단 및 진척도 계산 + @Transactional + public void dailyCheck() { + + List challenges = challengeMapper.getOngoingChallenges(); // ongoing인 챌린지 조회 + + for (UserChallengeVO c : challenges) { + Long challengeCtg = challengeMapper.getCategoryByChallengeId(c.getChallengeId()); // 해당 챌린지의 카테고리 아이디 조회 + Long userId = c.getUserId(); + List expenseCtg + = expenseMapper.getTodayExpenseCategories(userId); // 유저 소비내역의 카테고리 아이디 조회 + + UserChallengeVO updated; + + if (expenseCtg.contains(challengeCtg)) { // 소비내역에 현재 챌린지의 카테고리가 포함된 경우 -> 실패 + updated = c.toBuilder() + .status("failed") + .endDate(new Date()) + .build(); + } else { // 유저 소비내역에 현재 챌린지의 카테고리가 포함되지 않은 경우 -> 하루 성공 + boolean isLastDay = c.getEndDate().toInstant().atZone(ZoneId.systemDefault()) + .toLocalDate().equals(LocalDate.now()); + + updated = c.toBuilder() + .progress(c.getProgress() + 1) + .status(isLastDay ? "completed" : c.getStatus()) // 마지막 날인 경우 -> 최종 성공 + .build(); + + // 챌린지 완료 시 tier 승급 처리 + if (isLastDay) { + tierService.promoteUserTier(userId); + } + } + + challengeMapper.updateChallenge(updated); + } + } + + // 실패시 해당 카테고리 지출 내역 가져오기 + public List failChallenge(Long userId, Long challengeId) { + return challengeMapper.getFailExpenditures(userId, challengeId); + } + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/chat/config/RedisSubscriberInitializer.java b/src/main/java/org/bbagisix/chat/config/RedisSubscriberInitializer.java new file mode 100644 index 00000000..c64f66a1 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/config/RedisSubscriberInitializer.java @@ -0,0 +1,38 @@ +package org.bbagisix.chat.config; + +import org.bbagisix.chat.service.ChatMessageSubscriber; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.PostConstruct; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisSubscriberInitializer { + + private final RedisMessageListenerContainer redisMessageListenerContainer; + private final ChatMessageSubscriber chatMessageSubscriber; + + @PostConstruct + public void initializeSubscribers() { + try { + PatternTopic chatChannelPattern = new PatternTopic("chat:channel:*"); + + redisMessageListenerContainer.addMessageListener( + chatMessageSubscriber, + chatChannelPattern + ); + + log.info("✅ Redis 채팅 채널 구독 초기화 완료: pattern=chat:channel:*"); + + } catch (Exception e) { + log.error("❌ Redis 구독 초기화 실패: ", e); + log.error("Redis 구독 설정 실패 - 채팅 기능이 제한될 수 있습니다."); + } + } +} diff --git a/src/main/java/org/bbagisix/chat/controller/ChatController.java b/src/main/java/org/bbagisix/chat/controller/ChatController.java index 72642bef..f9393a01 100644 --- a/src/main/java/org/bbagisix/chat/controller/ChatController.java +++ b/src/main/java/org/bbagisix/chat/controller/ChatController.java @@ -1,13 +1,379 @@ package org.bbagisix.chat.controller; -import org.springframework.stereotype.Controller; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.bbagisix.chat.dto.ChatMessageDTO; +import org.bbagisix.chat.dto.response.ChatRoomInfoResponse; +import org.bbagisix.chat.dto.response.ParticipantCountResponse; +import org.bbagisix.chat.dto.response.ParticipantResponse; +import org.bbagisix.chat.dto.response.UserChallengeStatusResponse; +import org.bbagisix.chat.dto.response.UserChatRoomResponse; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.chat.service.ChatService; +import org.bbagisix.chat.service.ChatSessionService; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; -@Controller +@Slf4j +@RestController +@RequiredArgsConstructor +@Validated public class ChatController { - @GetMapping("/chat") - public String chatTestPage() { - return "chat-test"; + private final ChatService chatService; + private final ChatSessionService chatSessionService; + + /** + * 현재 로그인한 사용자의 챌린지 상태 조회 (JWT 기반) + */ + @GetMapping("/api/chat/status/me") + public UserChallengeStatusResponse getCurrentUserChallengeStatus(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + return chatService.getUserChallengeStatus(currentUser.getUserId()); + } + + /** + * 현재 로그인한 사용자가 참여중인 채팅방 목록 조회 (JWT 기반) + */ + @GetMapping("/api/chat/user/me") + public UserChatRoomResponse getCurrentUserChatRoom(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + Map chatRoomMap = chatService.getUserCurrentChatRoom(currentUser.getUserId()); + + return UserChatRoomResponse.builder() + .userId(getLongFromMap(chatRoomMap, "userId")) + .challengeId(getLongFromMap(chatRoomMap, "challengeId")) + .challengeName(getStringFromMap(chatRoomMap, "challengeName")) + .status(getStringFromMap(chatRoomMap, "status")) + .message(getStringFromMap(chatRoomMap, "message")) + .build(); + } + + /** + * 참여중인 채팅방 목록 조회 (기존 - 호환성 유지) + */ + @GetMapping("/api/chat/user/{userId}") + public UserChatRoomResponse getUserCurrentChatRoom(@PathVariable Long userId) { + Map chatRoomMap = chatService.getUserCurrentChatRoom(userId); + + return UserChatRoomResponse.builder() + .userId(getLongFromMap(chatRoomMap, "userId")) + .challengeId(getLongFromMap(chatRoomMap, "challengeId")) + .challengeName(getStringFromMap(chatRoomMap, "challengeName")) + .status(getStringFromMap(chatRoomMap, "status")) + .message(getStringFromMap(chatRoomMap, "message")) + .build(); + } + + /** + * 특정 채팅방 정보 조회 + */ + @GetMapping("/api/chat/{challengeId}/info") + public ChatRoomInfoResponse getChatRoomInfo(@PathVariable Long challengeId) { + int participantCount = chatSessionService.getParticipantCount(challengeId); + + return ChatRoomInfoResponse.builder() + .challengeId(challengeId) + .challengeName("챌린지 " + challengeId) // TODO: 실제 챌린지 이름으로 교체 + .participantCount(participantCount) + .status("valid") + .build(); + } + + /** + * 채팅방 참여자 목록 조회 + */ + @GetMapping("/api/chat/{challengeId}/participants") + public List getParticipants(@PathVariable Long challengeId) { + List> participantsMaps = chatService.getParticipants(challengeId); + + return participantsMaps.stream() + .map(map -> ParticipantResponse.builder() + .userId(getLongFromMap(map, "userId")) + .userName(getStringFromMap(map, "userName")) + .joinedAt(getLocalDateTimeFromMap(map, "joinedAt")) + .isActive(getBooleanFromMap(map, "isActive")) + .build()) + .collect(Collectors.toList()); + } + + /** + * 현재 접속자 수 조회 + */ + @GetMapping("/api/chat/{challengeId}/participants/count") + public ParticipantCountResponse getParticipantCount(@PathVariable Long challengeId) { + int count = chatSessionService.getParticipantCount(challengeId); + + return ParticipantCountResponse.builder() + .challengeId(challengeId) + .participantCount(count) + .build(); + } + + /** + * 채팅 메시지 이력 조회 (JWT 기반) + */ + @GetMapping("/api/chat/{challengeId}/messages") + public List getChatHistory(@PathVariable Long challengeId, + Authentication authentication, + @RequestParam(defaultValue = "50") int limit) { + + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + + // 사용자가 해당 챌린지에 참여 중인지 확인 + if (!chatService.isUserParticipatingInChallenge(challengeId, currentUser.getUserId())) { + throw new BusinessException(ErrorCode.CHALLENGE_ACCESS_DENIED); + } + + // 사용자가 챌린지에 참여한 시점 이후의 메시지만 조회 + return chatService.getChatHistory(challengeId, currentUser.getUserId(), limit); + } + + /** + * 사용자 챌린지 상태 조회 (기존 - 호환성 유지) + */ + @GetMapping("/api/chat/status/{userId}") + public UserChallengeStatusResponse getUserChallengeStatus(@PathVariable Long userId) { + return chatService.getUserChallengeStatus(userId); + } + + /** + * 채팅 메시지 전송 + * /app/chat/{challengeId}/send 로 메시지를 받아서 + * /topic/chat/{challengeId} 로 브로드캐스트 + * DTO → VO → Entity + */ + @MessageMapping("/chat/{challengeId}/send") + public void sendMessage(@DestinationVariable Long challengeId, + @Payload ChatMessageDTO chatMessage, + SimpMessageHeaderAccessor headerAccessor) { + try { + log.info("챌린지 {} 채팅 메시지 수신: {}", challengeId, chatMessage.getMessage()); + + // 챌린지 ID 일치 검증 + if (!challengeId.equals(chatMessage.getChallengeId())) { + log.warn("챌린지 ID 불일치: URL={}, DTO={}", challengeId, chatMessage.getChallengeId()); + chatMessage.setChallengeId(challengeId); // URL의 challengeId로 강제 설정 + } + + // 메시지를 VO → Entity 변환 후 DB에 저장 + // Redis pub/sub 발행 (ChatService에서 처리) + ChatMessageDTO savedMessage = chatService.saveMessage(chatMessage); + + log.info("메시지 저장 완료: ID {}", savedMessage.getMessageId()); + + } catch (BusinessException e) { + log.warn("비즈니스 예외 발생: code={}, message={}", e.getCode(), e.getMessage()); + // GlobalExceptionHandler 에서 처리 + } catch (Exception e) { + log.error("메시지 전송 중 예상하지 못한 오류: ", e); + throw new BusinessException(ErrorCode.WEBSOCKET_SEND_FAILED, e); + } + } + + /** + * 사용자가 채팅방에 입장 + * 시스템이 제어하는 입장 처리 + */ + @MessageMapping("/chat/{challengeId}/join") + public void joinChat(@DestinationVariable Long challengeId, + @Payload ChatMessageDTO joinMessage, + SimpMessageHeaderAccessor headerAccessor) { + + // 기본값 설정 (null 방지) + Long userId = joinMessage.getUserId() != null ? joinMessage.getUserId() : 0L; + String userName = "사용자" + userId; + + try { + log.info("📥 [입장 요청] 사용자 ID: {}, 챌린지 ID: {}", userId, challengeId); + + // ChatService를 통해 사용자 정보와 함께 입장 메시지 생성 + // Redis 발행 + ChatMessageDTO systemMessage = chatService.handleJoin(challengeId, userId); + + // userName 안전성 체크 + if (systemMessage != null && systemMessage.getUserName() != null && + !systemMessage.getUserName().trim().isEmpty()) { + userName = systemMessage.getUserName(); + } + + // 세션에 정보 저장 - null 값 완전 차단 + saveToSession(headerAccessor, challengeId, userId, userName); + + // 접속자 수 증가 (Redis pub/sub로 브로드캐스팅) + chatSessionService.addParticipant(challengeId); + int currentCount = chatSessionService.getParticipantCount(challengeId); + + log.info("✅ [입장 완료] 사용자: {}, 챌린지: {}, 현재 접속자 수: {}명", userName, challengeId, currentCount); + + // 시스템 메시지가 null일 경우 에러 처리 + if (systemMessage == null) { + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "입장 메시지 생성에 실패했습니다."); + } + + log.info("입장 처리 완료: 사용자 {}, 챌린지 {}", userName, challengeId); + + } catch (BusinessException e) { + log.warn("❌ [입장 실패] 비즈니스 예외: code={}, message={}, 사용자: {}", e.getCode(), e.getMessage(), userId); + handleJoinError(challengeId, userId, userName, headerAccessor); + // GlobalExceptionHandler에서 처리 + } catch (Exception e) { + log.error("❌ [입장 실패] 예상하지 못한 오류: 사용자: {}, 챌린지: {}", userId, challengeId, e); + handleJoinError(challengeId, userId, userName, headerAccessor); + throw new BusinessException(ErrorCode.WEBSOCKET_CONNECTION_ERROR, e); + } + } + + /** + * 채팅방 입장 시 이전 메시지 이력 조회 + */ + // @GetMapping("/api/chat/{challengeId}/messages") + // public List getChatHistory(@PathVariable Long challengeId, + // @RequestParam Long userId, + // @RequestParam(defaultValue = "50") int limit) { + // + // // 사용자가 해당 챌린지에 참여 중인지 확인 + // if (!chatService.isUserParticipatingInChallenge(challengeId, userId)) { + // throw new BusinessException(ErrorCode.CHALLENGE_ACCESS_DENIED); + // } + // + // // 사용자가 챌린지에 참여한 시점 이후의 메시지만 조회 + // return chatService.getChatHistory(challengeId, userId, limit); + // } + + /** + * WebSocket 연결 해제 시 자동 호출 + * 시스템이 제어하는 퇴장 처리 + */ + @EventListener + public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { + try { + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage()); + + Long challengeId = (Long)headerAccessor.getSessionAttributes().get("challengeId"); + Long userId = (Long)headerAccessor.getSessionAttributes().get("userId"); + String userName = (String)headerAccessor.getSessionAttributes().get("userName"); + + log.info("🔌 [WebSocket 연결 해제] 세션 ID: {}, challengeId: {}, userId: {}, userName: {}", + headerAccessor.getSessionId(), challengeId, userId, userName); + + if (challengeId != null) { + log.info("👋 [퇴장 시작] 사용자: {}, 챌린지: {}", userName, challengeId); + + // 접속자 수 감소 (Redis pub/sub로 브로드캐스트) + chatSessionService.removeParticipant(challengeId); + int currentCount = chatSessionService.getParticipantCount(challengeId); + + log.info("✅ [퇴장 완료] 사용자: {}, 챌린지: {}, 현재 접속자 수: {}명", userName, challengeId, currentCount); + + // 퇴장 메시지 생성 + Redis 발행 + if (userName != null) { + ChatMessageDTO systemMessage = chatService.handleLeave(challengeId, userId, userName); + } + } + } catch (BusinessException e) { + log.warn("퇴장 처리 중 비즈니스 예외: code={}, message={}", e.getCode(), e.getMessage()); + } catch (Exception e) { + log.error("WebSocket 연결 해제 처리 중 예상하지 못한 오류: ", e); + } + } + + /** + * 입장 오류 처리 (공통 메서드) + */ + private void handleJoinError(Long challengeId, Long userId, String userName, + SimpMessageHeaderAccessor headerAccessor) { + try { + // 에러 발생 시에도 접속자 수는 증가 + chatSessionService.addParticipant(challengeId); + + // 안전한 세션 저장 + saveToSession(headerAccessor, challengeId, userId, userName); + } catch (Exception e) { + log.error("입장 오류 처리 중 추가 오류: ", e); + } + } + + /** + * 세션 저장 (공통 메서드) + */ + private void saveToSession(SimpMessageHeaderAccessor headerAccessor, Long challengeId, Long userId, + String userName) { + try { + Map sessionAttributes = headerAccessor.getSessionAttributes(); + if (sessionAttributes != null) { + sessionAttributes.put("challengeId", challengeId); + sessionAttributes.put("userId", userId); + sessionAttributes.put("userName", userName != null ? userName : "사용자" + userId); + } + } catch (Exception e) { + log.error("세션 저장 중 오류: ", e); + throw new BusinessException(ErrorCode.SESSION_EXPIRED, e); + } + } + + /** + * 유틸리티 메서드들 + */ + private Long getLongFromMap(Map map, String key) { + Object value = map.get(key); + if (value == null) + return null; + if (value instanceof Long) + return (Long)value; + if (value instanceof Integer) + return ((Integer)value).longValue(); + if (value instanceof String) + return Long.parseLong((String)value); + return null; + } + + private String getStringFromMap(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } + + private Boolean getBooleanFromMap(Map map, String key) { + Object value = map.get(key); + if (value == null) + return null; + if (value instanceof Boolean) + return (Boolean)value; + if (value instanceof Integer) + return ((Integer)value) == 1; + if (value instanceof String) + return Boolean.parseBoolean((String)value); + return false; + } + + private LocalDateTime getLocalDateTimeFromMap(Map map, String key) { + Object value = map.get(key); + if (value == null) + return null; + if (value instanceof LocalDateTime) + return (LocalDateTime)value; + if (value instanceof Timestamp) + return ((Timestamp)value).toLocalDateTime(); + // 필요에 따라 다른 타입 변환 추가 + return null; } -} +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/chat/converter/ChatMessageConverter.java b/src/main/java/org/bbagisix/chat/converter/ChatMessageConverter.java new file mode 100644 index 00000000..bc120939 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/converter/ChatMessageConverter.java @@ -0,0 +1,130 @@ +package org.bbagisix.chat.converter; + +import java.time.LocalDateTime; + +import org.bbagisix.chat.domain.ChatMessageVO; +import org.bbagisix.chat.dto.ChatMessageDTO; +import org.bbagisix.chat.entity.ChatMessage; +import org.springframework.stereotype.Component; + +@Component +public class ChatMessageConverter { + + /** + * DTO -> VO + */ + public ChatMessageVO toVO(ChatMessageDTO dto) { + if (dto == null) { + return null; + } + + return ChatMessageVO.builder() + .challengeId(dto.getChallengeId()) + .userId(dto.getUserId()) + .message(dto.getMessage()) + .messageType(dto.getMessageType() != null ? dto.getMessageType() : "MESSAGE") + .sentAt(dto.getSentAt() != null ? dto.getSentAt() : LocalDateTime.now()) + .userName(dto.getUserName()) + .build(); + } + + /** + * VO -> Entity + */ + public ChatMessage toEntity(ChatMessageVO vo) { + if (vo == null) { + return null; + } + + return ChatMessage.builder() + .challengeId(vo.getChallengeId()) + .userId(vo.getUserId()) + .message(vo.getMessage()) + .messageType(vo.getMessageType()) + .sentAt(vo.getSentAt()) + .userName(vo.getUserName()) + .build(); + } + + /** + * Entity → VO + */ + public ChatMessageVO fromEntity(ChatMessage entity) { + if (entity == null) { + return null; + } + + return ChatMessageVO.builder() + .challengeId(entity.getChallengeId()) + .userId(entity.getUserId()) + .message(entity.getMessage()) + .messageType(entity.getMessageType()) + .sentAt(entity.getSentAt()) + .userName(entity.getUserName()) + .build(); + } + + /** + * VO → DTO 변환 (응답용) + */ + public ChatMessageDTO toDTO(ChatMessageVO vo, Long messageId) { + if (vo == null) { + return null; + } + + return ChatMessageDTO.builder() + .messageId(messageId) + .challengeId(vo.getChallengeId()) + .userId(vo.getUserId()) + .message(vo.getMessage()) + .messageType(vo.getMessageType()) + .sentAt(vo.getSentAt()) + .userName(vo.getUserName()) + .build(); + } + + /** + * Entity → DTO 변환 (조회용) + */ + public ChatMessageDTO toDTO(ChatMessage entity) { + if (entity == null) { + return null; + } + + return ChatMessageDTO.builder() + .messageId(entity.getMessageId()) + .challengeId(entity.getChallengeId()) + .userId(entity.getUserId()) + .message(entity.getMessage()) + .messageType(entity.getMessageType()) + .sentAt(entity.getSentAt()) + .userName(entity.getUserName()) + .build(); + } + + /** + * 시스템 메시지 생성 (입장/퇴장용) + */ + public ChatMessageVO createSystemMessage(Long challengeId, Long userId, String userName, String message) { + return ChatMessageVO.builder() + .challengeId(challengeId) + .userId(userId) + .userName(userName != null ? userName : "사용자" + userId) + .message(message) + .messageType("SYSTEM") + .sentAt(LocalDateTime.now()) + .build(); + } + + /** + * 에러 메시지 생성 + */ + public ChatMessageVO createErrorMessage(Long challengeId, String message) { + return ChatMessageVO.builder() + .challengeId(challengeId) + .message(message) + .messageType("ERROR") + .sentAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/org/bbagisix/chat/domain/ChatMessageVO.java b/src/main/java/org/bbagisix/chat/domain/ChatMessageVO.java new file mode 100644 index 00000000..18fb6556 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/domain/ChatMessageVO.java @@ -0,0 +1,59 @@ +package org.bbagisix.chat.domain; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ChatMessageVO { + private final Long challengeId; + private final Long userId; + private final String message; + private final String messageType; + private final LocalDateTime sentAt; + private final String userName; + + // 비즈니스 로직 메서드 + public boolean isSystemMessage() { + return "SYSTEM".equals(messageType); + } + + public boolean isErrorMessage() { + return "ERROR".equals(messageType); + } + + public boolean isValidMessage() { + if (message == null || message.trim().isEmpty()) { + return false; + } + + // 길이 제한 (255자) + if (message.length() > 255) { + return false; + } + + // HTML 태그 포함 여부 검사 + if (message.contains("<") || message.contains(">")) { + return false; + } + + return true; + } + + public String getDisplayMessage() { + if (isSystemMessage()) { + return "🔔 " + message; + } else if (isErrorMessage()) { + return "❌ " + message; + } + + String displayName = userName != null ? userName : ("사용자" + userId); + return "[" + displayName + "]" + message; + } + + public String getMessageTypeForDisplay() { + return messageType != null ? messageType : "MESSAGE"; + } +} diff --git a/src/main/java/org/bbagisix/chat/domain/ChatVO.java b/src/main/java/org/bbagisix/chat/domain/ChatVO.java deleted file mode 100644 index 017e78b6..00000000 --- a/src/main/java/org/bbagisix/chat/domain/ChatVO.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.chat.domain; - -public class ChatVO { -} diff --git a/src/main/java/org/bbagisix/chat/dto/ChatDTO.java b/src/main/java/org/bbagisix/chat/dto/ChatDTO.java deleted file mode 100644 index 758b91f5..00000000 --- a/src/main/java/org/bbagisix/chat/dto/ChatDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.chat.dto; - -public class ChatDTO { -} diff --git a/src/main/java/org/bbagisix/chat/dto/ChatHistoryDTO.java b/src/main/java/org/bbagisix/chat/dto/ChatHistoryDTO.java new file mode 100644 index 00000000..fe374392 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/ChatHistoryDTO.java @@ -0,0 +1,28 @@ +package org.bbagisix.chat.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatHistoryDTO { + + private Long messageId; + private Long challengeId; + private Long userId; + private String message; + private LocalDateTime sentAt; + private String messageType; + private String userName; + + // 추가 정보 (필요시) + // private final Boolean isMyMessage; +} diff --git a/src/main/java/org/bbagisix/chat/dto/ChatMessageDTO.java b/src/main/java/org/bbagisix/chat/dto/ChatMessageDTO.java new file mode 100644 index 00000000..e3ecbb37 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/ChatMessageDTO.java @@ -0,0 +1,47 @@ +package org.bbagisix.chat.dto; + +import java.time.LocalDateTime; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageDTO { + + private Long messageId; // message_id (BIGINT) + + @NotNull(message = "챌린지 ID는 필수입니다") + private Long challengeId; // challenge_id (BIGINT) + + @NotNull(message = "사용자 ID는 필수입니다") + private Long userId; // user_id (BIGINT) + + @NotNull(message = "메시지는 필수입니다") + @Size(min = 1, max = 255, message = "메시지는 1자 이상 255자 이하여야 합니다") + private String message; // message (VARCHAR) + + private LocalDateTime sentAt; // sent_at (DATETIME) + private String messageType; // message_type (VARCHAR) + + // 추가 필드 (Join) + private String userName; + + // 클라이언트 전송용 생성자 + public ChatMessageDTO(Long challengeId, Long userId, String message, String messageType) { + this.challengeId = challengeId; + this.userId = userId; + this.message = message; + this.messageType = messageType; + this.sentAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/org/bbagisix/chat/dto/UserChallengeInfoDTO.java b/src/main/java/org/bbagisix/chat/dto/UserChallengeInfoDTO.java new file mode 100644 index 00000000..9224b9ef --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/UserChallengeInfoDTO.java @@ -0,0 +1,41 @@ +package org.bbagisix.chat.dto; + +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@NoArgsConstructor +public class UserChallengeInfoDTO { + + private Long userChallengeId; + private Long userId; + private Long challengeId; + private String challengeName; + private String status; // ongoing, completed, failed + private LocalDateTime startDate; // 메시지 이력 조회 시작점 + private LocalDateTime endDate; // 챌린지 종료일 (참고용) + + /** + * 채팅방 접근 가능한지 확인 + * ongoing 상태일 때만 채팅방 접근 가능 + */ + public boolean canAccessChatRoom() { + return "ongoing".equals(status); + } + + /** + * 챌린지가 종료되었는지 확인 + * completed 또는 failed 상태일 때 종료 + */ + public boolean isFinished() { + return "completed".equals(status) || "failed".equals(status); + } +} diff --git a/src/main/java/org/bbagisix/chat/dto/response/ChatRoomInfoResponse.java b/src/main/java/org/bbagisix/chat/dto/response/ChatRoomInfoResponse.java new file mode 100644 index 00000000..e51b69df --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/response/ChatRoomInfoResponse.java @@ -0,0 +1,14 @@ +package org.bbagisix.chat.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ChatRoomInfoResponse { + + private final Long challengeId; + private final String challengeName; + private final Integer participantCount; + private final String status; +} diff --git a/src/main/java/org/bbagisix/chat/dto/response/ParticipantCountResponse.java b/src/main/java/org/bbagisix/chat/dto/response/ParticipantCountResponse.java new file mode 100644 index 00000000..85b9f2c0 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/response/ParticipantCountResponse.java @@ -0,0 +1,12 @@ +package org.bbagisix.chat.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ParticipantCountResponse { + + private final Long challengeId; + private final Integer participantCount; +} diff --git a/src/main/java/org/bbagisix/chat/dto/response/ParticipantResponse.java b/src/main/java/org/bbagisix/chat/dto/response/ParticipantResponse.java new file mode 100644 index 00000000..75d7adcd --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/response/ParticipantResponse.java @@ -0,0 +1,16 @@ +package org.bbagisix.chat.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ParticipantResponse { + + private final Long userId; + private final String userName; + private final LocalDateTime joinedAt; + private final Boolean isActive; +} diff --git a/src/main/java/org/bbagisix/chat/dto/response/UserChallengeStatusResponse.java b/src/main/java/org/bbagisix/chat/dto/response/UserChallengeStatusResponse.java new file mode 100644 index 00000000..e0beb325 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/response/UserChallengeStatusResponse.java @@ -0,0 +1,20 @@ +package org.bbagisix.chat.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserChallengeStatusResponse { + + private final Long userId; + private final Long challengeId; + private final String challengeName; + private final Boolean hasActiveChallenge; + private final String status; + private final String message; + private final LocalDateTime startDate; + private final LocalDateTime endDate; +} diff --git a/src/main/java/org/bbagisix/chat/dto/response/UserChatRoomResponse.java b/src/main/java/org/bbagisix/chat/dto/response/UserChatRoomResponse.java new file mode 100644 index 00000000..a3c7a1e3 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/dto/response/UserChatRoomResponse.java @@ -0,0 +1,15 @@ +package org.bbagisix.chat.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserChatRoomResponse { + + private final Long userId; + private final Long challengeId; + private final String challengeName; + private final String status; + private final String message; +} diff --git a/src/main/java/org/bbagisix/chat/entity/ChatMessage.java b/src/main/java/org/bbagisix/chat/entity/ChatMessage.java new file mode 100644 index 00000000..e930e18d --- /dev/null +++ b/src/main/java/org/bbagisix/chat/entity/ChatMessage.java @@ -0,0 +1,24 @@ +package org.bbagisix.chat.entity; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessage { + private Long messageId; + private Long challengeId; + private Long userId; + private String message; + private LocalDateTime sentAt; + private String messageType; + + // DB 조인 결과용 (사용자 정보) + private String userName; +} diff --git a/src/main/java/org/bbagisix/chat/interceptior/WebSocketJwtInterceptor.java b/src/main/java/org/bbagisix/chat/interceptior/WebSocketJwtInterceptor.java new file mode 100644 index 00000000..f1f60a8c --- /dev/null +++ b/src/main/java/org/bbagisix/chat/interceptior/WebSocketJwtInterceptor.java @@ -0,0 +1,145 @@ +package org.bbagisix.chat.interceptior; + +import java.util.List; +import java.util.Map; + +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.dto.UserResponse; +import org.bbagisix.user.util.JwtUtil; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * WebSocket STOMP 연결 시 JWT 토큰 검증 인터셉터 (쿠키만 지원) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketJwtInterceptor implements ChannelInterceptor { + + private final JwtUtil jwtUtil; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + // STOMP 연결 시 JWT 토큰 검증 + authenticateUser(accessor); + } + + return message; + } + + /** + * WebSocket 연결 시 JWT 토큰으로 사용자 인증 + */ + private void authenticateUser(StompHeaderAccessor accessor) { + try { + String token = extractTokenFromCookie(accessor); + + if (token == null) { + log.warn("❌ WebSocket 연결: JWT 토큰이 없습니다"); + return; + } + + // JWT 토큰 검증 + if (!jwtUtil.isExpired(token)) { + log.warn("❌ WebSocket 연결: 유효하지 않은 JWT 토큰"); + return; + } + + // 토큰에서 사용자 정보 추출 + String email = jwtUtil.getEmail(token); + String name = jwtUtil.getName(token); // email 반환 + String nickname = jwtUtil.getNickname(token); + Long userId = jwtUtil.getUserId(token); + + // 실제 이름은 nickname을 사용 + String displayName = nickname != null ? nickname : name; + log.info("✅ WebSocket JWT 인증 성공: userId={}, email={}, nickname={}", userId, email, nickname); + + // UserResponse 객체 생성 + UserResponse userResponse = UserResponse.builder() + .userId(userId) + .email(email) + .name(displayName) + .nickname(nickname) + .role("USER") + .build(); + + // CustomOAuth2User 객체 생성 + CustomOAuth2User customUser = new CustomOAuth2User(userResponse); + + // Authentication 객체 생성 + Authentication auth = new UsernamePasswordAuthenticationToken( + customUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + // WebSocket 세션에 인증 정보 저장 + accessor.setUser(auth); + + // 세션 속성에 사용자 정보 저장 (채팅에서 사용) + Map sessionAttributes = accessor.getSessionAttributes(); + if (sessionAttributes != null) { + sessionAttributes.put("userId", userId); + sessionAttributes.put("userName", displayName); // nickname 사용 + sessionAttributes.put("email", email); + sessionAttributes.put("authenticated", true); + } + + log.info("📝 WebSocket 세션에 사용자 정보 저장 완료: userId={}", userId); + } catch (Exception e) { + log.error("❌ WebSocket JWT 인증 중 오류 발생: {}", e.getMessage(), e); + } + } + + /** + * Cookie에서 JWT 토큰 추출 + */ + private String extractTokenFromCookie(StompHeaderAccessor accessor) { + List cookieHeaders = accessor.getNativeHeader("Cookie"); + if (cookieHeaders != null && !cookieHeaders.isEmpty()) { + for (String cookieHeader : cookieHeaders) { + String token = parseJwtFromCookie(cookieHeader); + if (token != null) { + log.debug("🍪 Cookie에서 JWT 토큰 추출 성공"); + return token; + } + } + } + + return null; + } + + /** + * Cookie 문자열에서 JWT 토큰 파싱 + */ + private String parseJwtFromCookie(String cookieHeader) { + if (cookieHeader == null) + return null; + + String[] cookies = cookieHeader.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.trim().split("=", 2); // key=value + if (parts.length == 2 && "jwt".equals(parts[0].trim())) { + return parts[1].trim(); // JWT 토큰 반환 + } + } + + return null; + } +} diff --git a/src/main/java/org/bbagisix/chat/mapper/ChatMapper.java b/src/main/java/org/bbagisix/chat/mapper/ChatMapper.java index 55c3ec68..e6b822bc 100644 --- a/src/main/java/org/bbagisix/chat/mapper/ChatMapper.java +++ b/src/main/java/org/bbagisix/chat/mapper/ChatMapper.java @@ -1,4 +1,45 @@ package org.bbagisix.chat.mapper; +import java.util.List; +import java.util.Map; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.chat.dto.ChatHistoryDTO; +import org.bbagisix.chat.dto.UserChallengeInfoDTO; +import org.bbagisix.chat.entity.ChatMessage; + +@Mapper public interface ChatMapper { + + // 채팅 메시지 저장 (Entity) + int insertMessage(ChatMessage chatMessage); + + // 특정 메시지 조회 (사용자 정보 포함) + ChatMessage selectMessageById(@Param("messageId") Long messageId); + + // 사용자 정보 조회 + Map selectUserById(@Param("userId") Long userId); + + // 사용자가 현재 참여중인 챌린지 채팅방 조회 (단일) + Map selectUserCurrentChatRoom(@Param("userId") Long userId); + + // 특정 챌린지의 참여자 목록 조회 + List> selectParticipants(@Param("challengeId") Long challengeId); + + // 사용자가 챌린지에 참여한 시점 이후의 채팅 메시지 조회 + List selectChatHistoryByUserParticipation( + @Param("challengeId") Long challengeId, + @Param("userId") Long userId, + @Param("limit") int limit + ); + + // 사용자가 해당 챌린지에 현재 참여 중인지 확인 + UserChallengeInfoDTO selectUserChallengeStatus( + @Param("challengeId") Long challengeId, + @Param("userId") Long userId + ); + + // 사용자의 현재 활성 챌린지 정보 조회 + UserChallengeInfoDTO selectUserActiveChallengeInfo(@Param("userId") Long userId); } diff --git a/src/main/java/org/bbagisix/chat/service/ChatMessagePublisher.java b/src/main/java/org/bbagisix/chat/service/ChatMessagePublisher.java new file mode 100644 index 00000000..6e9d57be --- /dev/null +++ b/src/main/java/org/bbagisix/chat/service/ChatMessagePublisher.java @@ -0,0 +1,64 @@ +package org.bbagisix.chat.service; + +import org.bbagisix.chat.dto.ChatMessageDTO; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatMessagePublisher { + + @Qualifier("chatRedisTemplate") + private final RedisTemplate redisTemplate; // pub/sub 메시지 발행 및 데이터 저장/조회 + + private static final String CHAT_CHANNEL_PREFIX = "chat:channel:"; // 채팅방 별 격리와 패턴 매칭 위해 사용 + + /** + * 특정 챌린지 채널로 메시지 발행 + */ + public void publishMessage(Long challengeId, ChatMessageDTO message) { + try { + String channel = CHAT_CHANNEL_PREFIX + challengeId; + log.debug("Redis로 메시지 발행: channel={}, message={}", channel, message.getMessage()); + + // Redis pub/sub로 메시지 발행 + redisTemplate.convertAndSend(channel, message); + log.debug("메시지 발행 완료: challengeId={}", challengeId); + } catch (Exception e) { + log.error("Redis 메시지 발행 중 오류: challengeId={}", challengeId, e); + throw new BusinessException(ErrorCode.WEBSOCKET_SEND_FAILED, e); + } + } + + /** + * 접속자 수 변경 브로드캐스트 + */ + public void publishParticipantCount(Long challengeId, int count) { + try { + String channel = CHAT_CHANNEL_PREFIX + challengeId; + + ChatMessageDTO countMessage = ChatMessageDTO.builder() + .challengeId(challengeId) + .messageType("PARTICIPANT_COUNT") + .userId(0L) // 시스템 메시지 + .message(String.valueOf(count)) + .build(); + + log.debug("Redis로 접속자 수 발행: channel={}, count={}", channel, count); + + // Redis 발행 + redisTemplate.convertAndSend(channel, countMessage); + log.debug("접속자 수 발행 완료: challengeId={}, count={}", challengeId, count); + } catch (Exception e) { + log.error("접속자 수 Redis 발행 중 오류: challengeId={}, count={}", challengeId, count, e); + throw new BusinessException(ErrorCode.WEBSOCKET_SEND_FAILED, e); + } + } +} diff --git a/src/main/java/org/bbagisix/chat/service/ChatMessageSubscriber.java b/src/main/java/org/bbagisix/chat/service/ChatMessageSubscriber.java new file mode 100644 index 00000000..cda671b2 --- /dev/null +++ b/src/main/java/org/bbagisix/chat/service/ChatMessageSubscriber.java @@ -0,0 +1,103 @@ +package org.bbagisix.chat.service; + +import java.util.Map; + +import org.bbagisix.chat.dto.ChatMessageDTO; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatMessageSubscriber implements MessageListener { + + private final SimpMessagingTemplate messagingTemplate; + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // 알 수 없는 필드 무시 + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) // ISO 문자열로 직렬화 + .findAndRegisterModules(); + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + // 채널명 추출 + String channel = new String(message.getChannel()); + String messageBody = new String(message.getBody()); + + log.debug("Redis에서 메시지 수신: channel={}, body={}", channel, messageBody); + + // JSON -> ChatMessageDTO (역직렬화) + ChatMessageDTO chatMessage = objectMapper.readValue(messageBody, ChatMessageDTO.class); + + // 채널명에서 challengeId 추출 + Long challengeId = extractChallengeIdFromChannel(channel); + + if (challengeId == null) { + log.warn("잘못된 채널 형식: {}", channel); + return; + } + + // 메시지 타입에 따른 처리 + handleMessage(challengeId, chatMessage); + + } catch (Exception e) { + log.error("Redis 메시지 처리 중 오류 (무시하고 계속): ", e); + } + } + + /** + * 메시지 타입에 따른 처리 + * @param challengeId + * @param chatMessage + */ + private void handleMessage(Long challengeId, ChatMessageDTO chatMessage) { + try { + if ("PARTICIPANT_COUNT".equals(chatMessage.getMessageType())) { + int count = Integer.parseInt(chatMessage.getMessage()); + + // 접속자 수 업데이트 메시지 - Map + Map countMessage = Map.of( + "type", "PARTICIPANT_COUNT", + "challengeId", challengeId, + "count", count + ); + + // 채팅 채널로 통합 전송 (프론트엔드에서 하나의 구독으로 처리) + messagingTemplate.convertAndSend("/topic/chat/" + challengeId, countMessage); + log.debug("접속자 수 업데이트 전송: challengeId={}, count={}", challengeId, count); + + } else { + // 일반 채팅 메시지 + messagingTemplate.convertAndSend("/topic/chat/" + challengeId, chatMessage); + log.debug("채팅 메시지 전송: challengeId={}, messageType={}", challengeId, chatMessage.getMessageType()); + } + } catch (Exception e) { + log.error("WebSocket 메시지 전송 중 오류 (무시하고 계속): challengeId={}", challengeId, e); + } + } + + /** + * 채널명에서 challengeId 추출 + * "chat:channel:123" -> 123 + */ + private Long extractChallengeIdFromChannel(String channel) { + try { + if (channel != null && channel.startsWith("chat:channel:")) { + String idPart = channel.substring("chat:channel:".length()); + return Long.parseLong(idPart); + } + } catch (NumberFormatException e) { + log.warn("challengeId 파싱 실패: channel={}", channel); + } + return null; + } +} diff --git a/src/main/java/org/bbagisix/chat/service/ChatService.java b/src/main/java/org/bbagisix/chat/service/ChatService.java index a60bd80b..940d4a54 100644 --- a/src/main/java/org/bbagisix/chat/service/ChatService.java +++ b/src/main/java/org/bbagisix/chat/service/ChatService.java @@ -1,4 +1,435 @@ package org.bbagisix.chat.service; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.bbagisix.category.mapper.CategoryMapper; +import org.bbagisix.chat.converter.ChatMessageConverter; +import org.bbagisix.chat.domain.ChatMessageVO; +import org.bbagisix.chat.dto.ChatHistoryDTO; +import org.bbagisix.chat.dto.ChatMessageDTO; +import org.bbagisix.chat.dto.UserChallengeInfoDTO; +import org.bbagisix.chat.dto.response.UserChallengeStatusResponse; +import org.bbagisix.chat.entity.ChatMessage; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.chat.mapper.ChatMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional public class ChatService { -} + + private final ChatMapper chatMapper; + private final ChatMessageConverter converter; + private final ChatMessagePublisher chatMessagePublisher; + private final CategoryMapper categoryMapper; + + /** + * 채팅 메시지 저장 + * Redis 발행(pub) + */ + public ChatMessageDTO saveMessage(ChatMessageDTO dto) { + log.debug("채팅 메시지 저장 요청: challengeId={}, userId={}, message={}", + dto.getChallengeId(), dto.getUserId(), dto.getMessage()); + + try { + // 1. DTO -> VO 변환 + ChatMessageVO vo = converter.toVO(dto); + + // 2. 비즈니스 로직 검증 + validateMessage(vo); + + // 3. VO -> Entity 변환 + ChatMessage entity = converter.toEntity(vo); + + // 4. DB 저장 + int result = chatMapper.insertMessage(entity); + if (result != 1) { + throw new BusinessException(ErrorCode.WEBSOCKET_SEND_FAILED); + } + + // 5. 저장된 메시지 조회 (사용자 정보 포함) + ChatMessage savedEntity = chatMapper.selectMessageById(entity.getMessageId()); + if (savedEntity == null) { + throw new BusinessException(ErrorCode.MESSAGE_LOAD_FAILED); + } + + // 6. Entity -> DTO 변환하여 반환 + ChatMessageDTO resultDTO = converter.toDTO(savedEntity); + + // 7. Redis pub/sub로 메시지 발행 (새로 추가된 부분) + try { + chatMessagePublisher.publishMessage(dto.getChallengeId(), resultDTO); + log.debug("메시지 Redis 발행 완료: messageId={}", resultDTO.getMessageId()); + } catch (Exception e) { + log.error("Redis 메시지 발행 실패 (DB 저장은 성공): messageId={}", resultDTO.getMessageId(), e); + } + log.debug("메시지 저장 완료: messageId={}", resultDTO.getMessageId()); + return resultDTO; + + } catch (BusinessException e) { + log.warn("비즈니스 예외 발생: code={}, message={}", e.getCode(), e.getMessage()); + throw e; + } catch (Exception e) { + log.error("메시지 저장 중 예상하지 못한 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + /** + * 채팅 이력 조회 (사용자가 참여한 시점 이후) + */ + public List getChatHistory(Long challengeId, Long userId, int limit) { + log.debug("채팅 이력 조회: challengeId={}, userId={}, limit={}", challengeId, userId, limit); + + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + try { + // 1. 먼저 사용자가 해당 챌린지에 참여 중인지 확인 + if (!isUserParticipatingInChallenge(challengeId, userId)) { + throw new BusinessException(ErrorCode.CHALLENGE_ACCESS_DENIED); + } + + // 2. 채팅 이력 조회 + List historyList = chatMapper.selectChatHistoryByUserParticipation( + challengeId, userId, limit + ); + + // 3. ChatHistoryDTO를 ChatMessageDTO로 변환 + return historyList.stream() + .map(this::convertHistoryToMessage) + .collect(Collectors.toList()); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("채팅 이력 조회 중 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "채팅 이력을 불러올 수 없습니다.", e); + } + } + + /** + * 사용자가 해당 챌린지에 참여 중인지 확인 + */ + public boolean isUserParticipatingInChallenge(Long challengeId, Long userId) { + log.debug("챌린지 참여 상태 확인: challengeId={}, userId={}", challengeId, userId); + + if (challengeId == null || userId == null) { + return false; + } + + try { + UserChallengeInfoDTO challengeInfo = chatMapper.selectUserChallengeStatus(challengeId, userId); + return challengeInfo != null && challengeInfo.canAccessChatRoom(); + + } catch (Exception e) { + log.error("챌린지 참여 상태 확인 중 오류: challengeId={}, userId={}", challengeId, userId, e); + return false; + } + } + + /** + * 사용자의 현재 활성 챌린지 상태 조회 + */ + public UserChallengeStatusResponse getUserChallengeStatus(Long userId) { + log.info("🔍 사용자 챌린지 상태 조회 시작: userId={}", userId); // 추가 + + if (userId == null) { + log.warn("❌ userId가 null입니다"); // 추가 + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + try { + log.info("📊 사용자 활성 챌린지 조회: userId={}", userId); + UserChallengeInfoDTO challengeInfo = chatMapper.selectUserActiveChallengeInfo(userId); + log.info("📊 DB 조회 결과: {}", challengeInfo); + + if (challengeInfo == null) { + // 참여 중인 챌린지가 없는 경우 + return UserChallengeStatusResponse.builder() + .userId(userId) + .hasActiveChallenge(false) + .status("no_challenge") + .message("참여 중인 챌린지가 없습니다.") + .build(); + } + + // 참여 중인 챌린지가 있는 경우 + return UserChallengeStatusResponse.builder() + .userId(userId) + .challengeId(challengeInfo.getChallengeId()) + .challengeName(challengeInfo.getChallengeName()) + .hasActiveChallenge(challengeInfo.canAccessChatRoom()) + .status(challengeInfo.getStatus()) + .message(getStatusMessage(challengeInfo.getStatus())) + .startDate(challengeInfo.getStartDate()) + .endDate(challengeInfo.getEndDate()) + .build(); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("사용자 챌린지 상태 조회 중 오류: userId={}", userId, e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "챌린지 상태를 확인할 수 없습니다.", e); + } + } + + /** + * ChatHistoryDTO를 ChatMessageDTO로 변환 (내부 유틸리티 메서드) + */ + private ChatMessageDTO convertHistoryToMessage(ChatHistoryDTO history) { + return ChatMessageDTO.builder() + .messageId(history.getMessageId()) + .challengeId(history.getChallengeId()) + .userId(history.getUserId()) + .message(history.getMessage()) + .sentAt(history.getSentAt()) + .messageType(history.getMessageType()) + .userName(history.getUserName()) + .build(); + } + + /** + * 상태별 메시지 생성 (내부 유틸리티 메서드) + */ + private String getStatusMessage(String status) { + switch (status) { + case "ongoing": + return "챌린지 진행 중입니다."; + case "completed": + return "챌린지를 완료했습니다."; + case "failed": + return "챌린지에 실패했습니다."; + default: + return "참여 중인 챌린지가 없습니다."; + } + } + + /** + * 사용자 입장 처리 + */ + public ChatMessageDTO handleJoin(Long challengeId, Long userId) { + log.debug("사용자 입장 처리: challengeId={}, userId={}", challengeId, userId); + + // 파라미터 검증 + validateJoinParameters(challengeId, userId); + + try { + // 사용자 정보 조회 + String userName = getUserName(userId); + + // 시스템 메시지 생성 + String message = userName + "님이 입장했습니다."; + ChatMessageVO systemVO = converter.createSystemMessage(challengeId, userId, userName, message); + + // VO → DTO 변환하여 반환 + ChatMessageDTO resultDTO = ChatMessageDTO.builder() + .challengeId(systemVO.getChallengeId()) + .userId(systemVO.getUserId()) + .userName(systemVO.getUserName()) + .message(systemVO.getMessage()) + .messageType(systemVO.getMessageType()) + .sentAt(systemVO.getSentAt()) + .build(); + + // Redis pub/sub로 입장 메시지 발행 + try { + chatMessagePublisher.publishMessage(challengeId, resultDTO); + log.debug("입장 메시지 Redis 발행 완료: userId={}, userName={}", userId, userName); + } catch (Exception e) { + log.error("입장 메시지 Redis 발행 실패: userId={}, userName={}", userId, userName, e); + // 입장 메시지 발행 실패해도 입장 처리는 계속 진행 + } + + log.debug("입장 처리 완료: userId={}, userName={}", userId, userName); + return resultDTO; + } catch (Exception e) { + log.error("입장 처리 중 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "입장 처리 중 오류가 발생했습니다.", e); + } + } + + /** + * 사용자 퇴장 처리 + */ + public ChatMessageDTO handleLeave(Long challengeId, Long userId, String userName) { + log.debug("사용자 퇴장 처리: challengeId={}, userId={}, userName={}", challengeId, userId, userName); + + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + try { + String displayName = (userName != null && !userName.trim().isEmpty()) + ? userName + : ("사용자" + userId); + + // 시스템 메시지 생성 + String message = displayName + "님이 퇴장했습니다."; + ChatMessageVO systemVO = converter.createSystemMessage(challengeId, userId, displayName, message); + + // VO → DTO 변환하여 반환 + ChatMessageDTO resultDTO = ChatMessageDTO.builder() + .challengeId(systemVO.getChallengeId()) + .userId(systemVO.getUserId()) + .userName(systemVO.getUserName()) + .message(systemVO.getMessage()) + .messageType(systemVO.getMessageType()) + .sentAt(systemVO.getSentAt()) + .build(); + + // Redis pub/sub로 퇴장 메시지 발행 + try { + chatMessagePublisher.publishMessage(challengeId, resultDTO); + log.debug("퇴장 메시지 Redis 발행 완료: userName={}", displayName); + } catch (Exception e) { + log.error("퇴장 메시지 Redis 발행 실패: userName={}", displayName, e); + // 퇴장 메시지 발행 실패해도 퇴장 처리는 계속 진행 + } + log.debug("퇴장 처리 완료: userName={}", displayName); + return resultDTO; + } catch (Exception e) { + log.error("퇴장 처리 중 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "퇴장 처리 중 오류가 발생했습니다.", e); + } + } + + /** + * 사용자가 참여중인 채팅방 목록 조회 + */ + public Map getUserCurrentChatRoom(Long userId) { + log.debug("사용자 채팅 목록 조회: userId={}", userId); + + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + try { + Map currentChatRoom = chatMapper.selectUserCurrentChatRoom(userId); + + if (currentChatRoom == null || currentChatRoom.isEmpty()) { + // 참여중인 챌린지가 없는 경우 + log.debug("사용자 {}가 참여 중인 챌린지가 없습니다.", userId); + return Map.of( + "userId", userId, + "challengeId", null, + "challengeName", null, + "status", "no_challenge", + "message", "참여 중인 챌린지가 없습니다." + ); + } + // 참여중인 챌린지가 있는 경우 + return currentChatRoom; + } catch (Exception e) { + log.error("채팅방 목록 조회 중 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "채팅방 목록을 불러올 수 없습니다."); + } + } + + /** + * 특정 채팅방의 참여자 목록 조회 + */ + public List> getParticipants(Long challengeId) { + log.debug("참여자 목록 조회: challengeId={}", challengeId); + + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + + try { + return chatMapper.selectParticipants(challengeId); + } catch (Exception e) { + log.error("참여자 목록 조회중 오류: ", e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, "참여자 목록을 불러올 수 없습니다.", e); + } + } + + /** + * 에러 메시지 생성 + */ + public ChatMessageDTO createErrorMessage(Long challengeId, String errorMessage) { + log.debug("에러 메시지 생성: challengeId={}, message={}", challengeId, errorMessage); + + ChatMessageVO errorVO = converter.createErrorMessage(challengeId, errorMessage); + + return ChatMessageDTO.builder() + .challengeId(errorVO.getChallengeId()) + .message(errorVO.getMessage()) + .messageType(errorVO.getMessageType()) + .sentAt(errorVO.getSentAt()) + .build(); + } + + /** + * 메시지 검증 + */ + private void validateMessage(ChatMessageVO vo) { + if (vo.getMessage() == null || vo.getMessage().trim().isEmpty()) { + throw new BusinessException(ErrorCode.MESSAGE_EMPTY); + } + + if (vo.getMessage().length() > 255) { + throw new BusinessException(ErrorCode.MESSAGE_TOO_LONG); + } + + if (vo.getMessage().contains("<") || vo.getMessage().contains(">")) { + throw new BusinessException(ErrorCode.MESSAGE_CONTAINS_HTML); + } + + if (!vo.isValidMessage()) { + throw new BusinessException(ErrorCode.INVALID_MESSAGE); + } + } + + /** + * 입장 파라미터 검증 + */ + private void validateJoinParameters(Long challengeId, Long userId) { + if (challengeId == null) { + throw new BusinessException(ErrorCode.CHALLENGE_ID_REQUIRED); + } + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + } + + /** + * 사용자 이름 조회 + */ + private String getUserName(Long userId) { + String defaultName = "사용자" + userId; + + try { + Map userMap = chatMapper.selectUserById(userId); + + if (userMap != null && !userMap.isEmpty()) { + String dbUserName = (String)userMap.get("name"); + if (dbUserName != null && !dbUserName.trim().isEmpty()) { + log.debug("사용자 정보 조회 성공: userId={}, name={}", userId, dbUserName); + return dbUserName; + } + } + + log.warn("사용자 ID {}의 정보를 DB에서 찾을 수 없습니다. 기본값 사용: {}", userId, defaultName); + return defaultName; + + } catch (Exception e) { + log.error("사용자 정보 조회 중 오류 (userId: {}): {}", userId, e.getMessage()); + return defaultName; + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/chat/service/ChatSessionService.java b/src/main/java/org/bbagisix/chat/service/ChatSessionService.java new file mode 100644 index 00000000..b5dd37bb --- /dev/null +++ b/src/main/java/org/bbagisix/chat/service/ChatSessionService.java @@ -0,0 +1,87 @@ +package org.bbagisix.chat.service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatSessionService { + + private final SimpMessagingTemplate messagingTemplate; + private final ChatMessagePublisher chatMessagePublisher; + + // challengeId 별 현재 접속자 수 저장 (메모리 기반) + private final Map challengeParticipantCount = new ConcurrentHashMap<>(); + + /** + * 사용자 입장 - 접속자 수 증가 + */ + public void addParticipant(Long challengeId) { + challengeParticipantCount.merge(challengeId, 1, Integer::sum); + int currentCount = challengeParticipantCount.get(challengeId); + + log.info("챌린지 {} 접속자 증가: {} 명", challengeId, currentCount); + + // Redis pub/sub로 접속자 수 브로드캐스트 + try { + chatMessagePublisher.publishParticipantCount(challengeId, currentCount); + log.debug("접속자 수 Redis 발행 완료: challengeId={}, count={}", challengeId, currentCount); + } catch (Exception e) { + log.error("접속자 수 Redis 발행 실패 (무시하고 계속): challengeId={}, count={}", + challengeId, currentCount, e); + // Redis 발행 실패해도 서비스는 계속 동작 + } + } + + /** + * 사용자 퇴장 - 접속자 수 감소 + */ + public void removeParticipant(Long challengeId) { + challengeParticipantCount.computeIfPresent(challengeId, (key, count) -> { // challengeId가 존재할 때만 실행 + int newCount = Math.max(0, count - 1); // 음수 방지 (비정상적 퇴장 방지) + log.info("챌린지 {} 접속자 감소: {} 명", challengeId, newCount); + + // Redis pub/sub로 접속자 수 브로드캐스트 + try { + chatMessagePublisher.publishParticipantCount(challengeId, newCount); + log.debug("접속자 수 Redis 발행 완료: challengeId={}, count={}", challengeId, newCount); + } catch (Exception e) { + log.error("접속자 수 Redis 발행 실패 (무시하고 계속): challengeId={}, count={}", + challengeId, newCount, e); + // Redis 발행 실패해도 서비스는 계속 동작 + } + + return newCount == 0 ? null : newCount; // 0이면 맵에서 제거 -> 메모리 효율성 + }); + } + + /** + * 현재 접속자 수 조회 + */ + public int getParticipantCount(Long challengeId) { + return challengeParticipantCount.getOrDefault(challengeId, 0); + } + + /** + * 접속자 수를 모든 구독자에게 브로드캐스트 + * Redis pub/sub가 대신 처리하므로 더 이상 필요 없음 + */ + /* + public void broadcastParticipantCount(Long challengeId, int count) { + Map countMessage = Map.of( + "type", "PARTICIPANT_COUNT", + "challengeId", challengeId, + "count", count + ); + + messagingTemplate.convertAndSend("/topic/chat/" + challengeId, countMessage); + } + */ +} diff --git a/src/main/java/org/bbagisix/classify/dto/ClassifyRequest.java b/src/main/java/org/bbagisix/classify/dto/ClassifyRequest.java new file mode 100644 index 00000000..0df0b812 --- /dev/null +++ b/src/main/java/org/bbagisix/classify/dto/ClassifyRequest.java @@ -0,0 +1,20 @@ +package org.bbagisix.classify.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassifyRequest { + + @JsonProperty("expenditure_id") + private Long expenditureId; + + private String description; +} diff --git a/src/main/java/org/bbagisix/classify/dto/ClassifyResponse.java b/src/main/java/org/bbagisix/classify/dto/ClassifyResponse.java new file mode 100644 index 00000000..7a25671e --- /dev/null +++ b/src/main/java/org/bbagisix/classify/dto/ClassifyResponse.java @@ -0,0 +1,21 @@ +package org.bbagisix.classify.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassifyResponse { + + @JsonProperty("expenditure_id") + private Long expenditureId; + + @JsonProperty("category_id") + private Long categoryId; +} diff --git a/src/main/java/org/bbagisix/classify/service/ClassifyService.java b/src/main/java/org/bbagisix/classify/service/ClassifyService.java new file mode 100644 index 00000000..561bd3ac --- /dev/null +++ b/src/main/java/org/bbagisix/classify/service/ClassifyService.java @@ -0,0 +1,9 @@ +package org.bbagisix.classify.service; + +import java.util.List; + +import org.bbagisix.expense.domain.ExpenseVO; + +public interface ClassifyService { + List classify(List expense); +} diff --git a/src/main/java/org/bbagisix/classify/service/ClassifyServiceImpl.java b/src/main/java/org/bbagisix/classify/service/ClassifyServiceImpl.java new file mode 100644 index 00000000..253c2f99 --- /dev/null +++ b/src/main/java/org/bbagisix/classify/service/ClassifyServiceImpl.java @@ -0,0 +1,83 @@ +package org.bbagisix.classify.service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.expense.domain.ExpenseVO; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class ClassifyServiceImpl implements ClassifyService { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${LLM_SERVER_URL}") + private String llmServerUrl; + + @Override + public List classify(List expenses) { + AtomicLong counter = new AtomicLong(1); + + // categoryId == 14인 것만 분류 대상 + List toClassify = expenses.stream() + .filter(e -> e.getCategoryId() != null && e.getCategoryId() == 14) + .collect(Collectors.toList()); + + // 분류 요청에 쓸 payload 구성 + List> exps = toClassify.stream() + .map(e -> { + long id = counter.getAndIncrement(); + e.setExpenditureId(id); + return Map.of( + "expenditure_id", id, + "description", e.getDescription() + ); + }) + .collect(Collectors.toList()); + + // 아무것도 보낼 게 없다면 바로 리턴 + if (exps.isEmpty()) { + return expenses; + } + + Map payload = Map.of("exps", exps); + + String classifyUrl = llmServerUrl + "/classify"; + Map response = restTemplate.postForObject(classifyUrl, payload, Map.class); + if (response == null || !response.containsKey("results")) { + throw new BusinessException(ErrorCode.LLM_CLASSIFY_ERROR); + } + + List> results = (List>)response.get("results"); + + // ID 매칭용 map + Map idToCategory = results.stream() + .collect(Collectors.toMap( + r -> ((Number)r.get("expenditure_id")).longValue(), + r -> ((Number)r.get("category_id")).longValue() + )); + + // 다시 카테고리 업데이트 + for (ExpenseVO expense : toClassify) { + Long id = expense.getExpenditureId(); + Long categoryId = idToCategory.get(id); + if (categoryId != null) { + expense.setCategoryId(categoryId); + expense.setExpenditureId(null); // 임시 ID 초기화 + } + } + + return expenses; + } +} diff --git a/src/main/java/org/bbagisix/common/codef/domain/CodefAccessTokenVO.java b/src/main/java/org/bbagisix/common/codef/domain/CodefAccessTokenVO.java new file mode 100644 index 00000000..8f4556c6 --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/domain/CodefAccessTokenVO.java @@ -0,0 +1,22 @@ +package org.bbagisix.common.codef.domain; + +import java.util.Date; + +import lombok.Getter; +import lombok.Setter; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class CodefAccessTokenVO { + // access_token db + private Long tokenId; + private String accessToken; + private Date expiresAt; + private Date updatedAt; + private Date createdAt; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionReqDTO.java b/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionReqDTO.java new file mode 100644 index 00000000..39f0ae4c --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionReqDTO.java @@ -0,0 +1,20 @@ +package org.bbagisix.common.codef.dto; + +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class CodefTransactionReqDTO { + private String bankCode; // organization + private String bankId; + private String bankEncryptPw; + private String bankAccount; + + private String connectedId; + private String startDate; + private String endDate; + private String orderBy; +} diff --git a/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionResDTO.java b/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionResDTO.java new file mode 100644 index 00000000..ffcba0d6 --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/dto/CodefTransactionResDTO.java @@ -0,0 +1,34 @@ +package org.bbagisix.common.codef.dto; + +import java.util.List; + +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class CodefTransactionResDTO { + private String resAccountBalance; // 계좌 잔액 + private String resAccountName; // 계좌명 + private List resTrHistoryList; // 거래내역 리스트 + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @ToString + public static class HistoryItem { + private String resAccountTrDate; // 날짜 + private String resAccountTrTime; // 시간 + private String resAccountOut; // 출금액 + private String resAccountIn; // 입금액 + private String resAccountDesc1; // 거래 설명 + private String resAccountDesc2; // 거래 설명 (거래 수단) + private String resAccountDesc3; // 거래 설명 (사업자명) + private String resAccountDesc4; // 거래 설명 (위치) + private String resAfterTranBalance; // 거래 후 잔액 + private String tranDesc; // 거래 설명 + } +} diff --git a/src/main/java/org/bbagisix/common/codef/mapper/CodefAccessTokenMapper.java b/src/main/java/org/bbagisix/common/codef/mapper/CodefAccessTokenMapper.java new file mode 100644 index 00000000..3f40539b --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/mapper/CodefAccessTokenMapper.java @@ -0,0 +1,13 @@ +package org.bbagisix.common.codef.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.bbagisix.common.codef.domain.CodefAccessTokenVO; + +@Mapper +public interface CodefAccessTokenMapper { + CodefAccessTokenVO getCurrentToken(); + + int insertToken(CodefAccessTokenVO tokenVO); + + int updateToken(CodefAccessTokenVO tokenVO); +} diff --git a/src/main/java/org/bbagisix/common/codef/service/CodefAccessTokenService.java b/src/main/java/org/bbagisix/common/codef/service/CodefAccessTokenService.java new file mode 100644 index 00000000..bab7263e --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/service/CodefAccessTokenService.java @@ -0,0 +1,216 @@ +package org.bbagisix.common.codef.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; + +import org.bbagisix.common.codef.domain.CodefAccessTokenVO; +import org.bbagisix.common.codef.mapper.CodefAccessTokenMapper; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RequiredArgsConstructor +@Service +@Log4j2 +public class CodefAccessTokenService { + + private static final String OAUTH_URL = "https://oauth.codef.io/oauth/token"; + + @Value("${CODEF_CLIENT_ID:}") + private String clientId; + @Value("${CODEF_CLIENT_SECRET:}") + private String clientSecret; + + private final CodefAccessTokenMapper codefAccessTokenMapper; + + private String accessToken; + private long expiresTime; + + // access token 유효성 확인 및 갱신 + public String getValidAccessToken() { + + CodefAccessTokenVO curToken = codefAccessTokenMapper.getCurrentToken(); + + if (curToken == null || isTokenExpired(curToken)) { + saveAccessToken(); // 토큰 저장 + curToken = codefAccessTokenMapper.getCurrentToken(); // 다시 조회 + if (curToken == null) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "토큰 저장 후에도 토큰을 조회할 수 없습니다."); + } + } + this.accessToken = curToken.getAccessToken(); + this.expiresTime = curToken.getExpiresAt().getTime(); + + return this.accessToken; + } + + // 토큰 만료 여부 확인 + private boolean isTokenExpired(CodefAccessTokenVO token) { + if (token == null || token.getExpiresAt() == null) { + return true; + } + + long currentTime = System.currentTimeMillis(); + long tokenExpiryTime = token.getExpiresAt().getTime(); + + // 만료 10분 전에 미리 갱신 + long bufferTime = 10 * 60 * 1000L; // 10분 + + return (currentTime + bufferTime) >= tokenExpiryTime; + } + + // 가져온 token을 token table에 저장 + public void saveAccessToken() { + // API에서 토큰 가져오기 + HashMap tokenMap = getAccessToken(); + + if (tokenMap == null) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "Codef OAuth API로부터 토큰을 받지 못했습니다."); + } + + // 토큰 정보 추출 + Object accessTokenObj = tokenMap.get("access_token"); + Object expiresInObj = tokenMap.get("expires_in"); + + if (accessTokenObj == null) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "응답에서 access_token을 찾을 수 없습니다."); + } + + if (expiresInObj == null) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "응답에서 expires_in을 찾을 수 없습니다."); + } + + accessToken = tokenMap.get("access_token").toString(); + + long expiresIn = Long.parseLong(tokenMap.get("expires_in").toString()) * 1000L; + expiresTime = System.currentTimeMillis() + expiresIn; + + // VO 생성 + CodefAccessTokenVO vo = new CodefAccessTokenVO(); + vo.setAccessToken(accessToken); + vo.setExpiresAt(new Date(expiresTime)); + + // DB 저장 (기존 토큰이 있으면 업데이트, 없으면 삽입) + CodefAccessTokenVO curToken = codefAccessTokenMapper.getCurrentToken(); + + if (curToken != null) { + vo.setTokenId(curToken.getTokenId()); + int updatedRows = codefAccessTokenMapper.updateToken(vo); + if (updatedRows == 0) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "토큰 업데이트에 실패했습니다."); + } + } else { + codefAccessTokenMapper.insertToken(vo); + } + } + + // api로 accesstoken 가져옴 + public HashMap getAccessToken() { + HttpURLConnection con = null; + BufferedReader br = null; + try { + // 클라이언트 ID/Secret 검증 + if (clientId == null || clientId.trim().isEmpty()) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "CODEF_CLIENT_ID가 설정되지 않았습니다."); + } + if (clientSecret == null || clientSecret.trim().isEmpty()) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "CODEF_CLIENT_SECRET이 설정되지 않았습니다."); + } + + URL url = new URL(OAUTH_URL); + con = (HttpURLConnection)url.openConnection(); + + // body + String params = "grant_type=client_credentials&scope=read"; + + // header + con.setRequestMethod("POST"); + con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + // auth : 클라이언트아이디, 시크릿코드 Base64 인코딩 + String auth = clientId + ":" + clientSecret; + String authStringEnc = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + con.setRequestProperty("Authorization", "Basic " + authStringEnc); + con.setDoInput(true); + con.setDoOutput(true); + + // 요청 + try (OutputStream os = con.getOutputStream()) { + os.write(params.getBytes()); + os.flush(); + } + + // 응답 + int responseCode = con.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + br = new BufferedReader(new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)); + } else if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, + "Codef OAuth 인증에 실패했습니다. 클라이언트 ID/Secret을 확인해주세요. (HTTP " + responseCode + ")"); + } else { + // 에러 응답 읽기 + br = new BufferedReader(new InputStreamReader(con.getErrorStream(), StandardCharsets.UTF_8)); + String errorResponse = readResponse(br); + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, + "Codef OAuth API 요청이 실패했습니다. (HTTP " + responseCode + "): " + errorResponse); + } + // 성공 응답 읽기 + String responseStr = readResponse(br); + + // JSON 파싱 + ObjectMapper mapper = new ObjectMapper(); + HashMap result = mapper.readValue(responseStr, + new TypeReference>() { + }); + + log.info("Codef OAuth API에서 토큰 성공적으로 획득"); + return result; + + } catch (IOException e) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, + "Codef OAuth API 네트워크 오류가 발생했습니다: " + e.getMessage()); + } catch (Exception e) { + // log.error("Access Token 획득 중 예상치 못한 오류", e); + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, + "Access Token 획득 중 예상치 못한 오류가 발생했습니다: " + e.getMessage()); + } finally { + // 리소스 정리 + if (br != null) { + try { + br.close(); + } catch (IOException e) { + log.warn("BufferedReader 정리 실패: {}", e.getMessage()); + } + } + if (con != null) { + con.disconnect(); + } + } + } + + // 응답 읽기 헬퍼 메서드 + private String readResponse(BufferedReader br) throws IOException { + StringBuilder responseStr = new StringBuilder(); + String inputLine; + while ((inputLine = br.readLine()) != null) { + responseStr.append(inputLine); + } + return responseStr.toString(); + } +} diff --git a/src/main/java/org/bbagisix/common/codef/service/CodefApiService.java b/src/main/java/org/bbagisix/common/codef/service/CodefApiService.java new file mode 100644 index 00000000..402e1651 --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/service/CodefApiService.java @@ -0,0 +1,369 @@ +package org.bbagisix.common.codef.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.dto.AssetDTO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.asset.encryption.EncryptionUtil; +import org.bbagisix.common.codef.dto.CodefTransactionReqDTO; +import org.bbagisix.common.codef.dto.CodefTransactionResDTO; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RequiredArgsConstructor +@Service +@Log4j2 +public class CodefApiService { + + @Value("${CODEF_PUBLIC_KEY:}") + private String publicKey; + + private final CodefAccessTokenService accessTokenService; + + private final EncryptionUtil encryptionUtil; + + private final AssetMapper assetMapper; + + private static final String CONNECTED_ID_URL = "https://development.codef.io/v1/account/create"; + private static final String TRANSACTION_LIST_URL = "https://development.codef.io/v1/kr/bank/p/account/transaction-list"; + private static final String DELETED_URL = "https://development.codef.io/v1/account/delete"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 은행 코드 매핑 + private static final Map BANK_CODES = new HashMap<>(); + + static { + BANK_CODES.put("산업은행", "0002"); + BANK_CODES.put("광주은행", "0034"); + BANK_CODES.put("기업은행", "0003"); + BANK_CODES.put("제주은행", "0035"); + BANK_CODES.put("국민은행", "0004"); + BANK_CODES.put("전북은행", "0037"); + BANK_CODES.put("수협은행", "0007"); + BANK_CODES.put("경남은행", "0039"); + BANK_CODES.put("농협은행", "0011"); + BANK_CODES.put("새마을금고", "0045"); + BANK_CODES.put("우리은행", "0020"); + BANK_CODES.put("신협은행", "0048"); + BANK_CODES.put("SC은행", "0023"); + BANK_CODES.put("우체국", "0071"); + BANK_CODES.put("씨티은행", "0027"); + BANK_CODES.put("하나은행", "0081"); + BANK_CODES.put("대구은행", "0031"); + BANK_CODES.put("신한은행", "0088"); + BANK_CODES.put("부산은행", "0032"); + BANK_CODES.put("K뱅크", "0089"); + } + + // connected id 조회 + public String getConnectedId(AssetDTO assetDTO) { + + // 은행 코드 + String bankCode = BANK_CODES.get(assetDTO.getBankName()); + // 비밀번호 암호화 + String encryptedPw = encryptPw(assetDTO.getBankpw()); + + // 요청 본문 생성 + Map reqBody = connectedIdReqBody(bankCode, assetDTO.getBankId(), encryptedPw); + + // API 호출 + Map res = postCodefApi(CONNECTED_ID_URL, reqBody); + if (res == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "Codef API로부터 응답을 받지 못했습니다."); + } + + // connected id 추출 + String connectedId = extractConnectedId(res); + if (connectedId == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "응답에서 Connected ID를 찾을 수 없습니다."); + } + return connectedId; + } + + // Connected ID 요청 본문 생성 + private Map connectedIdReqBody(String bankCode, String bankId, String encryptedPw) { + Map account = new HashMap<>(); + account.put("countryCode", "KR"); + account.put("businessType", "BK"); + account.put("clientType", "P"); + account.put("organization", bankCode); + account.put("loginType", "1"); + account.put("id", bankId); + account.put("password", encryptedPw); + + Map reqBody = new HashMap<>(); + reqBody.put("accountList", new Map[] {account}); + + return reqBody; + } + + // 비밀번호 RSA 암호화 + public String encryptPw(String password) { + if (password == null || password.trim().isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST, "암호화할 비밀번호가 비어있습니다."); + } + + return encryptionUtil.encryptRSA(password, publicKey); + } + + // 응답에서 Connected ID 추출 + private String extractConnectedId(Map res) { + Map dataMap = (Map)res.get("data"); + if (dataMap == null || dataMap.get("connectedId") == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "응답 데이터에서 Connected ID를 찾을 수 없습니다."); + } + return dataMap.get("connectedId").toString(); + } + + // 거래 내역 조회 요청 DTO 생성 + private CodefTransactionReqDTO createTransactionReqDTO(AssetDTO assetDTO, String connectedId, String startDate, + String endDate, boolean isFirst) { + CodefTransactionReqDTO requestDTO = new CodefTransactionReqDTO(); + + String bankCode = BANK_CODES.get(assetDTO.getBankName()); + requestDTO.setBankCode(bankCode); + + if (isFirst) { + String encryptedPassword = encryptPw(assetDTO.getBankpw()); + requestDTO.setBankEncryptPw(encryptedPassword); + } else { + requestDTO.setBankEncryptPw(assetDTO.getBankpw()); + } + + requestDTO.setBankId(assetDTO.getBankId()); + requestDTO.setBankAccount(assetDTO.getBankAccount()); + requestDTO.setConnectedId(connectedId); + requestDTO.setStartDate(startDate); + requestDTO.setEndDate(endDate); + requestDTO.setOrderBy("1"); + + return requestDTO; + } + + // 거래 내역 조회 + public CodefTransactionResDTO getTransactionList(AssetDTO assetDTO, String connectedId, String startDate, + String endDate, boolean isFirst) { + + CodefTransactionReqDTO requestDTO = createTransactionReqDTO(assetDTO, connectedId, startDate, endDate, isFirst); + + Map requestBody = transactionListReqBody(requestDTO); + Map res = postCodefApi(TRANSACTION_LIST_URL, requestBody); + + if (res == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "거래내역 조회 API로부터 응답을 받지 못했습니다."); + } + + CodefTransactionResDTO result = toTransactionResDTO(res); + return result; + } + + // Connected ID 삭제 + public boolean deleteConnectedId(Long userId) { + + // 사용자 계좌 정보 조회 + AssetVO assetVO = assetMapper.selectAssetByUserIdAndStatus(userId, "main"); + if (assetVO == null) { + throw new BusinessException(ErrorCode.ASSET_NOT_FOUND); + } + + String bankName = assetVO.getBankName(); + String connectedId = assetVO.getConnectedId(); + + if (connectedId == null || connectedId.trim().isEmpty()) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "삭제할 Connected ID가 없습니다."); + } + + String bankCode = BANK_CODES.get(bankName); + + // API 호출 + Map reqBody = deleteConnectedIdReqBody(bankCode, connectedId); + Map res = postCodefApi(DELETED_URL, reqBody); + + if (res == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "Connected ID 삭제 API로부터 응답을 받지 못했습니다."); + } + + return true; + } + + // Connected ID 삭제 요청 본문 생성 + private Map deleteConnectedIdReqBody(String bankCode, String connectedId) { + Map account = new HashMap<>(); + account.put("countryCode", "KR"); + account.put("businessType", "BK"); + account.put("clientType", "P"); + account.put("organization", bankCode); + account.put("loginType", "1"); + + Map reqBody = new HashMap<>(); + reqBody.put("accountList", new Map[] {account}); + reqBody.put("connectedId", connectedId); + return reqBody; + } + + // 거래내역 조회 요청 본문 생성 + private Map transactionListReqBody(CodefTransactionReqDTO requestDTO) { + Map transaction = new HashMap<>(); + transaction.put("organization", requestDTO.getBankCode()); + transaction.put("connectedId", requestDTO.getConnectedId()); + transaction.put("account", requestDTO.getBankAccount()); + transaction.put("startDate", requestDTO.getStartDate()); + transaction.put("endDate", requestDTO.getEndDate()); + transaction.put("orderBy", requestDTO.getOrderBy()); + transaction.put("inquiryType", "1"); + transaction.put("accountPassword", requestDTO.getBankEncryptPw()); + return transaction; + } + + // 응답을 CodefTransactionResDTO로 변환 + private CodefTransactionResDTO toTransactionResDTO(Map res) { + Map dataMap = (Map)res.get("data"); + if (dataMap == null) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "거래내역 응답 데이터가 없습니다."); + } + + CodefTransactionResDTO resDTO = new CodefTransactionResDTO(); + resDTO.setResAccountBalance((String)dataMap.get("resAccountBalance")); + resDTO.setResAccountName((String)dataMap.get("resAccountName")); + + List> historyList = (List>)dataMap.get("resTrHistoryList"); + if (historyList != null) { + List historyItems = historyList.stream() + .map(this::toHistoryItem) + .toList(); + resDTO.setResTrHistoryList(historyItems); + } + return resDTO; + } + + // Map을 HistoryItem으로 변환 + private CodefTransactionResDTO.HistoryItem toHistoryItem(Map itemMap) { + CodefTransactionResDTO.HistoryItem item = new CodefTransactionResDTO.HistoryItem(); + item.setResAccountTrDate((String)itemMap.get("resAccountTrDate")); + item.setResAccountTrTime((String)itemMap.get("resAccountTrTime")); + item.setResAccountOut((String)itemMap.get("resAccountOut")); + item.setResAccountIn((String)itemMap.get("resAccountIn")); + item.setResAccountDesc1((String)itemMap.get("resAccountDesc1")); + item.setResAccountDesc2((String)itemMap.get("resAccountDesc2")); + item.setResAccountDesc3((String)itemMap.get("resAccountDesc3")); + item.setResAccountDesc4((String)itemMap.get("resAccountDesc4")); + item.setResAfterTranBalance((String)itemMap.get("resAfterTranBalance")); + item.setTranDesc((String)itemMap.get("tranDesc")); + return item; + } + + // Codef API 공통 호출 메서드 + private Map postCodefApi(String apiURL, Map requestBody) { + HttpURLConnection con = null; + BufferedReader br = null; + + try { + URL url = new URL(apiURL); + con = (HttpURLConnection)url.openConnection(); + con.setRequestMethod("POST"); + con.setRequestProperty("Content-Type", "application/json"); + + String accessToken = accessTokenService.getValidAccessToken(); + if (accessToken == null) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL, "유효한 액세스 토큰을 가져올 수 없습니다."); + } + con.setRequestProperty("Authorization", "Bearer " + accessToken); + + con.setDoInput(true); + con.setDoOutput(true); + + // 요청 본문 전송 + String jsonBody = objectMapper.writeValueAsString(requestBody); + + try (OutputStream os = con.getOutputStream()) { + os.write(jsonBody.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + + // 응답 + int resCode = con.getResponseCode(); + if (resCode == HttpURLConnection.HTTP_OK) { + br = new BufferedReader(new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)); + } else if (resCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + throw new BusinessException(ErrorCode.CODEF_AUTH_FAIL); + } else { + br = new BufferedReader(new InputStreamReader(con.getErrorStream(), StandardCharsets.UTF_8)); + String errorResponse = readResponse(br); + throw new BusinessException(ErrorCode.CODEF_FAIL, "Codef API 요청이 실패했습니다."); + } + + String resString = readResponse(br); + + // URL 디코딩 + String decodedRes; + try { + decodedRes = URLDecoder.decode(resString, StandardCharsets.UTF_8); + } catch (Exception e) { + decodedRes = resString; + } + + return objectMapper.readValue(decodedRes, new TypeReference>() { + }); + } catch (ProtocolException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "프로토콜 오류가 발생했습니다: " + e.getMessage()); + } catch (MalformedURLException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "잘못된 URL입니다: " + e.getMessage()); + } catch (JsonParseException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "JSON 파싱 오류가 발생했습니다: " + e.getMessage()); + } catch (JsonMappingException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "JSON 매핑 오류가 발생했습니다: " + e.getMessage()); + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "JSON 처리 오류가 발생했습니다: " + e.getMessage()); + } catch (IOException e) { + throw new BusinessException(ErrorCode.CODEF_FAIL, "네트워크 I/O 오류가 발생했습니다: " + e.getMessage()); + } finally { + // 리소스 정리 + if (br != null) { + try { + br.close(); + } catch (IOException e) { + log.warn("BufferedReader 정리 실패: {}", e.getMessage()); + } + } + if (con != null) { + con.disconnect(); + } + } + } + + // 응답 읽기 헬퍼 메서드 + private String readResponse(BufferedReader br) throws IOException { + StringBuilder resStr = new StringBuilder(); + String inputLine; + while ((inputLine = br.readLine()) != null) { + resStr.append(inputLine); + } + return resStr.toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/common/codef/service/CodefSchedulerService.java b/src/main/java/org/bbagisix/common/codef/service/CodefSchedulerService.java new file mode 100644 index 00000000..76f00d8b --- /dev/null +++ b/src/main/java/org/bbagisix/common/codef/service/CodefSchedulerService.java @@ -0,0 +1,202 @@ +package org.bbagisix.common.codef.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.dto.AssetDTO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.asset.service.AssetService; +import org.bbagisix.classify.service.ClassifyService; +import org.bbagisix.common.codef.dto.CodefTransactionResDTO; +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.mapper.ExpenseMapper; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@Log4j2 +@RequiredArgsConstructor +public class CodefSchedulerService { + + private final AssetMapper assetMapper; + + private final ExpenseMapper expenseMapper; + + private final CodefApiService codefApiService; + + private static final Long TBC = 14L; // 카테고리 id : TBC 미지정 + private static final Long INCOME = 13L; // 카테고리 id : 수입 + private final AssetService assetService; + private final ClassifyService classifyService; + + // 10분마다 실행 (cron: 초 분 시 일 월 요일) + // @Scheduled(cron = "0 */10 * * * *") + // 자정(00:00)에 한번 실행 + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void syncAllMainAssetsTransactions() { + LocalDateTime now = LocalDateTime.now(); + + log.info("✅ Scheduler start" + now); + // 모든 main 계좌 조회 + List mainAssets = assetMapper.selectAllMainAssets(); + + if (mainAssets.isEmpty()) { + log.info("동기화할 main 계좌가 없습니다."); + return; + } + + int successCount = 0; + int failCount = 0; + + // 각 계좌별로 거래내역 동기화 + for (AssetVO asset : mainAssets) { + try { + syncAssetTransactions(asset); + successCount++; + } catch (Exception e) { + failCount++; + } + } + log.info("Scheduler finish - success: {}, fail: {}", successCount, failCount); + } + + // 개별 사용자 거래내역 동기화 (새로고침용) + @Transactional + public void syncUserTransactions(Long userId) { + AssetVO mainAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "main"); + if (mainAsset != null) { + log.info("User {} transaction sync start", userId); + syncAssetTransactions(mainAsset); + log.info("User {} transaction sync completed", userId); + } else { + log.warn("User {} has no main asset", userId); + } + } + + // 단일 계좌의 거래내역 동기화 + private void syncAssetTransactions(AssetVO asset) { + // AssetDTO 생성 + AssetDTO assetDTO = createAssetDTO(asset); + + // 조회 기간 설정 (어제~오늘) + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + + String todayStr = today.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yesterdayStr = yesterday.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // 거래내역 조회 + log.info(" 👉 [ user ID : {} ] Codef API start...", asset.getUserId()); + CodefTransactionResDTO transactionResDTO = codefApiService.getTransactionList(assetDTO, asset.getConnectedId(), + yesterdayStr, todayStr, false); + + if (transactionResDTO == null || transactionResDTO.getResTrHistoryList() == null) { + log.warn("API 응답이 null입니다"); + return; + } + // 계좌 잔액 업데이트 + updateAssetBalance(asset, transactionResDTO); + log.info(" 👉 [ user ID : {} ] update new balance... ", asset.getUserId()); + + // 새로운 거래내역만 필터링하여 저장 + List newTransactions = filterNewTransactions(asset, transactionResDTO); + + if (!newTransactions.isEmpty()) { + newTransactions = classifyService.classify(newTransactions); + int insertedCount = expenseMapper.insertExpenses(newTransactions); + log.info(" 👉 [ user ID : {} ] update new transactions... : {} ", asset.getUserId(), newTransactions.size()); + } + } + + // AssetVO를 AssetDTO로 변환 + private AssetDTO createAssetDTO(AssetVO asset) { + AssetDTO dto = new AssetDTO(); + dto.setBankName(asset.getBankName()); + dto.setBankId(asset.getBankId()); + dto.setBankpw(asset.getBankPw()); + dto.setBankAccount(asset.getBankAccount()); + return dto; + } + + // 계좌 잔액 업데이트 + private void updateAssetBalance(AssetVO asset, CodefTransactionResDTO transactionResDTO) { + if (transactionResDTO.getResAccountBalance() != null) { + Long newBalance = assetService.amountToLong(transactionResDTO.getResAccountBalance()); + if (!newBalance.equals(asset.getBalance())) { + assetMapper.updateAssetBalance(asset.getAssetId(), newBalance); + } + } + + } + + // 중복되지 않은 새로운 거래내역만 필터링 + private List filterNewTransactions(AssetVO asset, CodefTransactionResDTO transactionResDTO) { + List newTransactions = new ArrayList<>(); + + for (CodefTransactionResDTO.HistoryItem item : transactionResDTO.getResTrHistoryList()) { + List expenseVOList = assetService.toExpenseVOList(asset.getAssetId(), asset.getUserId(), + transactionResDTO); + + for (ExpenseVO expenseVO : expenseVOList) { + if (!isDuplicateTransaction(expenseVO) && !isUserModifiedTransaction(expenseVO)) { + newTransactions.add(expenseVO); + } + } + break; // 전체 리스트 한번에 처리 + } + return newTransactions; + } + + // 중복 거래 내역 체크 + private boolean isDuplicateTransaction(ExpenseVO expenseVO) { + try { + // 1차: codef_transaction_id 기반 중복 체크 (정확함) + if (expenseVO.getCodefTransactionId() != null) { + int codefCount = expenseMapper.countByCodefTransactionId(expenseVO.getCodefTransactionId()); + if (codefCount > 0) { + return true; + } + } + + // 2차: 기존 방식 fallback (codef_transaction_id가 없는 경우) + int count = assetMapper.countDuplicateTransaction( + expenseVO.getUserId(), + expenseVO.getAssetId(), + expenseVO.getAmount(), + expenseVO.getDescription(), + expenseVO.getExpenditureDate() + ); + return count > 0; + } catch (Exception err) { + return false; // 오류 시 중복이 아닌 것으로 간주하여 저장 + } + } + + // 사용자 수정 거래 체크 (user_modified=true인 거래 제외) + private boolean isUserModifiedTransaction(ExpenseVO expenseVO) { + try { + // codef_transaction_id가 있는 경우, 해당 거래가 사용자에 의해 수정되었는지 확인 + if (expenseVO.getCodefTransactionId() != null) { + List existingTransactions = expenseMapper.findByCodefTransactionId( + expenseVO.getCodefTransactionId()); + for (ExpenseVO existing : existingTransactions) { + if (Boolean.TRUE.equals(existing.getUserModified())) { + return true; // 사용자가 수정한 거래이므로 제외 + } + } + } + return false; + } catch (Exception err) { + return false; // 오류 시 제외하지 않음 + } + } +} diff --git a/src/main/java/org/bbagisix/common/config/PasswordEncoderConfig.java b/src/main/java/org/bbagisix/common/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..d3932688 --- /dev/null +++ b/src/main/java/org/bbagisix/common/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package org.bbagisix.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/org/bbagisix/common/config/RedisConfig.java b/src/main/java/org/bbagisix/common/config/RedisConfig.java new file mode 100644 index 00000000..f7e9f605 --- /dev/null +++ b/src/main/java/org/bbagisix/common/config/RedisConfig.java @@ -0,0 +1,63 @@ +package org.bbagisix.common.config; + +import org.bbagisix.chat.service.ChatMessageSubscriber; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisConnectionFactory redisConnectionFactory; + + @Bean(name = "chatRedisTemplate") + @Primary + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory); + + // Jackson2JsonRedisSerializer 설정 + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>( + Object.class); + + // ObjectMapper 설정 (JSR310 모듈 등록) + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + serializer.setObjectMapper(objectMapper); + + template.setDefaultSerializer(serializer); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + template.setHashKeySerializer(serializer); + + return template; + } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + ChatMessageSubscriber chatMessageSubscriber) { // 의존성 주입 추가 + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + + container.addMessageListener(chatMessageSubscriber, + new PatternTopic("chat:channel:*")); + + return container; + } +} diff --git a/src/main/java/org/bbagisix/common/config/RootConfig.java b/src/main/java/org/bbagisix/common/config/RootConfig.java new file mode 100644 index 00000000..15a1da5a --- /dev/null +++ b/src/main/java/org/bbagisix/common/config/RootConfig.java @@ -0,0 +1,230 @@ +package org.bbagisix.common.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.client.RestTemplate; + +import javax.sql.DataSource; + +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Configuration +@PropertySource({"classpath:/application.properties"}) +@MapperScan(basePackages = {"org.bbagisix.**.mapper"}) +@ComponentScan( + basePackages = "org.bbagisix", + excludeFilters = { + @ComponentScan.Filter(type = org.springframework.context.annotation.FilterType.ANNOTATION, classes = { + Controller.class, ControllerAdvice.class}) + } +) +@EnableScheduling +public class RootConfig { + private static final Logger log = LogManager.getLogger(RootConfig.class); + + @Value("${jdbc.driver}") + String driver; + + // docker-compose.yml로부터 환경 변수 주입 + @Value("${DB_HOST:localhost}") + String dbHost; + @Value("${DB_PORT:3306}") + String dbPort; + @Value("${DB_NAME:dondothat}") + String dbName; + @Value("${DB_USER:root}") + String username; + @Value("${DB_PASSWORD:1234}") + String password; + + @Value("${REDIS_HOST:localhost}") + private String redisHost; + @Value("${REDIS_PORT:6379}") + private int redisPort; + + @Value("${SPRING_MAIL_HOST}") + private String mailHost; + @Value("${SPRING_MAIL_PORT}") + private int mailPort; + @Value("${SPRING_MAIL_USERNAME}") + private String mailUsername; + @Value("${SPRING_MAIL_PASSWORD}") + private String mailPassword; + + // OAuth 환경 변수 + @Value("${GOOGLE_CLIENT_ID:}") + private String googleClientId; + @Value("${GOOGLE_CLIENT_SECRET:}") + private String googleClientSecret; + @Value("${NAVER_CLIENT_ID:}") + private String naverClientId; + @Value("${NAVER_CLIENT_SECRET:}") + private String naverClientSecret; + @Value("${BASE_URL:}") + private String baseUrl; + @Value("${JWT_SECRET}") + private String jwtSecret; + + // CODEF 환경 변수 + @Value("${CODEF_CLIENT_ID:}") + private String codefClientId; + @Value("${CODEF_CLIENT_SECRET:}") + private String codefClientSecret; + @Value("${CODEF_PUBLIC_KEY:}") + private String codefPublicKey; + + // FSS API 환경 변수 + @Value("${FSS_API_KEY}") + private String fssApiKey; + @Value("${FSS_API_URL}") + private String fssApiUrl; + + @Bean + public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public StringRedisTemplate stringRedisTemplate() { + StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); + stringRedisTemplate.setConnectionFactory(redisConnectionFactory()); + return stringRedisTemplate; + } + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailHost); + mailSender.setPort(mailPort); + mailSender.setUsername(mailUsername); + mailSender.setPassword(mailPassword); + mailSender.setDefaultEncoding("UTF-8"); + mailSender.setJavaMailProperties(getMailProperties()); + return mailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.put("mail.smtp.auth", "true"); + properties.put("mail.smtp.starttls.enable", "true"); + return properties; + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + template.setDefaultSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + + @Bean + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + + // 환경 변수를 사용하여 JDBC URL 구성 (SSL 및 공개키 검색 옵션 추가) + String jdbcUrl = String.format("jdbc:log4jdbc:mysql://%s:%s/%s?useSSL=false&allowPublicKeyRetrieval=true", + dbHost, dbPort, dbName); + + config.setDriverClassName(driver); + config.setJdbcUrl(jdbcUrl); + config.setUsername(username); + config.setPassword(password); + + // MySQL 최적화 설정 + config.setMaximumPoolSize(10); + config.setMinimumIdle(5); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setLeakDetectionThreshold(60000); + + // MySQL 연결 검증 설정 + config.setConnectionTestQuery("SELECT 1"); + config.setValidationTimeout(3000); + + HikariDataSource dataSource = new HikariDataSource(config); + log.info("DB Connection: {}", maskDbUrl(jdbcUrl)); + return dataSource; + } + + private String maskDbUrl(String url) { + Pattern pattern = Pattern.compile("(?<=//)([^:/]+)"); + Matcher matcher = pattern.matcher(url); + if (matcher.find()) { + String host = matcher.group(1); + if (host.length() > 4 && !"localhost".equalsIgnoreCase(host)) { + String maskedHost = host.substring(0, 2) + "..." + host.substring(host.length() - 2); + return matcher.replaceFirst(maskedHost); + } + } + return url; + } + + @Autowired + ApplicationContext applicationContext; + + @Bean + public SqlSessionFactory sqlSessionFactory() throws Exception { + SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean(); + sqlSessionFactory.setConfigLocation(applicationContext.getResource("classpath:/mybatis-config.xml")); + sqlSessionFactory.setDataSource(dataSource()); + sqlSessionFactory.setMapperLocations(applicationContext.getResources("classpath:/mappers/**/*.xml")); + return (SqlSessionFactory)sqlSessionFactory.getObject(); + } + + @Bean + public DataSourceTransactionManager transactionManager() { + return new DataSourceTransactionManager(dataSource()); + } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + scheduler.setThreadNamePrefix("scheduler-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(20); + scheduler.initialize(); + return scheduler; + } +} diff --git a/src/main/java/org/bbagisix/common/config/SecurityConfig.java b/src/main/java/org/bbagisix/common/config/SecurityConfig.java new file mode 100644 index 00000000..9df3ac29 --- /dev/null +++ b/src/main/java/org/bbagisix/common/config/SecurityConfig.java @@ -0,0 +1,183 @@ +package org.bbagisix.common.config; + +import java.util.Arrays; + +import org.bbagisix.user.filter.JWTFilter; +import org.bbagisix.user.handler.CustomOAuth2SuccessHandler; +import org.bbagisix.user.service.CustomOAuth2UserService; +import org.bbagisix.user.util.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.env.Environment; +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.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.ForwardedHeaderFilter; +import javax.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@PropertySource("classpath:application.properties") +@RequiredArgsConstructor +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + private final CustomOAuth2UserService customOAuth2UserService; + private final Environment environment; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; + private final JwtUtil jwtUtil; + + @Override + protected void configure(HttpSecurity http) throws Exception { + + // CORS 설정 적용 + http.cors(); + + // csrf disable + http.csrf().disable(); + + // Form 로그인 방식 disable + http.formLogin().disable(); + + // HTTP basic 인증 방식 disable + http.httpBasic().disable(); + + // 세션 설정 - JWT 기반이므로 STATELESS + http.sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + http.sessionManagement(management -> + management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // 경로별 인가 작업 + http.authorizeRequests() + // 정적 리소스 허용 + .antMatchers("/", "/error", "/resources/**", "/static/**", "/css/**", "/js/**", "/images/**").permitAll() + // OAuth2 관련 경로 허용 + .antMatchers("/oauth2-login", "/oauth2-success", "/oauth2/**", "/login/oauth2/**").permitAll() + // API 회원가입/로그인 경로 허용 + .antMatchers("/api/user/signup", "/api/user/login", "/api/user/send-verification", + "/api/user/check-email", "/api/user/check-nickname").permitAll() + // 디버그 경로 허용 (개발용) + .antMatchers("/debug/**").permitAll() + // 나머지는 인증 필요 (닉네임 변경, /me 등) + .anyRequest().authenticated(); + + http.exceptionHandling() + .authenticationEntryPoint((request, response, authException) -> { + String requestURI = request.getRequestURI(); + System.out.println("Authentication failed for: " + requestURI); + + if (requestURI.startsWith("/api/")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + // /api/user/me 요청에 대해서는 명확한 에러 메시지 반환 + if (requestURI.equals("/api/user/me")) { + response.getWriter().write("{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"JWT 토큰이 필요합니다. 로그인해주세요.\",\"code\":\"NO_TOKEN\"}"); + } else { + response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"인증이 필요합니다.\"}"); + } + } else { + response.sendRedirect("/login"); + } + }); + + http.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + // OAuth2 로그인 설정 + http.oauth2Login() + .clientRegistrationRepository(clientRegistrationRepository(environment)) + .userInfoEndpoint() + .userService(customOAuth2UserService) + .and() + .successHandler(customOAuth2SuccessHandler) + .failureUrl("/oauth2-login?error"); + } + + @Bean + public static ClientRegistrationRepository clientRegistrationRepository(Environment environment) { + return new InMemoryClientRegistrationRepository( + googleClientRegistration(environment), + naverClientRegistration(environment) + ); + } + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:5173", + "https://dondothat.netlify.app", + "https://54.208.50.238", + "https://dondothat.store", + "https://www.dondothat.store" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public OAuth2AuthorizedClientService authorizedClientService( + ClientRegistrationRepository clientRegistrationRepository) { + return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository); + } + + private static ClientRegistration googleClientRegistration(Environment environment) { + return ClientRegistration.withRegistrationId("google") + .clientId(environment.getProperty("GOOGLE_CLIENT_ID")) + .clientSecret(environment.getProperty("GOOGLE_CLIENT_SECRET")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("profile", "email") + .authorizationUri("https://accounts.google.com/o/oauth2/auth") + .tokenUri("https://oauth2.googleapis.com/token") + .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo") + .userNameAttributeName("email") + .clientName("Google") + .build(); + } + + private static ClientRegistration naverClientRegistration(Environment environment) { + return ClientRegistration.withRegistrationId("naver") + .clientId(environment.getProperty("NAVER_CLIENT_ID")) + .clientSecret(environment.getProperty("NAVER_CLIENT_SECRET")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("name", "email") + .authorizationUri("https://nid.naver.com/oauth2.0/authorize") + .tokenUri("https://nid.naver.com/oauth2.0/token") + .userInfoUri("https://openapi.naver.com/v1/nid/me") + .userNameAttributeName("response") + .clientName("Naver") + .build(); + } +} diff --git a/src/main/java/org/bbagisix/config/ServletConfig.java b/src/main/java/org/bbagisix/common/config/ServletConfig.java similarity index 95% rename from src/main/java/org/bbagisix/config/ServletConfig.java rename to src/main/java/org/bbagisix/common/config/ServletConfig.java index 62146850..36d4909f 100644 --- a/src/main/java/org/bbagisix/config/ServletConfig.java +++ b/src/main/java/org/bbagisix/common/config/ServletConfig.java @@ -1,4 +1,4 @@ -package org.bbagisix.config; +package org.bbagisix.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.support.StandardServletMultipartResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; @@ -48,4 +49,5 @@ public MultipartResolver multipartResolver() { StandardServletMultipartResolver resolver = new StandardServletMultipartResolver(); return resolver; } + } diff --git a/src/main/java/org/bbagisix/config/WebConfig.java b/src/main/java/org/bbagisix/common/config/WebConfig.java similarity index 83% rename from src/main/java/org/bbagisix/config/WebConfig.java rename to src/main/java/org/bbagisix/common/config/WebConfig.java index 2f2552cf..4743cf5e 100644 --- a/src/main/java/org/bbagisix/config/WebConfig.java +++ b/src/main/java/org/bbagisix/common/config/WebConfig.java @@ -1,5 +1,4 @@ -package org.bbagisix.config; - +package org.bbagisix.common.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.filter.CharacterEncodingFilter; @@ -19,11 +18,11 @@ public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitiali final String LOCATION = "c:/upload"; final long MAX_FILE_SIZE = 1024 * 1024 * 10L; final long MAX_REQUEST_SIZE = 1024 * 1024 * 20L; - final int FILE_SIZE_THRESHOLD = 1024 * 1024 * 5;; + final int FILE_SIZE_THRESHOLD = 1024 * 1024 * 5; @Override protected Class[] getRootConfigClasses() { - return new Class[] {RootConfig.class}; + return new Class[] {RootConfig.class, SecurityConfig.class}; } @Override @@ -40,11 +39,14 @@ protected String[] getServletMappings() { // POST body 문자 인코딩 필터 설정 - UTF-8 설정 protected Filter[] getServletFilters() { CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter(); - characterEncodingFilter.setEncoding("UTF-8"); characterEncodingFilter.setForceEncoding(true); - return new Filter[] {characterEncodingFilter}; + // Spring Security Filter + org.springframework.web.filter.DelegatingFilterProxy securityFilter = + new org.springframework.web.filter.DelegatingFilterProxy("springSecurityFilterChain"); + + return new Filter[] {characterEncodingFilter, securityFilter}; } @Override diff --git a/src/main/java/org/bbagisix/common/config/WebSocketConfig.java b/src/main/java/org/bbagisix/common/config/WebSocketConfig.java new file mode 100644 index 00000000..bd7b3a58 --- /dev/null +++ b/src/main/java/org/bbagisix/common/config/WebSocketConfig.java @@ -0,0 +1,73 @@ +package org.bbagisix.common.config; + +import org.bbagisix.chat.interceptior.WebSocketJwtInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * WebSocket 설정 - JWT 인증 및 CORS 강화 + */ +@Slf4j +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketJwtInterceptor webSocketJwtInterceptor; + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 메시지 브로커 설정 + config.enableSimpleBroker("/topic", "/queue"); // 구독 경로 + config.setApplicationDestinationPrefixes("/app"); // 메시지 전송 경로 + config.setUserDestinationPrefix("/user"); // 개인 메시지 경로 + + log.info("[WebSocketConfig] 메시지 브로커 설정 완료"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // STOMP 엔드포인트 등록 - 하나만 등록! + registry.addEndpoint("/api/ws/chat") + .setAllowedOriginPatterns( + "http://localhost:*", + "http://127.0.0.1:*", + "https://*.netlify.app", + "https://54.208.50.238" // 백엔드 IP도 추가 + ) + .withSockJS() + .setHeartbeatTime(25000) // 하트비트 주기 + .setDisconnectDelay(5000) // 연결 종료까지 지연 시간 + .setSessionCookieNeeded(false); // SockJS 세션 쿠키 비활성화 + + log.info("[WebSocketConfig] STOMP 엔드포인트 등록 완료: /api/ws/chat"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // 클라이언트 → 서버 메시지 채널: 인터셉터 추가로 인증 처리 + registration.interceptors(webSocketJwtInterceptor); + registration.taskExecutor().corePoolSize(4); + registration.taskExecutor().maxPoolSize(8); + registration.taskExecutor().keepAliveSeconds(60); + + log.info("[WebSocketConfig] WebSocket JWT 인터셉터 등록 완료"); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + // 서버에서 클라이언트로 가는 메시지 처리 + registration.taskExecutor().corePoolSize(4); + registration.taskExecutor().maxPoolSize(8); + registration.taskExecutor().keepAliveSeconds(60); + + log.info("[WebSocketConfig] 아웃바운드 채널 설정 완료"); + } +} diff --git a/src/main/java/org/bbagisix/controller/HomeController.java b/src/main/java/org/bbagisix/common/controller/HomeController.java similarity index 89% rename from src/main/java/org/bbagisix/controller/HomeController.java rename to src/main/java/org/bbagisix/common/controller/HomeController.java index 66fdb984..fb88f0cf 100644 --- a/src/main/java/org/bbagisix/controller/HomeController.java +++ b/src/main/java/org/bbagisix/common/controller/HomeController.java @@ -1,4 +1,4 @@ -package org.bbagisix.controller; +package org.bbagisix.common.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/org/bbagisix/common/exception/BusinessException.java b/src/main/java/org/bbagisix/common/exception/BusinessException.java new file mode 100644 index 00000000..abbae29e --- /dev/null +++ b/src/main/java/org/bbagisix/common/exception/BusinessException.java @@ -0,0 +1,41 @@ +package org.bbagisix.common.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String customMessage) { + super(customMessage); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String customMessage, Throwable cause) { + super(customMessage, cause); + this.errorCode = errorCode; + } + + public String getCode() { + return errorCode.getCode(); + } + + public String getErrorMessage() { + return errorCode.getMessage(); + } + + public org.springframework.http.HttpStatus getHttpStatus() { + return errorCode.getHttpStatus(); + } +} diff --git a/src/main/java/org/bbagisix/exception/CommonExceptionAdvice.java b/src/main/java/org/bbagisix/common/exception/CommonExceptionAdvice.java similarity index 74% rename from src/main/java/org/bbagisix/exception/CommonExceptionAdvice.java rename to src/main/java/org/bbagisix/common/exception/CommonExceptionAdvice.java index c82909cd..1e73d59a 100644 --- a/src/main/java/org/bbagisix/exception/CommonExceptionAdvice.java +++ b/src/main/java/org/bbagisix/common/exception/CommonExceptionAdvice.java @@ -1,4 +1,4 @@ -package org.bbagisix.exception; +package org.bbagisix.common.exception; import javax.servlet.http.HttpServletRequest; @@ -14,13 +14,7 @@ @Log4j2 @ControllerAdvice public class CommonExceptionAdvice { - @ExceptionHandler(Exception.class) - public String except(Exception ex, Model model) { - log.error("Exception ......." + ex.getMessage()); - model.addAttribute("exception", ex); - log.error(model); - return "error_page"; - } + @ExceptionHandler(NoHandlerFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public String handle404(NoHandlerFoundException ex, HttpServletRequest request, Model model) { @@ -28,4 +22,4 @@ public String handle404(NoHandlerFoundException ex, HttpServletRequest request, model.addAttribute("uri", request.getRequestURI()); return "custom404"; } -} +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/common/exception/ErrorCode.java b/src/main/java/org/bbagisix/common/exception/ErrorCode.java new file mode 100644 index 00000000..993a4eec --- /dev/null +++ b/src/main/java/org/bbagisix/common/exception/ErrorCode.java @@ -0,0 +1,99 @@ +package org.bbagisix.common.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // 메시지 관련 에러 + INVALID_MESSAGE(HttpStatus.BAD_REQUEST, "C001", "메시지가 유효하지 않습니다."), + MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "C002", "메시지가 너무 깁니다. (최대 255자)"), + MESSAGE_EMPTY(HttpStatus.BAD_REQUEST, "C003", "메시지를 입력해주세요"), + MESSAGE_CONTAINS_HTML(HttpStatus.BAD_REQUEST, "C004", "메시지에 HTML 태그를 포함할 수 없습니다."), + + // 사용자 관련 에러 + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), + USER_ID_REQUIRED(HttpStatus.BAD_REQUEST, "U002", "사용자 ID는 필수입니다."), + USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "U003", "권한이 없습니다."), + + // 회원가입 + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "E002", "이미 사용 중인 이메일입니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "E003", "인증코드가 올바르지 않습니다."), + VERIFICATION_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "E004", "인증코드가 만료되었습니다."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "이메일 발송에 실패했습니다."), + SOCIAL_NOT_FOUND(HttpStatus.NOT_FOUND, "E005", "지원하지 않는 소셜 로그인입니다."), + + // 챌린지 관련 에러 + CHALLENGE_NOT_FOUND(HttpStatus.NOT_FOUND, "CH001", "챌린지를 찾을 수 없습니다."), + CHALLENGE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "CH002", "챌린지 ID는 필수입니다."), + CHALLENGE_ENDED(HttpStatus.BAD_REQUEST, "CH003", "종료된 챌린지입니다."), + CHALLENGE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "CH004", "해당 챌린지에 참여할 권한이 없습니다."), + ALREADY_JOINED_CHALLENGE(HttpStatus.CONFLICT, "CH005", "이미 챌린지에 참여중입니다."), + CHALLENGE_NOT_JOINED(HttpStatus.BAD_REQUEST, "CH006", "사용자 정보와 일치하는 챌린지가 없습니다"), + CHALLENGE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CH007", "챌린지 업데이트에 실패했습니다"), + + // 데이터베이스 관련 에러 + DATA_ACCESS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "D001", "일시적인 오류가 발생했습니다."), + MESSAGE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "D002", "메시지 저장에 실패했습니다."), + MESSAGE_LOAD_FAILED(HttpStatus.INSUFFICIENT_STORAGE, "D003", "메시지를 불러올 수 없습니다."), + + // WebSocket 관련 에러 + WEBSOCKET_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W001", "채팅 연결에 실패했습니다."), + WEBSOCKET_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "W002", "메시지 전송에 실패했습니다."), + SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "W003", "세션이 만료되었습니다. 다시 연결해주세요."), + + // 소비내역 관련 에러 + EXPENSE_NOT_FOUND(HttpStatus.NOT_FOUND, "EX001", "소비내역을 찾을 수 없습니다."), + EXPENSE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "EX002", "소비내역에 대한 접근 권한이 없습니다."), + EXPENSE_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EX003", "소비내역 생성에 실패했습니다."), + EXPENSE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EX004", "소비내역 수정에 실패했습니다."), + EXPENSE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EX005", "소비내역 삭제에 실패했습니다."), + EXPENSE_SUMMARY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EX006", "지출 집계 조회에 실패했습니다."), + + // LLM 관련 에러 + LLM_CLASSIFY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "L001", "카테고리 분류에 실패했습니다."), + LLM_ANALYTICS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "L002", "과소비 카테고리 분석에 실패했습니다."), + + // 자산/계좌 관련 에러 + ASSET_FAIL(HttpStatus.BAD_REQUEST, "A001", "계좌 처리 중 오류가 발생했습니다."), + ASSET_NOT_FOUND(HttpStatus.NOT_FOUND, "A002", "계좌를 찾을 수 없습니다."), + ASSET_ALREADY_EXISTS(HttpStatus.CONFLICT, "A003", "이미 존재하는 계좌입니다."), + + // 거래내역 관련 에러 + TRANSACTION_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "T001", "거래내역 처리 중 오류가 발생했습니다."), + TRANSACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "T002", "거래내역을 찾을 수 없습니다."), + + // Codef API 관련 에러 + CODEF_FAIL(HttpStatus.SERVICE_UNAVAILABLE, "CF001", "Codef API 처리 중 오류가 발생했습니다."), + CODEF_AUTH_FAIL(HttpStatus.UNAUTHORIZED, "CF002", "Codef API 인증에 실패했습니다."), + + // 암호화 관련 에러 + ENCRYPTION_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "암호화 처리 중 오류가 발생했습니다."), + + // 금융상품 관련 에러 + FSS_API_CALL_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "FP001", "금감원 API 호출에 실패했습니다."), + FSS_API_RESPONSE_ERROR(HttpStatus.BAD_GATEWAY, "FP002", "금감원 API 응답 처리 중 오류가 발생했습니다."), + FINPRODUCT_DATA_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FP003", "금융상품 데이터 저장에 실패했습니다."), + FINPRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "FP004", "금융상품을 찾을 수 없습니다."), + + // Authentication 관련 에러 + AUTHENTICATION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, "AU01", "Authentication인증이 필요합니다."), + + // 저금통 관련 에러 + SAVING_UPDATE_DENIED(HttpStatus.FORBIDDEN, "S001", "해당 금액을 저금할 권한이 없습니다."), + SAVING_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S002", "저금통 계좌 잔액 업데이트에 실패했습니다."), + + // 일반적인 시스템 에러 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부 오류가 발생했습니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "S002", "잘못된 요청입니다."), + SERVICE_UNVALIABLE(HttpStatus.SERVICE_UNAVAILABLE, "S003", "서비스를 일시적으로 사용할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/org/bbagisix/common/exception/ErrorResponse.java b/src/main/java/org/bbagisix/common/exception/ErrorResponse.java new file mode 100644 index 00000000..0d940d58 --- /dev/null +++ b/src/main/java/org/bbagisix/common/exception/ErrorResponse.java @@ -0,0 +1,68 @@ +package org.bbagisix.common.exception; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ErrorResponse { + + private final String code; + private final String message; + private final LocalDateTime timestamp; + private final String path; // Web API 경로 (null 허용) + + // 일반 HTTP 에러 응답 + public static ErrorResponse of(ErrorCode errorCode) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + } + + public static ErrorResponse of(ErrorCode errorCode, String path) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .timestamp(LocalDateTime.now()) + .path(path) + .build(); + } + + // WebSocket 전용 에러 응답 + @Getter + @Builder + public static class WebSocketErrorResponse { + private final String type; // 고정값: "ERROR" + private final String code; // 예: "CH001" + private final String message; // 사용자에게 보여줄 메시지 + private final Long challengeId; // 관련 챌린지 ID + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private final LocalDateTime timestamp; + + public static WebSocketErrorResponse of(ErrorCode errorCode, Long challengeId) { + return WebSocketErrorResponse.builder() + .type("ERROR") + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .challengeId(challengeId) + .timestamp(LocalDateTime.now()) + .build(); + } + + public static WebSocketErrorResponse of(ErrorCode errorCode, Long challengeId, String customMessage) { + return WebSocketErrorResponse.builder() + .type("ERROR") + .code(errorCode.getCode()) + .message(customMessage) + .challengeId(challengeId) + .timestamp(LocalDateTime.now()) + .build(); + } + } +} diff --git a/src/main/java/org/bbagisix/common/exception/GlobalExceptionHandler.java b/src/main/java/org/bbagisix/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..0b876574 --- /dev/null +++ b/src/main/java/org/bbagisix/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,85 @@ +package org.bbagisix.common.exception; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.ConstraintViolationException; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final SimpMessagingTemplate messagingTemplate; + + // WebSocket용 BusinessException 처리 + @MessageExceptionHandler(BusinessException.class) + public void handleWsBusinessException(BusinessException e) { + log.warn("WebSocket BusinessException 발생: code={}, message={}", e.getCode(), e.getMessage()); + + ErrorResponse.WebSocketErrorResponse errorResponse = + ErrorResponse.WebSocketErrorResponse.of(e.getErrorCode(), extractChallengeId(e)); + + Long challengeId = extractChallengeId(e); + if (challengeId != null) { + messagingTemplate.convertAndSend("/topic/chat/" + challengeId, errorResponse); + } + } + + // WebSocket용 Validation 예외 처리 + @MessageExceptionHandler(ConstraintViolationException.class) + public void handleWsValidationException(ConstraintViolationException e) { + log.warn("WebSocket ConstraintViolationException 발생: {}", e.getMessage()); + + ErrorResponse.WebSocketErrorResponse errorResponse = + ErrorResponse.WebSocketErrorResponse.of(ErrorCode.INVALID_REQUEST, null); + } + + // WebSocket용 일반 예외 처리 + @MessageExceptionHandler(RuntimeException.class) + public void handleWsRuntimeException(RuntimeException e) { + log.error("WebSocket RuntimeException 발생: {}", e.getMessage(), e); + + ErrorResponse.WebSocketErrorResponse errorResponse = + ErrorResponse.WebSocketErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, null); + } + + // REST API용 BusinessException 처리 + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleRestBusinessException(BusinessException e, HttpServletRequest request) { + log.warn("REST BusinessException 발생: code={}, message={}", e.getCode(), e.getMessage()); + return ResponseEntity + .status(e.getHttpStatus()) + .body(ErrorResponse.of(e.getErrorCode(), request.getRequestURI())); + } + + // REST API용 Validation 예외 처리 + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(org.springframework.http.HttpStatus.BAD_REQUEST) + public ErrorResponse handleRestValidationException(ConstraintViolationException e, HttpServletRequest request) { + log.warn("REST ConstraintViolationException 발생: {}", e.getMessage()); + + return ErrorResponse.of(ErrorCode.INVALID_REQUEST, request.getRequestURI()); + } + + // REST API용 일반 예외 처리 + @ExceptionHandler(Exception.class) + @ResponseStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleRestGeneralException(Exception e, HttpServletRequest request) { + log.error("REST General Exception 발생: {}", e.getMessage(), e); + + return ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI()); + } + + // 확장용 challengeId 추출기 + private Long extractChallengeId(BusinessException e) { + // TODO: 향후 BusinessException에 challengeId 포함 시 여기에 처리 + return null; + } +} diff --git a/src/main/java/org/bbagisix/config/WebSocketConfig.java b/src/main/java/org/bbagisix/config/WebSocketConfig.java deleted file mode 100644 index 8b5409bd..00000000 --- a/src/main/java/org/bbagisix/config/WebSocketConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.bbagisix.config; - -import org.bbagisix.chat.handler.ChatWebSocketHandler; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - - private final ChatWebSocketHandler chatWebSocketHandler; - - public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler) { - this.chatWebSocketHandler = chatWebSocketHandler; - } - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(chatWebSocketHandler, "/ws/chat") - .setAllowedOrigins("*"); - } -} diff --git a/src/main/java/org/bbagisix/expense/controller/ExpenseController.java b/src/main/java/org/bbagisix/expense/controller/ExpenseController.java index df44c0f2..c9e0f5b6 100644 --- a/src/main/java/org/bbagisix/expense/controller/ExpenseController.java +++ b/src/main/java/org/bbagisix/expense/controller/ExpenseController.java @@ -1,4 +1,90 @@ package org.bbagisix.expense.controller; +import java.util.List; +import java.util.Map; + +import org.bbagisix.category.dto.CategoryDTO; +import org.bbagisix.category.service.CategoryService; +import org.bbagisix.expense.dto.ExpenseDTO; +import org.bbagisix.expense.service.ExpenseService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.bbagisix.user.dto.CustomOAuth2User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("/api/expenses") +@RequiredArgsConstructor +@Log4j2 public class ExpenseController { + private final ExpenseService expenseService; + private final CategoryService categoryService; + + @GetMapping + public ResponseEntity> getAllExpenses(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + List expenses = expenseService.getExpensesByUserId(currentUser.getUserId()); + return ResponseEntity.ok(expenses); + } + + @PostMapping + public ResponseEntity createExpense(@RequestBody ExpenseDTO expenseDTO, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + expenseDTO.setUserId(currentUser.getUserId()); + ExpenseDTO createdExpense = expenseService.createExpense(expenseDTO); + return new ResponseEntity<>(createdExpense, HttpStatus.CREATED); + } + + @GetMapping("/{expenditureId}") + public ResponseEntity getExpenseById(@PathVariable Long expenditureId, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + ExpenseDTO expense = expenseService.getExpenseById(expenditureId, currentUser.getUserId()); + return ResponseEntity.ok(expense); + } + + @PutMapping("/{expenditureId}") + public ResponseEntity updateExpense(@PathVariable Long expenditureId, + @RequestBody ExpenseDTO expenseDTO, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + ExpenseDTO updatedExpense = expenseService.updateExpense(expenditureId, expenseDTO, currentUser.getUserId()); + return ResponseEntity.ok(updatedExpense); + } + + @DeleteMapping("/{expenditureId}") + public ResponseEntity deleteExpense(@PathVariable Long expenditureId, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + expenseService.deleteExpense(expenditureId, currentUser.getUserId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/categories") + public ResponseEntity> getAllCategories() { + return ResponseEntity.ok(categoryService.getAllCategories()); + } + + @PostMapping("/refresh") + public ResponseEntity refreshExpensesFromCodef(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + expenseService.refreshFromCodef(currentUser.getUserId()); + return ResponseEntity.ok("거래내역 새로고침이 완료되었습니다."); + } + + @GetMapping("/current-month-summary") + public ResponseEntity> getCurrentMonthSummary(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User) authentication.getPrincipal(); + Map summary = expenseService.getCurrentMonthSummary(currentUser.getUserId()); + return ResponseEntity.ok(summary); + } } diff --git a/src/main/java/org/bbagisix/expense/domain/ExpenseVO.java b/src/main/java/org/bbagisix/expense/domain/ExpenseVO.java index d7973ce3..56206f79 100644 --- a/src/main/java/org/bbagisix/expense/domain/ExpenseVO.java +++ b/src/main/java/org/bbagisix/expense/domain/ExpenseVO.java @@ -1,4 +1,35 @@ package org.bbagisix.expense.domain; +import java.util.Date; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) public class ExpenseVO { + private Long expenditureId; + private Long userId; + private Long categoryId; + private Long assetId; + private Long amount; + private String description; + private Date expenditureDate; + private Date createdAt; + private Date updatedAt; + private Boolean userModified; + private String codefTransactionId; + private Date deletedAt; } diff --git a/src/main/java/org/bbagisix/expense/dto/ExpenseDTO.java b/src/main/java/org/bbagisix/expense/dto/ExpenseDTO.java index 9372debf..0bd3454f 100644 --- a/src/main/java/org/bbagisix/expense/dto/ExpenseDTO.java +++ b/src/main/java/org/bbagisix/expense/dto/ExpenseDTO.java @@ -1,4 +1,39 @@ package org.bbagisix.expense.dto; +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor public class ExpenseDTO { + // 응답 필드 (서버가 클라이언트에게 응답을 보낼떄만 사용) + private Long expenditureId; + private String categoryName; + private String categoryIcon; + private String assetName; + private String bankName; + private Date createdAt; + private Date updatedAt; + private Boolean userModified; + + // 요청/응답 공통 필드 + private Long userId; + private Long categoryId; + private Long assetId; + private Long amount; + private String description; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + private Date expenditureDate; } diff --git a/src/main/java/org/bbagisix/expense/mapper/ExpenseMapper.java b/src/main/java/org/bbagisix/expense/mapper/ExpenseMapper.java index 673f44ab..a0bf0b7c 100644 --- a/src/main/java/org/bbagisix/expense/mapper/ExpenseMapper.java +++ b/src/main/java/org/bbagisix/expense/mapper/ExpenseMapper.java @@ -1,4 +1,43 @@ package org.bbagisix.expense.mapper; +import java.util.List; +import java.util.Map; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.dto.ExpenseDTO; + +@Mapper public interface ExpenseMapper { + + void insert(ExpenseVO expense); + + ExpenseVO findById(Long expenditureId); + + List findAllByUserIdWithDetails(Long userId); + + int update(ExpenseVO expense); + + List getRecentExpenses(Long userId); + + List getTodayExpenseCategories(Long userId); + + Long getSumOfPeriodExpenses(@Param("userId") Long userId, + @Param("categoryId") Long categoryId, + @Param("period") Long period); + + int insertExpenses(List expenses); + + // codef 거래 ID로 중복 개수 확인 + int countByCodefTransactionId(String codefTransactionId); + + // codef 거래 ID로 실제 거래 데이터 조회 + List findByCodefTransactionId(String codefTransactionId); + + // 내역을 물리적 삭제 대신 소프트 삭제 + int softDelete(Long expenditureId, Long userId); + + // 현재월 카테고리별 지출 집계 + List> getCurrentMonthSummaryByCategory(Long userId); } diff --git a/src/main/java/org/bbagisix/expense/service/ExpenseService.java b/src/main/java/org/bbagisix/expense/service/ExpenseService.java index 5fd5a2c0..505c32c0 100644 --- a/src/main/java/org/bbagisix/expense/service/ExpenseService.java +++ b/src/main/java/org/bbagisix/expense/service/ExpenseService.java @@ -1,4 +1,31 @@ package org.bbagisix.expense.service; -public class ExpenseService { +import java.util.List; +import java.util.Map; + +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.dto.ExpenseDTO; + +public interface ExpenseService { + ExpenseDTO createExpense(ExpenseDTO expenseDTO); + + ExpenseDTO getExpenseById(Long expenditureId, Long userId); + + List getExpensesByUserId(Long userId); + + ExpenseDTO updateExpense(Long expenditureId, ExpenseDTO expenseDTO, Long userId); + + void deleteExpense(Long expenditureId, Long userId); + + List getRecentExpenses(Long userId); + + // 시스템 내부 호출용 메서드 (권한 검증 없음) + ExpenseDTO getExpenseByIdInternal(Long expenditureId); + ExpenseDTO updateExpenseInternal(Long expenditureId, ExpenseDTO expenseDTO); + + // Codef 동기화 + void refreshFromCodef(Long userId); + + // 현재월 카테고리별 지출 집계 + Map getCurrentMonthSummary(Long userId); } diff --git a/src/main/java/org/bbagisix/expense/service/ExpenseServiceImpl.java b/src/main/java/org/bbagisix/expense/service/ExpenseServiceImpl.java new file mode 100644 index 00000000..ab598ccf --- /dev/null +++ b/src/main/java/org/bbagisix/expense/service/ExpenseServiceImpl.java @@ -0,0 +1,289 @@ +package org.bbagisix.expense.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.category.domain.CategoryVO; +import org.bbagisix.category.mapper.CategoryMapper; +import org.bbagisix.common.codef.service.CodefSchedulerService; +import org.bbagisix.expense.domain.ExpenseVO; +import org.bbagisix.expense.dto.ExpenseDTO; +import org.bbagisix.expense.mapper.ExpenseMapper; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class ExpenseServiceImpl implements ExpenseService { + private final ExpenseMapper expenseMapper; + private final CategoryMapper categoryMapper; + private final AssetMapper assetMapper; + private final CodefSchedulerService codefSchedulerService; + + @Override + public ExpenseDTO createExpense(ExpenseDTO expenseDTO) { + try { + // 항상 사용자의 main 계좌로 설정 + AssetVO mainAsset = assetMapper.selectAssetByUserIdAndStatus(expenseDTO.getUserId(), "main"); + if (mainAsset == null) { + throw new BusinessException(ErrorCode.ASSET_NOT_FOUND, "main 계좌가 연결되지 않았습니다."); + } + expenseDTO.setAssetId(mainAsset.getAssetId()); + + ExpenseVO vo = dtoToVo(expenseDTO); + // 사용자 직접 생성시 true 설정 + vo.setUserModified(true); + expenseMapper.insert(vo); + if (vo.getExpenditureId() == null) { + throw new BusinessException(ErrorCode.EXPENSE_CREATE_FAILED); + } + return voToDto(vo); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 생성 중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_CREATE_FAILED, e); + } + } + + @Override + public ExpenseDTO getExpenseById(Long expenditureId, Long userId) { + try { + ExpenseVO vo = expenseMapper.findById(expenditureId); + if (vo == null) { + throw new BusinessException(ErrorCode.EXPENSE_NOT_FOUND); + } + if (!vo.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.EXPENSE_ACCESS_DENIED); + } + return voToDto(vo); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 조회 중 예상치 못한 오류 발생: expenditureId={}, userId={}, error={}", expenditureId, userId, + e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + @Override + public List getExpensesByUserId(Long userId) { + try { + return expenseMapper.findAllByUserIdWithDetails(userId); + } catch (Exception e) { + log.error("사용자 소비내역 목록 조회 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + @Override + public ExpenseDTO updateExpense(Long expenditureId, ExpenseDTO expenseDTO, Long userId) { + try { + ExpenseVO vo = expenseMapper.findById(expenditureId); + if (vo == null) { + throw new BusinessException(ErrorCode.EXPENSE_NOT_FOUND); + } + if (!vo.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.EXPENSE_ACCESS_DENIED); + } + + // 항상 사용자의 main 계좌로 설정 + AssetVO mainAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "main"); + if (mainAsset == null) { + throw new BusinessException(ErrorCode.ASSET_NOT_FOUND, "main 계좌가 연결되지 않았습니다."); + } + + vo.setCategoryId(expenseDTO.getCategoryId()); + vo.setAssetId(mainAsset.getAssetId()); + vo.setAmount(expenseDTO.getAmount()); + vo.setDescription(expenseDTO.getDescription()); + vo.setExpenditureDate(expenseDTO.getExpenditureDate()); + // 수정 시 user_modified = true 자동 설정 + vo.setUserModified(true); + vo.setUserId(userId); // WHERE 조건을 위해 필요 + + int result = expenseMapper.update(vo); + if (result != 1) { + throw new BusinessException(ErrorCode.EXPENSE_UPDATE_FAILED, + "예상 업데이트 수: 1, 실제: " + result); + } + return voToDto(expenseMapper.findById(expenditureId)); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 수정 중 예상치 못한 오류 발생: expenditureId={}, userId={}, error={}", expenditureId, userId, + e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_UPDATE_FAILED, e); + } + } + + @Override + public void deleteExpense(Long expenditureId, Long userId) { + try { + ExpenseVO vo = expenseMapper.findById(expenditureId); + if (vo == null) { + throw new BusinessException(ErrorCode.EXPENSE_NOT_FOUND); + } + if (!vo.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.EXPENSE_ACCESS_DENIED); + } + // 삭제 시 softDelete() 메서드 사용 + int result = expenseMapper.softDelete(expenditureId, userId); + if (result != 1) { + throw new BusinessException(ErrorCode.EXPENSE_DELETE_FAILED, + "예상 삭제 수: 1, 실제: " + result); + } + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 삭제 중 예상치 못한 오류 발생: expenditureId={}, userId={}, error={}", expenditureId, userId, + e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_DELETE_FAILED, e); + } + } + + @Override + public List getRecentExpenses(Long userId) { + try { + return expenseMapper.getRecentExpenses(userId); + } catch (Exception e) { + log.error("최근 소비내역 조회 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + @Override + public ExpenseDTO getExpenseByIdInternal(Long expenditureId) { + try { + ExpenseVO vo = expenseMapper.findById(expenditureId); + if (vo == null) { + throw new BusinessException(ErrorCode.EXPENSE_NOT_FOUND); + } + return voToDto(vo); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 내부 조회 중 오류 발생: expenditureId={}, error={}", expenditureId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + @Override + public ExpenseDTO updateExpenseInternal(Long expenditureId, ExpenseDTO expenseDTO) { + try { + ExpenseVO vo = expenseMapper.findById(expenditureId); + if (vo == null) { + throw new BusinessException(ErrorCode.EXPENSE_NOT_FOUND); + } + + vo.setCategoryId(expenseDTO.getCategoryId()); + vo.setAssetId(expenseDTO.getAssetId()); + vo.setAmount(expenseDTO.getAmount()); + vo.setDescription(expenseDTO.getDescription()); + vo.setExpenditureDate(expenseDTO.getExpenditureDate()); + + int result = expenseMapper.update(vo); + if (result != 1) { + throw new BusinessException(ErrorCode.EXPENSE_UPDATE_FAILED, + "예상 업데이트 수: 1, 실제: " + result); + } + return voToDto(expenseMapper.findById(expenditureId)); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("소비내역 내부 업데이트 중 오류 발생: expenditureId={}, error={}", expenditureId, e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_UPDATE_FAILED, e); + } + } + + private ExpenseVO dtoToVo(ExpenseDTO dto) { + if (dto == null) + return null; + return ExpenseVO.builder() + .userId(dto.getUserId()) + .categoryId(dto.getCategoryId()) + .assetId(dto.getAssetId()) + .amount(dto.getAmount()) + .description(dto.getDescription()) + .expenditureDate(dto.getExpenditureDate()) + .build(); + } + + private ExpenseDTO voToDto(ExpenseVO vo) { + if (vo == null) + return null; + + ExpenseDTO dto = ExpenseDTO.builder() + .expenditureId(vo.getExpenditureId()) + .userId(vo.getUserId()) + .categoryId(vo.getCategoryId()) + .assetId(vo.getAssetId()) + .amount(vo.getAmount()) + .description(vo.getDescription()) + .expenditureDate(vo.getExpenditureDate()) + .createdAt(vo.getCreatedAt()) + .updatedAt(vo.getUpdatedAt()) + .userModified(vo.getUserModified()) + .build(); + + if (vo.getCategoryId() != null) { + CategoryVO categoryVO = categoryMapper.findById(vo.getCategoryId()); + if (categoryVO != null) { + dto.setCategoryName(categoryVO.getName()); + } + } + + if (vo.getAssetId() != null) { + AssetVO assetVO = assetMapper.selectAssetById(vo.getAssetId()); + if (assetVO != null) { + dto.setAssetName(assetVO.getAssetName()); + dto.setBankName(assetVO.getBankName()); + } + } + return dto; + } + + @Override + public void refreshFromCodef(Long userId) { + try { + // CodefSchedulerService의 개별 사용자 동기화 로직 호출 + codefSchedulerService.syncUserTransactions(userId); + } catch (Exception e) { + log.error("Codef 동기화 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_SUMMARY_FAILED, "거래내역 새로고침에 실패했습니다."); + } + } + + @Override + public Map getCurrentMonthSummary(Long userId) { + try { + List> results = expenseMapper.getCurrentMonthSummaryByCategory(userId); + Map summary = new HashMap<>(); + + for (Map row : results) { + String categoryName = (String)row.get("key"); + Object amountObj = row.get("value"); + Long amount = 0L; + + if (amountObj instanceof Number) { + amount = ((Number)amountObj).longValue(); + } + + summary.put(categoryName, amount); + } + + return summary; + } catch (Exception e) { + log.error("현재월 지출 집계 조회 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.EXPENSE_SUMMARY_FAILED, "지출 집계 조회에 실패했습니다."); + } + } +} diff --git a/src/main/java/org/bbagisix/finproduct/controller/FinProductController.java b/src/main/java/org/bbagisix/finproduct/controller/FinProductController.java new file mode 100644 index 00000000..8db8dd69 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/controller/FinProductController.java @@ -0,0 +1,51 @@ +package org.bbagisix.finproduct.controller; + +import org.bbagisix.finproduct.dto.RecommendedSavingDTO; +import org.bbagisix.finproduct.service.FinProductService; +import org.bbagisix.finproduct.service.RecommendationService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.util.List; + +// 금융상품 API 컨트롤러 +// 금감원 데이터 동기화 및 맞춤형 적금상품 추천 기능 제공 +@RestController +@RequestMapping("/api/finproduct") +@RequiredArgsConstructor +@Log4j2 +public class FinProductController { + + private final FinProductService finProductService; + private final RecommendationService recommendationService; + + + // 금감원 적금상품 데이터 동기화 + // 스케줄러에서 호출하여 최신 데이터로 업데이트 + @PostMapping("/sync") + public ResponseEntity syncFinProductData() { + log.info("금융상품 데이터 동기화 API 호출"); + + finProductService.syncFinProductData(); + + log.info("금융상품 데이터 동기화 API 완료"); + return new ResponseEntity<>("금융상품 데이터 동기화가 완료되었습니다.", HttpStatus.OK); + } + + // 사용자 맞춤 적금상품 추천 조회 + // 하이브리드 방식: 백엔드 1차 필터링 + LLM 2차 지능형 추천 + @GetMapping("/recommend") + public ResponseEntity> getRecommendedSavings( + Authentication authentication, + @RequestParam(value = "limit", required = false) Integer limit) { + + List recommendations = recommendationService.getRecommendedSavings(authentication, limit); + + return ResponseEntity.ok(recommendations); + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/domain/SavingBaseVO.java b/src/main/java/org/bbagisix/finproduct/domain/SavingBaseVO.java new file mode 100644 index 00000000..acf23eb9 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/domain/SavingBaseVO.java @@ -0,0 +1,35 @@ +package org.bbagisix.finproduct.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.sql.Timestamp; + +@Getter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SavingBaseVO { + private Long savingBaseId; + private String dclsMonth; + private String finCoNo; + private String finPrdtCd; + private String korCoNm; + private String finPrdtNm; + private String joinWay; + private String mtrtInt; + private String spclCnd; + private String joinDeny; + private String joinMember; + private String etcNote; + private Integer maxLimit; + private String dclsStrtDay; + private String dclsEndDay; + private String finCoSubmDay; + private Timestamp createdAt; + private Timestamp updatedAt; +} diff --git a/src/main/java/org/bbagisix/finproduct/domain/SavingOptionVO.java b/src/main/java/org/bbagisix/finproduct/domain/SavingOptionVO.java new file mode 100644 index 00000000..68488c46 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/domain/SavingOptionVO.java @@ -0,0 +1,32 @@ +package org.bbagisix.finproduct.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; +import java.sql.Timestamp; + +@Getter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SavingOptionVO { + private Long savingOptionId; + private Long savingBaseId; // Foreign Key + private String dclsMonth; + private String finCoNo; + private String finPrdtCd; + private String intrRateType; + private String intrRateTypeNm; + private String rsrvType; + private String rsrvTypeNm; + private String saveTrm; + private BigDecimal intrRate; + private BigDecimal intrRate2; + private Timestamp createdAt; + private Timestamp updatedAt; +} diff --git a/src/main/java/org/bbagisix/finproduct/dto/FssApiResponseDTO.java b/src/main/java/org/bbagisix/finproduct/dto/FssApiResponseDTO.java new file mode 100644 index 00000000..235ee45f --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/FssApiResponseDTO.java @@ -0,0 +1,52 @@ +package org.bbagisix.finproduct.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FssApiResponseDTO { + + @JsonProperty("result") + private ResultDTO result; + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResultDTO { + @JsonProperty("prdt_div") + private String prdtDiv; + + @JsonProperty("total_count") + private int totalCount; + + @JsonProperty("max_page_no") + private int maxPageNo; + + @JsonProperty("now_page_no") + private int nowPageNo; + + @JsonProperty("err_cd") + private String errCd; + + @JsonProperty("err_msg") + private String errMsg; + + @JsonProperty("baseList") + private List baseList; + + @JsonProperty("optionList") + private List optionList; + } +} diff --git a/src/main/java/org/bbagisix/finproduct/dto/LlmSavingProductDTO.java b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingProductDTO.java new file mode 100644 index 00000000..3833cb01 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingProductDTO.java @@ -0,0 +1,23 @@ +package org.bbagisix.finproduct.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +// LLM 서버와 통신용 적금 상품 정보 (요청/응답 공용) +public class LlmSavingProductDTO { + private String finPrdtCd; + private String korCoNm; + private String finPrdtNm; + private String spclCnd; + private String joinMember; + private double intrRate; + private double intrRate2; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/dto/LlmSavingRequestDTO.java b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingRequestDTO.java new file mode 100644 index 00000000..1f18598e --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingRequestDTO.java @@ -0,0 +1,18 @@ +package org.bbagisix.finproduct.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@Builder +// LLM 서버로 보내는 추천 요청 데이터 (상품리스트 + 사용자정보) +public class LlmSavingRequestDTO { + private List savings; + private int userAge; + private String userJob; + private String mainBankName; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/dto/LlmSavingResponseDTO.java b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingResponseDTO.java new file mode 100644 index 00000000..a5a502ad --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/LlmSavingResponseDTO.java @@ -0,0 +1,13 @@ +package org.bbagisix.finproduct.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +// LLM 서버에서 받는 추천 응답 데이터 (추천된 상품리스트) +public class LlmSavingResponseDTO { + private List recommendations; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/dto/ProductBaseDTO.java b/src/main/java/org/bbagisix/finproduct/dto/ProductBaseDTO.java new file mode 100644 index 00000000..42ef9f46 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/ProductBaseDTO.java @@ -0,0 +1,61 @@ +package org.bbagisix.finproduct.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductBaseDTO { + + @JsonProperty("dcls_month") + private String dclsMonth; + + @JsonProperty("fin_co_no") + private String finCoNo; + + @JsonProperty("fin_prdt_cd") + private String finPrdtCd; + + @JsonProperty("kor_co_nm") + private String korCoNm; + + @JsonProperty("fin_prdt_nm") + private String finPrdtNm; + + @JsonProperty("join_way") + private String joinWay; + + @JsonProperty("mtrt_int") + private String mtrtInt; + + @JsonProperty("spcl_cnd") + private String spclCnd; + + @JsonProperty("join_deny") + private String joinDeny; + + @JsonProperty("join_member") + private String joinMember; + + @JsonProperty("etc_note") + private String etcNote; + + @JsonProperty("max_limit") + private Integer maxLimit; + + @JsonProperty("dcls_strt_day") + private String dclsStrtDay; + + @JsonProperty("dcls_end_day") + private String dclsEndDay; + + @JsonProperty("fin_co_subm_day") + private String finCoSubmDay; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/dto/ProductOptionDTO.java b/src/main/java/org/bbagisix/finproduct/dto/ProductOptionDTO.java new file mode 100644 index 00000000..7becc7f9 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/ProductOptionDTO.java @@ -0,0 +1,48 @@ +package org.bbagisix.finproduct.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductOptionDTO { + + @JsonProperty("dcls_month") + private String dclsMonth; + + @JsonProperty("fin_co_no") + private String finCoNo; + + @JsonProperty("fin_prdt_cd") + private String finPrdtCd; + + @JsonProperty("intr_rate_type") + private String intrRateType; + + @JsonProperty("intr_rate_type_nm") + private String intrRateTypeNm; + + @JsonProperty("rsrv_type") + private String rsrvType; + + @JsonProperty("rsrv_type_nm") + private String rsrvTypeNm; + + @JsonProperty("save_trm") + private String saveTrm; + + @JsonProperty("intr_rate") + private BigDecimal intrRate; + + @JsonProperty("intr_rate2") + private BigDecimal intrRate2; +} diff --git a/src/main/java/org/bbagisix/finproduct/dto/RecommendedSavingDTO.java b/src/main/java/org/bbagisix/finproduct/dto/RecommendedSavingDTO.java new file mode 100644 index 00000000..d7ad7bba --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/dto/RecommendedSavingDTO.java @@ -0,0 +1,34 @@ +package org.bbagisix.finproduct.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.math.BigDecimal; + +@Getter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +// API 응답용 적금 상품 정보 (사용자 정보는 내부 처리용) +public class RecommendedSavingDTO { + private String finPrdtCd; // 금융상품 코드 + private String korCoNm; // 금융회사 명 + private String finPrdtNm; // 금융상품 명 + private String spclCnd; // 우대 조건 + private String joinMember; // 가입 대상 + private BigDecimal intrRate; // 저축 금리 + private BigDecimal intrRate2; // 최고 우대금리 + + // 사용자 정보 (LLM 추천용, JSON 응답에서 제외) + @JsonIgnore + private Integer userAge; // 사용자 나이 + @JsonIgnore + private String userJob; // 사용자 직업 + @JsonIgnore + private String mainBankName; // 주거래은행 +} diff --git a/src/main/java/org/bbagisix/finproduct/mapper/FinProductMapper.java b/src/main/java/org/bbagisix/finproduct/mapper/FinProductMapper.java new file mode 100644 index 00000000..ef0ad11e --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/mapper/FinProductMapper.java @@ -0,0 +1,30 @@ +package org.bbagisix.finproduct.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.finproduct.domain.SavingBaseVO; +import org.bbagisix.finproduct.domain.SavingOptionVO; +import org.bbagisix.finproduct.dto.RecommendedSavingDTO; + +import java.util.List; + +@Mapper +public interface FinProductMapper { + + void insertOrUpdateBase(SavingBaseVO baseVO); + + void insertOrUpdateOption(SavingOptionVO optionVO); + + // 복합 키(fin_co_no, fin_prdt_cd, dcls_month)를 기준으로 saving_base 테이블의 PK를 조회 + // 옵션 정보를 saving_option 테이블에 저장할 때, 방금 알아낸 `saving_base_id` 값을 `saving_base_id` 컬럼에 넣음 + Long findSavingBaseId(SavingBaseVO baseVO); + + + // 추천 적금 상품 조회 (백엔드 필터링) + // 사용자 정보(나이, 직업, 주거래은행)를 기반으로 불가능한 상품 제외 + // LLM에 전달할 필터링된 적금 상품 목록 + List findRecommendedSavings( + @Param("userId") Long userId, + @Param("limit") Integer limit + ); +} diff --git a/src/main/java/org/bbagisix/finproduct/service/FinProductService.java b/src/main/java/org/bbagisix/finproduct/service/FinProductService.java new file mode 100644 index 00000000..4fd030ff --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/service/FinProductService.java @@ -0,0 +1,179 @@ +package org.bbagisix.finproduct.service; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.finproduct.domain.SavingBaseVO; +import org.bbagisix.finproduct.domain.SavingOptionVO; +import org.bbagisix.finproduct.dto.FssApiResponseDTO; +import org.bbagisix.finproduct.dto.ProductBaseDTO; +import org.bbagisix.finproduct.dto.ProductOptionDTO; +import org.bbagisix.finproduct.mapper.FinProductMapper; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +// 데이터 저장 서비스 +@Log4j2 +@Service +@RequiredArgsConstructor +public class FinProductService { + + private final FssApiService fssApiService; + private final FinProductMapper finProductMapper; + + // 금감원 API 데이터를 동기화하여 DB에 저장 + @Scheduled(cron = "0 0 4 ? * MON") // 매주 월요일 오전 4시에 실행 + @Transactional + public void syncFinProductData() { + try { + log.info("금융상품 데이터 동기화 시작"); + + // 1. 금감원 API에서 데이터 조회 + FssApiResponseDTO fssResponse = fssApiService.getSavingProductsFromFss(); + + // 2. 조회된 데이터를 DB에 저장 + saveFinProductData(fssResponse); + + log.info("금융상품 데이터 동기화 완료"); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("금융상품 데이터 동기화 중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.FINPRODUCT_DATA_SAVE_FAILED, e); + } + } + + // FSS API 응답 데이터를 DB에 저장 + @Transactional + public void saveFinProductData(FssApiResponseDTO fssResponse) { + try { + if (fssResponse == null || fssResponse.getResult() == null) { + throw new BusinessException(ErrorCode.FINPRODUCT_DATA_SAVE_FAILED, "저장할 데이터가 없습니다"); + } + + // 1. Base 데이터 저장 + int baseSavedCount = saveBaseData(fssResponse.getResult().getBaseList()); + + // 2. Option 데이터 저장 + int optionSavedCount = saveOptionData(fssResponse.getResult().getOptionList()); + + log.info("데이터 저장 완료 - Base: {}건, Option: {}건", baseSavedCount, optionSavedCount); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("FSS API 데이터 저장 중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.FINPRODUCT_DATA_SAVE_FAILED, e); + } + } + + // Base 데이터 저장 + private int saveBaseData(java.util.List baseList) { + if (baseList == null || baseList.isEmpty()) { + log.warn("저장할 Base 데이터가 없습니다"); + return 0; + } + + int savedCount = 0; + for (ProductBaseDTO baseDTO : baseList) { + try { + SavingBaseVO baseVO = convertToBaseVO(baseDTO); + finProductMapper.insertOrUpdateBase(baseVO); + savedCount++; + + } catch (Exception e) { + log.error("Base 데이터 저장 실패 - 상품코드: {}, 오류: {}", + baseDTO.getFinPrdtCd(), e.getMessage()); + // 개별 실패는 로그만 남기고 계속 진행 + } + } + + return savedCount; + } + + // Option 데이터 저장 + private int saveOptionData(java.util.List optionList) { + if (optionList == null || optionList.isEmpty()) { + log.warn("저장할 Option 데이터가 없습니다"); + return 0; + } + + int savedCount = 0; + for (ProductOptionDTO optionDTO : optionList) { + try { + // 1. 같은 상품코드의 Base 데이터의 PK 조회 + SavingBaseVO baseVO = SavingBaseVO.builder() + .finCoNo(optionDTO.getFinCoNo()) + .finPrdtCd(optionDTO.getFinPrdtCd()) // 같은 상품 코드 + .dclsMonth(optionDTO.getDclsMonth()) + .build(); + + Long savingBaseId = finProductMapper.findSavingBaseId(baseVO); + if (savingBaseId == null) { + log.warn("Option 저장 실패 - 연관된 Base 데이터를 찾을 수 없음: {}", optionDTO.getFinPrdtCd()); + continue; + } + + // 2. 각 Option을 개별 저장 (여러 번 호출) + SavingOptionVO optionVO = convertToOptionVO(optionDTO, savingBaseId); + finProductMapper.insertOrUpdateOption(optionVO); // 기간별로 여러 번 호출 + savedCount++; + + } catch (Exception e) { + log.error("Option 데이터 저장 실패 - 상품코드: {}, 오류: {}", + optionDTO.getFinPrdtCd(), e.getMessage()); + // 개별 실패는 로그만 남기고 계속 진행 + } + } + + return savedCount; + } + + // ProductBaseDTO를 SavingBaseVO로 변환 + private SavingBaseVO convertToBaseVO(ProductBaseDTO dto) { + if (dto == null) + return null; + + return SavingBaseVO.builder() + .dclsMonth(dto.getDclsMonth()) + .finCoNo(dto.getFinCoNo()) + .finPrdtCd(dto.getFinPrdtCd()) + .korCoNm(dto.getKorCoNm()) + .finPrdtNm(dto.getFinPrdtNm()) + .joinWay(dto.getJoinWay()) + .mtrtInt(dto.getMtrtInt()) + .spclCnd(dto.getSpclCnd()) + .joinDeny(dto.getJoinDeny()) + .joinMember(dto.getJoinMember()) + .etcNote(dto.getEtcNote()) + .maxLimit(dto.getMaxLimit()) + .dclsStrtDay(dto.getDclsStrtDay()) + .dclsEndDay(dto.getDclsEndDay()) + .finCoSubmDay(dto.getFinCoSubmDay()) + .build(); + } + + // ProductOptionDTO를 SavingOptionVO로 변환 + private SavingOptionVO convertToOptionVO(ProductOptionDTO dto, Long savingBaseId) { + if (dto == null) + return null; + + return SavingOptionVO.builder() + .savingBaseId(savingBaseId) + .dclsMonth(dto.getDclsMonth()) + .finCoNo(dto.getFinCoNo()) + .finPrdtCd(dto.getFinPrdtCd()) + .intrRateType(dto.getIntrRateType()) + .intrRateTypeNm(dto.getIntrRateTypeNm()) + .rsrvType(dto.getRsrvType()) + .rsrvTypeNm(dto.getRsrvTypeNm()) + .saveTrm(dto.getSaveTrm()) + .intrRate(dto.getIntrRate()) + .intrRate2(dto.getIntrRate2()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/service/FssApiService.java b/src/main/java/org/bbagisix/finproduct/service/FssApiService.java new file mode 100644 index 00000000..af273b54 --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/service/FssApiService.java @@ -0,0 +1,115 @@ +package org.bbagisix.finproduct.service; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.finproduct.dto.FssApiResponseDTO; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +// 금감원 API 호출 서비스 +@Log4j2 +@Service +@RequiredArgsConstructor +public class FssApiService { + + // 상수 정의 + private static final String BANK_SECTOR_CODE = "020000"; // 은행권 + private static final String SUCCESS_CODE = "000"; // 정상 응답 코드 + private static final int MAX_RETRY_COUNT = 3; // 최대 재시도 횟수 + private static final long RETRY_DELAY_MS = 1000L; // 재시도 간격 (1초) + + private final RestTemplate restTemplate; + + @Value("${FSS_API_KEY}") + private String apiKey; + + @Value("${FSS_API_URL}") + private String apiUrl; + + // 금감원 적금상품 API에서 은행권 데이터를 수집 + public FssApiResponseDTO getSavingProductsFromFss() { + try { + log.info("금감원 API 호출 시작 - 은행권 적금상품 데이터 수집"); + + FssApiResponseDTO response = callFssApi(); + if (response == null || !isSuccessResponse(response)) { + throw new BusinessException(ErrorCode.FSS_API_CALL_FAILED); + } + + int baseCount = response.getResult().getBaseList() != null ? response.getResult().getBaseList().size() : 0; + int optionCount = + response.getResult().getOptionList() != null ? response.getResult().getOptionList().size() : 0; + + log.info("금감원 API 호출 완료 - Base: {}건, Option: {}건", baseCount, optionCount); + + return response; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("금감원 API 호출 중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.FSS_API_RESPONSE_ERROR, e); + } + } + + // 금감원 API 호출 + private FssApiResponseDTO callFssApi() { + String url = buildApiUrl(); + + for (int attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) { + try { + log.debug("API 호출 시도 {}/{}", attempt, MAX_RETRY_COUNT); + + FssApiResponseDTO response = restTemplate.getForObject(url, FssApiResponseDTO.class); + + if (response != null && isSuccessResponse(response)) { + log.debug("API 호출 성공"); + return response; + } else if (response != null) { + log.warn("API 응답 에러 - 코드: {}, 메시지: {}", + response.getResult().getErrCd(), response.getResult().getErrMsg()); + } else { + log.warn("API 응답이 null"); + } + + } catch (Exception e) { + log.warn("API 호출 실패 (시도 {}/{}): {}", attempt, MAX_RETRY_COUNT, e.getMessage()); + } + + // 마지막 시도가 아니면 잠시 대기 + if (attempt < MAX_RETRY_COUNT) { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + log.error("API 호출 최종 실패 - 최대 재시도 횟수 초과"); + return null; + } + + // API URL 생성 + private String buildApiUrl() { + return UriComponentsBuilder.fromHttpUrl(apiUrl) + .queryParam("auth", apiKey) + .queryParam("topFinGrpNo", BANK_SECTOR_CODE) + .queryParam("pageNo", 1) + .build() + .toUriString(); + } + + // API 응답이 성공인지 확인 + private boolean isSuccessResponse(FssApiResponseDTO response) { + return response != null + && response.getResult() != null + && SUCCESS_CODE.equals(response.getResult().getErrCd()); + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/finproduct/service/RecommendationService.java b/src/main/java/org/bbagisix/finproduct/service/RecommendationService.java new file mode 100644 index 00000000..5fe212af --- /dev/null +++ b/src/main/java/org/bbagisix/finproduct/service/RecommendationService.java @@ -0,0 +1,178 @@ +package org.bbagisix.finproduct.service; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.finproduct.dto.LlmSavingProductDTO; +import org.bbagisix.finproduct.dto.LlmSavingRequestDTO; +import org.bbagisix.finproduct.dto.LlmSavingResponseDTO; +import org.bbagisix.finproduct.dto.RecommendedSavingDTO; +import org.bbagisix.finproduct.mapper.FinProductMapper; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Collectors; + +// 적금상품 추천 서비스 +// 하이브리드 방식: 백엔드 1차 필터링 + LLM 2차 지능형 추천 +@Log4j2 +@Service +@RequiredArgsConstructor +public class RecommendationService { + + private final FinProductMapper finProductMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${LLM_SERVER_URL}") + private String llmServerUrl; + + // Authentication에서 사용자 ID 추출하여 추천 서비스 호출 + public List getRecommendedSavings(Authentication authentication, Integer limit) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + Long userId = currentUser.getUserId(); + + log.info("사용자 {}에 대한 적금상품 추천 API 호출 - limit: {}", userId, limit); + + return getFilteredSavings(userId, limit); + } + + // 사용자 맞춤 적금상품 추천 (하이브리드 방식) + // 1차: DB 필터링 → 2차: LLM 지능형 추천 + public List getFilteredSavings(Long userId, Integer limit) { + try { + log.info("사용자 {}에 대한 하이브리드 추천 시작 - limit: {}", userId, limit); + + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED, "사용자 ID가 필요합니다"); + } + + // 1차: DB에서 나이 및 직업 필터링 후 조회 + List filteredProducts = finProductMapper.findRecommendedSavings(userId, null); + + if (filteredProducts == null || filteredProducts.isEmpty()) { + log.warn("사용자 {}에 대한 추천 가능한 상품이 없습니다", userId); + throw new BusinessException(ErrorCode.FINPRODUCT_NOT_FOUND, "추천 가능한 금융상품이 없습니다"); + } + + log.info("사용자 {}에 대한 1차 DB 필터링 완료 - {}개 상품", userId, filteredProducts.size()); + + // 2차: LLM 서버에서 지능형 추천 (3개) + List llmRecommendations = callLlmRecommendation(filteredProducts, userId); + + log.info("사용자 {}에 대한 LLM 추천 완료 - {}개 상품", userId, llmRecommendations.size()); + + return llmRecommendations; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("하이브리드 추천 중 예상치 못한 오류 발생 - userId: {}, limit: {}, 오류: {}", + userId, limit, e.getMessage(), e); + throw new BusinessException(ErrorCode.FINPRODUCT_NOT_FOUND, e); + } + } + + // LLM 서버 호출하여 지능형 추천 받기 + private List callLlmRecommendation(List filteredProducts, Long userId) { + try { + // 사용자 정보 추출 (첫 번째 상품에서) + RecommendedSavingDTO firstProduct = filteredProducts.get(0); + int userAge = firstProduct.getUserAge(); + String userJob = firstProduct.getUserJob(); + String mainBankName = firstProduct.getMainBankName(); + + // LLM 요청 데이터 구성 + List llmProducts = filteredProducts.stream() + .map(product -> LlmSavingProductDTO.builder() + .finPrdtCd(product.getFinPrdtCd()) + .korCoNm(product.getKorCoNm()) + .finPrdtNm(product.getFinPrdtNm()) + .spclCnd(product.getSpclCnd()) + .joinMember(product.getJoinMember()) + .intrRate(product.getIntrRate().doubleValue()) + .intrRate2(product.getIntrRate2().doubleValue()) + .build()) + .collect(Collectors.toList()); + + LlmSavingRequestDTO request = LlmSavingRequestDTO.builder() + .savings(llmProducts) + .userAge(userAge) + .userJob(userJob) + .mainBankName(mainBankName) + .build(); + + // HTTP 요청 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(request, headers); + // LLM 서버 호출 + String llmEndpoint = llmServerUrl + "/recommend-savings"; + log.info("LLM 서버 호출: {} - 사용자: {}, 상품수: {}", llmEndpoint, userId, llmProducts.size()); + + ResponseEntity response = restTemplate.exchange( + llmEndpoint, + HttpMethod.POST, + entity, + LlmSavingResponseDTO.class + ); + + if (response.getBody() != null && response.getBody().getRecommendations() != null) { + // LLM 추천 결과 반환 (상품 정보만) + return response.getBody().getRecommendations().stream() + .map(product -> RecommendedSavingDTO.builder() + .finPrdtCd(product.getFinPrdtCd()) + .korCoNm(product.getKorCoNm()) + .finPrdtNm(product.getFinPrdtNm()) + .spclCnd(product.getSpclCnd()) + .joinMember(product.getJoinMember()) + .intrRate(BigDecimal.valueOf(product.getIntrRate())) + .intrRate2(BigDecimal.valueOf(product.getIntrRate2())) + .build()) + .collect(Collectors.toList()); + } else { + log.warn("LLM 서버에서 빈 응답 - 사용자: {}", userId); + // LLM 실패 시 상위 3개 상품 반환 (상품 정보만) + return filteredProducts.stream() + .limit(3) + .map(product -> RecommendedSavingDTO.builder() + .finPrdtCd(product.getFinPrdtCd()) + .korCoNm(product.getKorCoNm()) + .finPrdtNm(product.getFinPrdtNm()) + .spclCnd(product.getSpclCnd()) + .joinMember(product.getJoinMember()) + .intrRate(product.getIntrRate()) + .intrRate2(product.getIntrRate2()) + .build()) + .collect(Collectors.toList()); + } + + } catch (Exception e) { + log.error("LLM 서버 호출 실패 - 사용자: {}, 오류: {}", userId, e.getMessage(), e); + // LLM 실패 시 상위 3개 상품 반환 (상품 정보만) + return filteredProducts.stream() + .limit(3) + .map(product -> RecommendedSavingDTO.builder() + .finPrdtCd(product.getFinPrdtCd()) + .korCoNm(product.getKorCoNm()) + .finPrdtNm(product.getFinPrdtNm()) + .spclCnd(product.getSpclCnd()) + .joinMember(product.getJoinMember()) + .intrRate(product.getIntrRate()) + .intrRate2(product.getIntrRate2()) + .build()) + .collect(Collectors.toList()); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/mypage/controller/MyPageController.java b/src/main/java/org/bbagisix/mypage/controller/MyPageController.java new file mode 100644 index 00000000..572c0477 --- /dev/null +++ b/src/main/java/org/bbagisix/mypage/controller/MyPageController.java @@ -0,0 +1,41 @@ +package org.bbagisix.mypage.controller; + +import org.bbagisix.mypage.domain.MyPageDTO; +import java.util.List; +import org.bbagisix.mypage.domain.UserChallengeDTO; +import org.bbagisix.mypage.service.MyPageService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("/api/mypage") +@RequiredArgsConstructor +@Log4j2 +public class MyPageController { + + private final MyPageService myPageService; + + @GetMapping("/accounts") + public ResponseEntity getUserAccounts(Authentication authentication) { + MyPageDTO response = myPageService.getUserAccountData(authentication); + return ResponseEntity.ok(response); + } + + @GetMapping("/tier") + public ResponseEntity getUserTier(Authentication authentication) { + MyPageDTO.TierInfo tierInfo = myPageService.getUserTierInfo(authentication); + return ResponseEntity.ok(tierInfo); + } + + @GetMapping("/challenges/completed") + public ResponseEntity> getUserChallenges(Authentication authentication) { + List challenges = myPageService.getUserChallenges(authentication); + return ResponseEntity.ok(challenges); + } +} diff --git a/src/main/java/org/bbagisix/mypage/domain/MyPageDTO.java b/src/main/java/org/bbagisix/mypage/domain/MyPageDTO.java new file mode 100644 index 00000000..57421861 --- /dev/null +++ b/src/main/java/org/bbagisix/mypage/domain/MyPageDTO.java @@ -0,0 +1,38 @@ +package org.bbagisix.mypage.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MyPageDTO { + private AccountInfo mainAccount; + private AccountInfo subAccount; + private TierInfo tierInfo; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class AccountInfo { + private Long assetId; + private String assetName; + private String bankName; + private Long balance; + private String status; // main, sub + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class TierInfo { + private Long tierId; + private String tierName; + private Integer completedChallenges; + } +} diff --git a/src/main/java/org/bbagisix/mypage/domain/UserChallengeDTO.java b/src/main/java/org/bbagisix/mypage/domain/UserChallengeDTO.java new file mode 100644 index 00000000..52275536 --- /dev/null +++ b/src/main/java/org/bbagisix/mypage/domain/UserChallengeDTO.java @@ -0,0 +1,27 @@ +package org.bbagisix.mypage.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class UserChallengeDTO { + private Long userChallengeId; + private String title; + private String status; + + // JSON 출력 시 날짜 형식 지정 + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") + private Date startDate; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") + private Date endDate; + + private Long categoryId; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/mypage/service/MyPageService.java b/src/main/java/org/bbagisix/mypage/service/MyPageService.java new file mode 100644 index 00000000..31088e46 --- /dev/null +++ b/src/main/java/org/bbagisix/mypage/service/MyPageService.java @@ -0,0 +1,133 @@ +package org.bbagisix.mypage.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.challenge.domain.ChallengeVO; +import org.bbagisix.challenge.domain.UserChallengeVO; +import org.bbagisix.challenge.mapper.ChallengeMapper; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.mypage.domain.MyPageDTO; +import org.bbagisix.tier.service.TierService; +import org.bbagisix.tier.dto.TierDTO; +import org.bbagisix.mypage.domain.UserChallengeDTO; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.mapper.UserMapper; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class MyPageService { + + private final AssetMapper assetMapper; + private final UserMapper userMapper; + private final TierService tierService; + private final ChallengeMapper challengeMapper; + + public MyPageDTO getUserAccountData(Authentication authentication) { + Long userId = extractUserId(authentication); + + // main 계좌 조회 + AssetVO mainAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "main"); + + // sub 계좌 조회 + AssetVO subAsset = assetMapper.selectAssetByUserIdAndStatus(userId, "sub"); + + // tier 정보 조회 + MyPageDTO.TierInfo tierInfo = getUserTierInfo(authentication); + + return MyPageDTO.builder() + .mainAccount(mainAsset != null ? convertToAccountInfo(mainAsset) : null) + .subAccount(subAsset != null ? convertToAccountInfo(subAsset) : null) + .tierInfo(tierInfo) + .build(); + } + + public MyPageDTO.TierInfo getUserTierInfo(Authentication authentication) { + Long userId = extractUserId(authentication); + + // 현재 티어 정보 조회 + TierDTO currentTier = tierService.getUserCurrentTier(userId); + + // 완료한 챌린지 수 조회 + Integer completedChallenges = getCompletedChallengeCount(userId); + + if (currentTier == null) { + // 티어가 없는 경우 기본값 반환 + return MyPageDTO.TierInfo.builder() + .tierId(null) + .tierName("티어 없음") + .completedChallenges(completedChallenges) + .build(); + } + + return MyPageDTO.TierInfo.builder() + .tierId(currentTier.getTierId()) + .tierName(currentTier.getName()) + .completedChallenges(completedChallenges) + .build(); + } + + // 완료된(complete/failed) 사용자 챌린지 목록 조회 + public List getUserChallenges(Authentication authentication) { + Long userId = extractUserId(authentication); + + List userChallenges = challengeMapper.getUserCompletedChallenges(userId); + + return userChallenges.stream() + .map(this::convertToUserChallengeDTO) + .collect(Collectors.toList()); + } + + // Authentication에서 사용자 ID 추출 + private Long extractUserId(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + } + + CustomOAuth2User curUser = (CustomOAuth2User)authentication.getPrincipal(); + if (curUser == null || curUser.getUserId() == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + return curUser.getUserId(); + } + + private MyPageDTO.AccountInfo convertToAccountInfo(AssetVO assetVO) { + return MyPageDTO.AccountInfo.builder() + .assetId(assetVO.getAssetId()) + .assetName(assetVO.getAssetName()) + .bankName(assetVO.getBankName()) + .balance(assetVO.getBalance()) + .status(assetVO.getStatus()) + .build(); + } + + private Integer getCompletedChallengeCount(Long userId) { + Integer count = userMapper.getCompletedChallengeCount(userId); + return count != null ? count : 0; + } + + private UserChallengeDTO convertToUserChallengeDTO(UserChallengeVO userChallenge) { + // 챌린지 상세 정보 조회 + ChallengeVO challenge = challengeMapper.findByChallengeId(userChallenge.getChallengeId()); + Long categoryId = challengeMapper.getCategoryByChallengeId(userChallenge.getChallengeId()); + + return UserChallengeDTO.builder() + .userChallengeId(userChallenge.getUserChallengeId()) + .title(challenge != null ? challenge.getTitle() : "알 수 없는 챌린지") + .status(userChallenge.getStatus()) + .startDate(userChallenge.getStartDate()) + .endDate(userChallenge.getEndDate()) + .categoryId(categoryId) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/reward/controller/RewardController.java b/src/main/java/org/bbagisix/reward/controller/RewardController.java deleted file mode 100644 index 6cede523..00000000 --- a/src/main/java/org/bbagisix/reward/controller/RewardController.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.reward.controller; - -public class RewardController { -} diff --git a/src/main/java/org/bbagisix/reward/domain/RewardVO.java b/src/main/java/org/bbagisix/reward/domain/RewardVO.java deleted file mode 100644 index edbb32da..00000000 --- a/src/main/java/org/bbagisix/reward/domain/RewardVO.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.reward.domain; - -public class RewardVO { -} diff --git a/src/main/java/org/bbagisix/reward/dto/RewardDTO.java b/src/main/java/org/bbagisix/reward/dto/RewardDTO.java deleted file mode 100644 index 1942b01b..00000000 --- a/src/main/java/org/bbagisix/reward/dto/RewardDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.reward.dto; - -public class RewardDTO { -} diff --git a/src/main/java/org/bbagisix/reward/mapper/RewardMapper.java b/src/main/java/org/bbagisix/reward/mapper/RewardMapper.java deleted file mode 100644 index a4033b5c..00000000 --- a/src/main/java/org/bbagisix/reward/mapper/RewardMapper.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.reward.mapper; - -public interface RewardMapper { -} diff --git a/src/main/java/org/bbagisix/reward/service/RewardService.java b/src/main/java/org/bbagisix/reward/service/RewardService.java deleted file mode 100644 index 7968c729..00000000 --- a/src/main/java/org/bbagisix/reward/service/RewardService.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.reward.service; - -public class RewardService { -} diff --git a/src/main/java/org/bbagisix/saving/controller/SavingController.java b/src/main/java/org/bbagisix/saving/controller/SavingController.java new file mode 100644 index 00000000..0d661760 --- /dev/null +++ b/src/main/java/org/bbagisix/saving/controller/SavingController.java @@ -0,0 +1,47 @@ +package org.bbagisix.saving.controller; + +import java.util.List; + +import org.bbagisix.saving.dto.SavingDTO; +import org.bbagisix.saving.service.SavingService; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("/api/saving") +@RequiredArgsConstructor +@Log4j2 +public class SavingController { + private final SavingService savingService; + + @GetMapping("/total") + public ResponseEntity getTotalSaving(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + Long total = savingService.getTotalSaving(currentUser.getUserId()); + return ResponseEntity.ok(total); + } + + @GetMapping("/history") + public ResponseEntity> getSavingHistory(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + List savingHistory = savingService.getSavingHistory(currentUser.getUserId()); + return ResponseEntity.ok(savingHistory); + } + + @PostMapping("/save/{userChallengeId}") + public ResponseEntity saveSaving(@PathVariable Long userChallengeId, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + Long userId = currentUser.getUserId(); + savingService.updateSaving(userId, userChallengeId); + return ResponseEntity.ok("저금통 저장 완료"); + } +} diff --git a/src/main/java/org/bbagisix/saving/dto/SavingDTO.java b/src/main/java/org/bbagisix/saving/dto/SavingDTO.java new file mode 100644 index 00000000..d5ab7a6b --- /dev/null +++ b/src/main/java/org/bbagisix/saving/dto/SavingDTO.java @@ -0,0 +1,25 @@ +package org.bbagisix.saving.dto; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SavingDTO { + private Long categoryId; // + private String title; + private Long period; + private Date startDate; + private Date endDate; + private Long saving; +} diff --git a/src/main/java/org/bbagisix/saving/mapper/SavingMapper.java b/src/main/java/org/bbagisix/saving/mapper/SavingMapper.java new file mode 100644 index 00000000..aba45bb1 --- /dev/null +++ b/src/main/java/org/bbagisix/saving/mapper/SavingMapper.java @@ -0,0 +1,11 @@ +package org.bbagisix.saving.mapper; + +import java.util.List; + +import org.bbagisix.saving.dto.SavingDTO; + +public interface SavingMapper { + List getSavingHistory(Long userId); + + Long getTotalSaving(Long userId); +} diff --git a/src/main/java/org/bbagisix/saving/service/SavingService.java b/src/main/java/org/bbagisix/saving/service/SavingService.java new file mode 100644 index 00000000..b98008ff --- /dev/null +++ b/src/main/java/org/bbagisix/saving/service/SavingService.java @@ -0,0 +1,78 @@ +package org.bbagisix.saving.service; + +import java.util.List; + +import org.bbagisix.asset.domain.AssetVO; +import org.bbagisix.asset.mapper.AssetMapper; +import org.bbagisix.challenge.domain.UserChallengeVO; +import org.bbagisix.challenge.mapper.ChallengeMapper; +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.saving.dto.SavingDTO; +import org.bbagisix.saving.mapper.SavingMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class SavingService { + + private final SavingMapper savingMapper; + private final ChallengeMapper challengeMapper; + private final AssetMapper assetMapper; + + public List getSavingHistory(Long userId) { + try { + return savingMapper.getSavingHistory(userId); + } catch (Exception e) { + log.error("사용자 저금 내역 조회 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + public Long getTotalSaving(Long userId) { + try { + return savingMapper.getTotalSaving(userId); + } catch (Exception e) { + log.error("사용자 총 저금액 조회 중 오류 발생: userId={}, error={}", userId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + + @Transactional(rollbackFor = Exception.class) + public void updateSaving(Long userId, Long userChallengeId) { + + try {// 해당 userChallenge 조회 + UserChallengeVO userChallenge; + userChallenge = challengeMapper.getUserChallengeById(userChallengeId); + if (userChallenge == null) + throw new BusinessException(ErrorCode.CHALLENGE_NOT_FOUND); + if (!userChallenge.getUserId().equals(userId)) + throw new BusinessException(ErrorCode.SAVING_UPDATE_DENIED); + Long totalSaving = userChallenge.getSaving() * userChallenge.getPeriod(); + + // user 계좌(sub) 조회 + AssetVO asset; + asset = assetMapper.selectAssetByUserIdAndStatus(userId, "sub"); + if (asset == null) + throw new BusinessException(ErrorCode.ASSET_NOT_FOUND); + + // balance update + int updated = assetMapper.updateSavingAssetBalance(asset.getAssetId(), totalSaving); + if (updated != 1) { + throw new BusinessException(ErrorCode.SAVING_UPDATE_FAILED); + } + + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + log.error("asset(저금통) balance 업데이트 중 알 수 없는 오류 발생: error={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.DATA_ACCESS_ERROR, e); + } + } + +} diff --git a/src/main/java/org/bbagisix/system/controller/SystemController.java b/src/main/java/org/bbagisix/system/controller/SystemController.java deleted file mode 100644 index 71718bfe..00000000 --- a/src/main/java/org/bbagisix/system/controller/SystemController.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.system.controller; - -public class SystemController { -} diff --git a/src/main/java/org/bbagisix/system/service/SystemService.java b/src/main/java/org/bbagisix/system/service/SystemService.java deleted file mode 100644 index a17a5de1..00000000 --- a/src/main/java/org/bbagisix/system/service/SystemService.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.system.service; - -public class SystemService { -} diff --git a/src/main/java/org/bbagisix/tier/controller/TierController.java b/src/main/java/org/bbagisix/tier/controller/TierController.java new file mode 100644 index 00000000..cbc1a194 --- /dev/null +++ b/src/main/java/org/bbagisix/tier/controller/TierController.java @@ -0,0 +1,44 @@ +package org.bbagisix.tier.controller; + +import java.util.List; + +import org.bbagisix.tier.dto.TierDTO; +import org.bbagisix.tier.service.TierService; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class TierController { + + private final TierService tierService; + + /** + * 개인 티어 조회 + * GET /api/user/me/tiers + */ + @GetMapping("/user/me/tiers") + public ResponseEntity getUserCurrentTier(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + TierDTO currentTier = tierService.getUserCurrentTier(currentUser.getUserId()); + return ResponseEntity.ok(currentTier); + } + + /** + * 전체 티어 조회 + * GET /api/tiers/all + */ + @GetMapping("/tiers/all") + public ResponseEntity> getAllTiers() { + List allTiers = tierService.getAllTiers(); + return ResponseEntity.ok(allTiers); + } + +} diff --git a/src/main/java/org/bbagisix/tier/domain/TierVO.java b/src/main/java/org/bbagisix/tier/domain/TierVO.java new file mode 100644 index 00000000..4ba71680 --- /dev/null +++ b/src/main/java/org/bbagisix/tier/domain/TierVO.java @@ -0,0 +1,15 @@ +package org.bbagisix.tier.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TierVO { + private Long tierId; + private String name; +} diff --git a/src/main/java/org/bbagisix/tier/dto/TierDTO.java b/src/main/java/org/bbagisix/tier/dto/TierDTO.java new file mode 100644 index 00000000..04d29f83 --- /dev/null +++ b/src/main/java/org/bbagisix/tier/dto/TierDTO.java @@ -0,0 +1,35 @@ +package org.bbagisix.tier.dto; + +import org.bbagisix.tier.domain.TierVO; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TierDTO { + private Long tierId; + private String name; + private Boolean isCurrentTier; // 사용자의 현재 티어인지 여부 + + // TierVO를 TierDTO로 변환하는 정적 메서드 + public static TierDTO from(TierVO tierVO) { + return TierDTO.builder() + .tierId(tierVO.getTierId()) + .name(tierVO.getName()) + .build(); + } + + // 사용자 정보를 포함한 TierDTO 생성 + public static TierDTO fromWithUserInfo(TierVO tierVO, Long userCurrentTierId) { + return TierDTO.builder() + .tierId(tierVO.getTierId()) + .name(tierVO.getName()) + .isCurrentTier(tierVO.getTierId().equals(userCurrentTierId)) + .build(); + } +} diff --git a/src/main/java/org/bbagisix/tier/mapper/TierMapper.java b/src/main/java/org/bbagisix/tier/mapper/TierMapper.java new file mode 100644 index 00000000..4bfb3a4d --- /dev/null +++ b/src/main/java/org/bbagisix/tier/mapper/TierMapper.java @@ -0,0 +1,17 @@ +package org.bbagisix.tier.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.tier.domain.TierVO; + +@Mapper +public interface TierMapper { + + // 모든 티어 목록 조회 (tier_order 순서대로) + List findAllTiers(); + + // 티어 ID로 단일 티어 조회 + TierVO findById(@Param("tierId") Long tierId); +} diff --git a/src/main/java/org/bbagisix/tier/service/TierService.java b/src/main/java/org/bbagisix/tier/service/TierService.java new file mode 100644 index 00000000..f243bc56 --- /dev/null +++ b/src/main/java/org/bbagisix/tier/service/TierService.java @@ -0,0 +1,114 @@ +package org.bbagisix.tier.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.tier.domain.TierVO; +import org.bbagisix.tier.dto.TierDTO; +import org.bbagisix.tier.mapper.TierMapper; +import org.bbagisix.user.domain.UserVO; +import org.bbagisix.user.mapper.UserMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TierService { + + private final TierMapper tierMapper; + private final UserMapper userMapper; + + // 모든 티어 목록 조회 + public List getAllTiers() { + List allTiers = tierMapper.findAllTiers(); + + return allTiers.stream() + .map(TierDTO::from) + .collect(Collectors.toList()); + } + + // 사용자의 현재 티어 조회 + public TierDTO getUserCurrentTier(Long userId) { + if (userId == null) { + throw new BusinessException(ErrorCode.USER_ID_REQUIRED); + } + + // 사용자 정보 조회 + UserVO user = userMapper.findByUserId(userId); + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + // tier_id가 null이면 아직 티어가 없는 상태 + if (user.getTierId() == null) { + return null; // 또는 기본 티어 반환 + } + + // 현재 티어 조회 + TierVO currentTier = tierMapper.findById(user.getTierId()); + if (currentTier == null) { + log.warn("사용자 {}의 tier_id {}에 해당하는 티어를 찾을 수 없습니다.", userId, user.getTierId()); + return null; + } + + return TierDTO.from(currentTier); + } + + // 챌린지 성공 시 티어 승급 처리 + @Transactional + public void promoteUserTier(Long userId) { + // 완료한 챌린지 수 조회 + Integer completedChallenges = userMapper.getCompletedChallengeCount(userId); + + // 완료한 챌린지 수에 기반한 적절한 tier 계산 + Long appropriateTierId = calculateTierByCompletedChallenges(completedChallenges); + + // 현재 사용자 정보 조회 + UserVO user = userMapper.findByUserId(userId); + Long currentTierId = user.getTierId(); + + // 계산된 tier가 현재 tier보다 높은 경우에만 업데이트 + if (appropriateTierId > (currentTierId != null ? currentTierId : 0L)) { + // 해당 tier가 실제로 존재하는지 확인 + TierVO targetTier = tierMapper.findById(appropriateTierId); + if (targetTier != null) { + int updateResult = userMapper.updateUserTier(userId, appropriateTierId); + if (updateResult > 0) { + log.info("✅ 사용자 {} 티어 승급 성공: {} → {} (완료한 챌린지: {}개)", + userId, currentTierId, appropriateTierId, completedChallenges); + } else { + log.error("❌ 사용자 {} 티어 업데이트 실패", userId); + } + } else { + log.error("❌ tier_id {} 에 해당하는 tier가 존재하지 않음", appropriateTierId); + } + } else { + log.info("⏭️ tier 업데이트 조건 불만족 - 현재: {}, 계산된: {}", currentTierId, appropriateTierId); + } + } + + // 완료한 챌린지 수에 따른 tier 계산 + private Long calculateTierByCompletedChallenges(Integer completedChallenges) { + if (completedChallenges == null || completedChallenges == 0) { + return 1L; // default + } else if (completedChallenges <= 1) { + return 1L; // 브론즈 + } else if (completedChallenges <= 2) { + return 2L; // 실버 + } else if (completedChallenges <= 3) { + return 3L; // 골드 + } else if (completedChallenges <= 4) { + return 4L; // 플래티넘 + } else if (completedChallenges <= 5) { + return 5L; // 루비 + } else { + return 6L; // 에메랄드 + } + } +} diff --git a/src/main/java/org/bbagisix/user/controller/OAuth2Controller.java b/src/main/java/org/bbagisix/user/controller/OAuth2Controller.java new file mode 100644 index 00000000..c2ef75f1 --- /dev/null +++ b/src/main/java/org/bbagisix/user/controller/OAuth2Controller.java @@ -0,0 +1,13 @@ +package org.bbagisix.user.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OAuth2Controller { + + @GetMapping("/oauth2-success") + public String oauth2Success() { + return "OAuth2 login successful. JWT token is set in cookie."; + } +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/user/controller/OAuth2RedirectController.java b/src/main/java/org/bbagisix/user/controller/OAuth2RedirectController.java new file mode 100644 index 00000000..d61a7e81 --- /dev/null +++ b/src/main/java/org/bbagisix/user/controller/OAuth2RedirectController.java @@ -0,0 +1,32 @@ +package org.bbagisix.user.controller; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.bbagisix.user.service.OAuth2RedirectService; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class OAuth2RedirectController { + + private final OAuth2RedirectService oauth2RedirectService; + + @GetMapping("/oauth2/authorization/google") + public void googleLogin(@RequestParam(required = false) String redirectUrl, HttpServletRequest request, HttpServletResponse response) throws IOException { + response.sendRedirect(oauth2RedirectService.prepareOAuth2Login(request, "google", redirectUrl)); + } + + @GetMapping("/oauth2/authorization/naver") + public void naverLogin(@RequestParam(required = false) String redirectUrl, HttpServletRequest request, HttpServletResponse response) throws IOException { + response.sendRedirect(oauth2RedirectService.prepareOAuth2Login(request, "naver", redirectUrl)); + } +} diff --git a/src/main/java/org/bbagisix/user/controller/UserController.java b/src/main/java/org/bbagisix/user/controller/UserController.java index 16da11c4..98b6dc70 100644 --- a/src/main/java/org/bbagisix/user/controller/UserController.java +++ b/src/main/java/org/bbagisix/user/controller/UserController.java @@ -1,4 +1,76 @@ package org.bbagisix.user.controller; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; + +import org.apache.ibatis.annotations.Param; +import org.bbagisix.user.dto.LoginRequest; +import org.bbagisix.user.dto.SendCodeRequest; +import org.bbagisix.user.dto.SignUpRequest; +import org.bbagisix.user.dto.UserResponse; +import org.bbagisix.user.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor public class UserController { + + private final UserService userService; + + @PostMapping("/send-verification") + public ResponseEntity sendCode(@Valid @RequestBody SendCodeRequest request) { + return ResponseEntity.ok(userService.sendVerificationCode(request.getEmail())); + } + + @PostMapping("/signup") + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request, HttpServletResponse response, HttpServletRequest httpRequest) { + return ResponseEntity.ok(userService.signUp(request, response, httpRequest)); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, HttpServletResponse response, HttpServletRequest httpRequest) { + return ResponseEntity.ok(userService.login(request.getEmail(), request.getPassword(), response, httpRequest)); + } + + @GetMapping("/check-email") + public ResponseEntity checkEmail(@Param("email") String email) { + return ResponseEntity.ok(userService.isEmailDuplicate(email)); + } + + @PutMapping("/update-nickname") + public ResponseEntity updateNickname(@Param("nickname") String nickname, Authentication authentication) { + return ResponseEntity.ok(userService.updateNickname(authentication, nickname)); + } + + @PutMapping("/update-assetConnected") + public ResponseEntity updateAssetConnected(@Param("assetConnected") Boolean assetConnected, + Authentication authentication) { + return ResponseEntity.ok(userService.updateAssetConnected(assetConnected, authentication)); + } + + @PutMapping("/update-savingConnected") + public ResponseEntity updateSavingConnected(@Param("savingConnected") Boolean savingConnected, Authentication authentication) { + return ResponseEntity.ok(userService.updateSavingConnected(savingConnected, authentication)); + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(Authentication authentication) { + return ResponseEntity.ok(userService.getCurrentUser(authentication)); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response, HttpServletRequest request) { + return ResponseEntity.ok(userService.logout(response, request)); + } } diff --git a/src/main/java/org/bbagisix/user/domain/UserVO.java b/src/main/java/org/bbagisix/user/domain/UserVO.java index ce33413e..98fd8256 100644 --- a/src/main/java/org/bbagisix/user/domain/UserVO.java +++ b/src/main/java/org/bbagisix/user/domain/UserVO.java @@ -1,4 +1,30 @@ package org.bbagisix.user.domain; +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class UserVO { + private Long userId; + private String name; + private String email; + private String password; + private String nickname; + private Integer age; + private String socialId; + private String role; + private String job; + private boolean emailVerified; + private boolean assetConnected; + private boolean savingConnected; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long tierId; } diff --git a/src/main/java/org/bbagisix/user/dto/CustomOAuth2User.java b/src/main/java/org/bbagisix/user/dto/CustomOAuth2User.java new file mode 100644 index 00000000..02030d44 --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/CustomOAuth2User.java @@ -0,0 +1,59 @@ +package org.bbagisix.user.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class CustomOAuth2User implements OAuth2User { + + private final UserResponse userResponse; + + public CustomOAuth2User(UserResponse userResponse) { + this.userResponse = userResponse; + } + + @Override + public Map getAttributes() { + return Map.of(); + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return userResponse.getRole(); + } + }); + return collection; + } + + @Override + public String getName() { + return userResponse.getEmail(); + } + + public String getRole() { + return userResponse.getRole(); + } + + public String getEmail() { + return userResponse.getEmail(); + } + + public String getNickname() { + return userResponse.getNickname(); + } + + public Long getUserId() { + return userResponse.getUserId(); + } + + public Integer getAge() { + return userResponse.getAge(); + } +} diff --git a/src/main/java/org/bbagisix/user/dto/EmailVerificationRequest.java b/src/main/java/org/bbagisix/user/dto/EmailVerificationRequest.java new file mode 100644 index 00000000..05c903dc --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/EmailVerificationRequest.java @@ -0,0 +1,23 @@ +package org.bbagisix.user.dto; + +import javax.validation.constraints.Email; + +import org.apache.logging.log4j.core.config.plugins.validation.constraints.NotBlank; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EmailVerificationRequest { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "인증 토큰은 필수입니다.") + private String token; +} diff --git a/src/main/java/org/bbagisix/user/dto/GoogleResponse.java b/src/main/java/org/bbagisix/user/dto/GoogleResponse.java new file mode 100644 index 00000000..2b998abd --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/GoogleResponse.java @@ -0,0 +1,32 @@ +package org.bbagisix.user.dto; + +import java.util.Map; + +public class GoogleResponse implements OAuth2Response { + + private final Map attribute; + + public GoogleResponse(Map attribute) { + this.attribute = attribute; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } +} diff --git a/src/main/java/org/bbagisix/user/dto/GoogleTokenResponse.java b/src/main/java/org/bbagisix/user/dto/GoogleTokenResponse.java new file mode 100644 index 00000000..46f35ac4 --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/GoogleTokenResponse.java @@ -0,0 +1,21 @@ +package org.bbagisix.user.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleTokenResponse { + @JsonProperty("acccess_token") + private String accessToken; + @JsonProperty("expires_in") + private int expiresIn; + @JsonProperty("scope") + private String scope; + @JsonProperty("token_type") + private String tokenType; + @JsonProperty("id_token") + private String idToken; +} diff --git a/src/main/java/org/bbagisix/user/dto/LoginRequest.java b/src/main/java/org/bbagisix/user/dto/LoginRequest.java new file mode 100644 index 00000000..5e3fb763 --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/LoginRequest.java @@ -0,0 +1,14 @@ +package org.bbagisix.user.dto; + +import javax.validation.constraints.NotBlank; + +import lombok.Getter; + +@Getter +public class LoginRequest { + @NotBlank(message = "이메일은 필수입니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; +} diff --git a/src/main/java/org/bbagisix/user/dto/NaverResponse.java b/src/main/java/org/bbagisix/user/dto/NaverResponse.java new file mode 100644 index 00000000..9549be54 --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/NaverResponse.java @@ -0,0 +1,32 @@ +package org.bbagisix.user.dto; + +import java.util.Map; + +public class NaverResponse implements OAuth2Response { + + private final Map attribute; + + public NaverResponse(Map attribute) { + this.attribute = (Map) attribute.get("response"); + } + + @Override + public String getProvider() { + return "naver"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } +} diff --git a/src/main/java/org/bbagisix/user/dto/OAuth2Response.java b/src/main/java/org/bbagisix/user/dto/OAuth2Response.java new file mode 100644 index 00000000..8de91707 --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/OAuth2Response.java @@ -0,0 +1,16 @@ +package org.bbagisix.user.dto; + +public interface OAuth2Response { + + // 제공자 + String getProvider(); + + // 제공자에서 발급해주는 아이디 + String getProviderId(); + + // 이메일 + String getEmail(); + + // 사용자 실명 + String getName(); +} diff --git a/src/main/java/org/bbagisix/user/dto/SendCodeRequest.java b/src/main/java/org/bbagisix/user/dto/SendCodeRequest.java new file mode 100644 index 00000000..2d30724c --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/SendCodeRequest.java @@ -0,0 +1,14 @@ +package org.bbagisix.user.dto; + +import javax.validation.constraints.Email; + +import javax.validation.constraints.NotBlank; + +import lombok.Data; + +@Data +public class SendCodeRequest { + @NotBlank + @Email + private String email; +} diff --git a/src/main/java/org/bbagisix/user/dto/SignUpRequest.java b/src/main/java/org/bbagisix/user/dto/SignUpRequest.java new file mode 100644 index 00000000..dbbaa32b --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/SignUpRequest.java @@ -0,0 +1,36 @@ +package org.bbagisix.user.dto; + +import javax.validation.constraints.Email; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SignUpRequest { + @NotBlank(message = "이름은 필수입니다.") + private String name; + + @NotBlank(message = "닉네임은 필수입니다.") + private String nickname; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + @NotNull(message = "나이는 필수입니다.") + private Integer age; + + @NotNull(message = "직업은 필수입니다.") + private String job; +} diff --git a/src/main/java/org/bbagisix/user/dto/UserDTO.java b/src/main/java/org/bbagisix/user/dto/UserDTO.java deleted file mode 100644 index 388d3fc9..00000000 --- a/src/main/java/org/bbagisix/user/dto/UserDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.bbagisix.user.dto; - -public class UserDTO { -} diff --git a/src/main/java/org/bbagisix/user/dto/UserResponse.java b/src/main/java/org/bbagisix/user/dto/UserResponse.java new file mode 100644 index 00000000..7a0b1afe --- /dev/null +++ b/src/main/java/org/bbagisix/user/dto/UserResponse.java @@ -0,0 +1,21 @@ +package org.bbagisix.user.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class UserResponse { + private Long userId; + private String name; + private String email; + private String nickname; + private Integer age; + private String role; + private String job; + private boolean assetConnected; + private boolean savingConnected; + private Long tierId; +} \ No newline at end of file diff --git a/src/main/java/org/bbagisix/user/filter/JWTFilter.java b/src/main/java/org/bbagisix/user/filter/JWTFilter.java new file mode 100644 index 00000000..b1e8c591 --- /dev/null +++ b/src/main/java/org/bbagisix/user/filter/JWTFilter.java @@ -0,0 +1,104 @@ +package org.bbagisix.user.filter; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.dto.UserResponse; +import org.bbagisix.user.util.JwtUtil; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws + ServletException, IOException { + + String requestURI = request.getRequestURI(); + + String token = null; + + // 1. Authorization 헤더에서 토큰 확인 + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.split(" ")[1]; + } + + // 2. Authorization 헤더에 토큰이 없다면 쿠키에서 확인 + if (token == null) { + token = getTokenFromCookie(request); + } + + // 토큰이 없으면 다음 필터로 넘어감 + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + // 토큰 만료 확인 + if (jwtUtil.isExpired(token)) { + filterChain.doFilter(request, response); + return; + } + + // JWT에서 사용자 정보 추출 + String name = jwtUtil.getName(token); + String role = jwtUtil.getRole(token); + String email = jwtUtil.getEmail(token); + String nickname = jwtUtil.getNickname(token); + Long userId = jwtUtil.getUserId(token); + + UserResponse userResponse = UserResponse.builder() + .userId(userId) + .name(name) + .nickname(nickname) + .email(email) + .role(role) + .assetConnected(false) + .build(); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(userResponse); + + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, + customOAuth2User.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authToken); + filterChain.doFilter(request, response); + } + + /** + * 쿠키에서 JWT 토큰 추출 (fallback 쿠키 포함) + */ + private String getTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + String token = null; + String fallbackToken = null; + + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + token = cookie.getValue(); + } else if ("accessToken_fallback".equals(cookie.getName())) { + fallbackToken = cookie.getValue(); + } + } + + // 기본 토큰이 있으면 사용, 없으면 fallback 토큰 사용 + return token != null ? token : fallbackToken; + } + return null; + } +} diff --git a/src/main/java/org/bbagisix/user/handler/CustomOAuth2SuccessHandler.java b/src/main/java/org/bbagisix/user/handler/CustomOAuth2SuccessHandler.java new file mode 100644 index 00000000..56df05c1 --- /dev/null +++ b/src/main/java/org/bbagisix/user/handler/CustomOAuth2SuccessHandler.java @@ -0,0 +1,143 @@ +package org.bbagisix.user.handler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.service.OAuth2RedirectService; +import org.bbagisix.user.service.UserService; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final UserService userService; + private final OAuth2RedirectService oauth2RedirectService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + + try { + log.info("OAuth2 로그인 성공 처리 시작"); + + // 사용자 정보 추출 및 검증 + CustomOAuth2User oAuth2User = extractAndValidateUser(authentication); + + // JWT 토큰 생성 및 설정 + userService.processOAuth2Login( + oAuth2User.getEmail(), + oAuth2User.getRole(), + oAuth2User.getName(), + oAuth2User.getNickname(), + oAuth2User.getUserId(), + response, + request + ); + + // 리다이렉트 URL 결정 및 이동 + String redirectUrl = determineRedirectUrl(request); + redirectToTarget(request, response, redirectUrl); + + log.info("OAuth2 로그인 성공 처리 완료"); + + } catch (Exception e) { + log.error("OAuth2 로그인 성공 처리 중 오류 발생", e); + response.sendRedirect("/error?message=authentication_success_error"); + } + } + + /** + * 사용자 정보 추출 및 검증 + */ + private CustomOAuth2User extractAndValidateUser(Authentication authentication) { + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + log.info("OAuth2 사용자 정보 - Email: {}, Name: {}, UserId: {}", + oAuth2User.getEmail(), oAuth2User.getName(), oAuth2User.getUserId()); + + // 필수 정보 검증 + if (oAuth2User.getEmail() == null || oAuth2User.getUserId() == null) { + throw new IllegalStateException("필수 사용자 정보가 누락되었습니다."); + } + + return oAuth2User; + } + + /** + * 리다이렉트 URL 결정 + */ + private String determineRedirectUrl(HttpServletRequest request) { + // 1. URL 파라미터 확인 + String redirectUri = request.getParameter("redirect_uri"); + if (isValidUrl(redirectUri)) { + log.info("URL 파라미터에서 리다이렉트 URI 발견: {}", redirectUri); + return redirectUri; + } + + // 2. 세션에서 원본 URL 확인 + String originalUrl = oauth2RedirectService.getOriginalUrl(request.getSession()); + if (isValidUrl(originalUrl)) { + // 세션 정보 사용 후 제거 + oauth2RedirectService.clearOriginalUrl(request.getSession()); + return getRedirectUrlFromOrigin(originalUrl); + } + + // 3. Referer 헤더 확인 + String referer = request.getHeader("Referer"); + if (isValidUrl(referer)) { + log.info("Referer 헤더에서 URL 확인: {}", referer); + return getRedirectUrlFromOrigin(referer); + } + + // 4. 기본값 + log.info("기본 리다이렉트 URL 사용"); + return "https://dondothat.netlify.app/oauth-redirect"; + } + + /** + * URL 유효성 검사 + */ + private boolean isValidUrl(String url) { + return url != null && !url.trim().isEmpty(); + } + + /** + * 원본 URL에서 적절한 리다이렉트 URL 생성 + */ + private String getRedirectUrlFromOrigin(String originUrl) { + if (originUrl.contains("localhost:5173") || originUrl.contains("127.0.0.1:5173")) { + return "http://localhost:5173/oauth-redirect"; + } else if (originUrl.contains("netlify.app")) { + return "https://dondothat.netlify.app/oauth-redirect"; + } else { + return "https://dondothat.netlify.app/oauth-redirect"; + } + } + + /** + * 리다이렉트 실행 + */ + private void redirectToTarget(HttpServletRequest request, HttpServletResponse response, String redirectUrl) + throws IOException { + String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + + log.info("리다이렉트 대상 URL: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/org/bbagisix/user/mapper/UserMapper.java b/src/main/java/org/bbagisix/user/mapper/UserMapper.java index a095ffeb..6e5d1690 100644 --- a/src/main/java/org/bbagisix/user/mapper/UserMapper.java +++ b/src/main/java/org/bbagisix/user/mapper/UserMapper.java @@ -1,4 +1,45 @@ package org.bbagisix.user.mapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.bbagisix.user.domain.UserVO; + +@Mapper public interface UserMapper { + + // 회원가입 + int insertUser(UserVO user); + + // 조회 메서드 + UserVO findByUserId(@Param("userId") Long userId); + + UserVO selectUserByEmail(@Param("email") String email); + + UserVO selectUserById(@Param("userId") Long userId); + + UserVO findByName(@Param("name") String name); + + UserVO findBySocialId(@Param("socialId") String socialId); + + UserVO findByEmail(@Param("email") String email); + + String getNameByUserId(@Param("userId") Long userId); + + // 이메일 중복 체크 + int countByEmail(@Param("email") String email); + + // 닉네임 업데이트 + int updateNickname(@Param("userId") Long userId, @Param("nickname") String nickname); + + // 계좌 연동 여부 업데이트 + void updateAssetConnected(@Param("userId") Long userId, @Param("assetConnected") Boolean assetConnected); + + // 저금통 연결 여부 업데이트 + void updateSavingConnected(@Param("userId") Long userId, @Param("savingConnected") Boolean savingConnected); + + // 사용자 티어 업데이트 + int updateUserTier(@Param("userId") Long userId, @Param("tierId") Long tierId); + + // 완료한 챌린지 수 조회 + Integer getCompletedChallengeCount(@Param("userId") Long userId); } diff --git a/src/main/java/org/bbagisix/user/service/CustomOAuth2UserService.java b/src/main/java/org/bbagisix/user/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..a3f2c4ce --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/CustomOAuth2UserService.java @@ -0,0 +1,75 @@ +package org.bbagisix.user.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.dto.GoogleResponse; +import org.bbagisix.user.dto.NaverResponse; +import org.bbagisix.user.dto.OAuth2Response; +import org.bbagisix.user.domain.UserVO; +import org.bbagisix.user.dto.UserResponse; +import org.bbagisix.user.mapper.UserMapper; +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.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserMapper userMapper; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // 기본 OAuth2UserService로 사용자 정보 가져오기 + OAuth2User oAuth2User = super.loadUser(userRequest); + System.out.println(oAuth2User); + + // OAuth2 제공자 정보 (google, naver 등) + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if (registrationId.equals("naver")) { + oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); + } else if (registrationId.equals("google")) { + oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); + } else { + throw new BusinessException(ErrorCode.SOCIAL_NOT_FOUND); + } + String socialId = oAuth2Response.getProvider() + "_" + oAuth2Response.getProviderId(); + String email = oAuth2Response.getEmail(); + UserVO userVO = userMapper.findByEmail(email); + + if (userVO == null) { + log.info("신규 사용자입니다. DB에 저장합니다."); + userVO = UserVO.builder() + .name(oAuth2Response.getName()) + .socialId(socialId) + .email(email) + .nickname("기본 닉네임") + .socialId(socialId) + .role("ROLE_USER") + .build(); + + userMapper.insertUser(userVO); + } else { + log.info("기존 사용자입니다."); + } + + UserResponse userResponse = UserResponse.builder() + .name(userVO.getName()) + .nickname(userVO.getNickname()) + .role(userVO.getRole()) + .email(userVO.getEmail()) + .assetConnected(false) + .build(); + + return new CustomOAuth2User(userResponse); + } +} diff --git a/src/main/java/org/bbagisix/user/service/EmailService.java b/src/main/java/org/bbagisix/user/service/EmailService.java new file mode 100644 index 00000000..a6c9e4b0 --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/EmailService.java @@ -0,0 +1,51 @@ +package org.bbagisix.user.service; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; // 👈 이것으로 변경 + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username:noreply@dondothat.com}") + private String fromEmail; + + public void sendVerificationCode(String toEmail, String verificationCode) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromEmail); + message.setTo(toEmail); + message.setSubject("DonDoThat 이메일 인증코드"); + + String emailContent = String.format( + "안녕하세요!\n\n" + + "DonDoThat 회원가입을 위한 인증코드입니다.\n\n" + + "인증코드: %s\n\n" + + "이 코드는 5분 후에 만료됩니다.\n\n", + verificationCode + ); + + message.setText(emailContent); + mailSender.send(message); + + } catch (Exception e) { + throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED); + } + } + + public String generateVerificationCode() { + return String.format("%06d", (int)(Math.random() * 1000000)); + } +} diff --git a/src/main/java/org/bbagisix/user/service/OAuth2RedirectService.java b/src/main/java/org/bbagisix/user/service/OAuth2RedirectService.java new file mode 100644 index 00000000..7cb3ee43 --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/OAuth2RedirectService.java @@ -0,0 +1,56 @@ +package org.bbagisix.user.service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class OAuth2RedirectService { + + private static final String OAUTH2_ORIGINAL_URL_KEY = "OAUTH2_ORIGINAL_URL"; + private static final String OAUTH2_BASE_PATH = "/oauth2/authorization/"; + + public String prepareOAuth2Login(HttpServletRequest request, String provider, String redirectUrl) { + + // 원본 URL 저장 + saveOriginalUrl(request, redirectUrl); + + // OAuth2 리다이렉트 URL 생성 + String oauth2RedirectUrl = OAUTH2_BASE_PATH + provider; + + log.info("OAuth2 리다이렉트 URL 생성: {}", oauth2RedirectUrl); + return oauth2RedirectUrl; + } + + private void saveOriginalUrl(HttpServletRequest request, String redirectUrl) { + HttpSession session = request.getSession(); + + if (redirectUrl != null && !redirectUrl.trim().isEmpty()) { + session.setAttribute(OAUTH2_ORIGINAL_URL_KEY, redirectUrl); + log.info("OAuth2 세션에 요청 URL 저장: {}", redirectUrl); + } else { + // Referer에서 추출 + String referer = request.getHeader("Referer"); + if (referer != null && !referer.trim().isEmpty()) { + session.setAttribute(OAUTH2_ORIGINAL_URL_KEY, referer); + log.info("OAuth2 세션에 Referer URL 저장: {}", referer); + } else { + log.info("OAuth2 원본 URL이 없음 - 기본 페이지로 이동"); + } + } + } + + public String getOriginalUrl(HttpSession session) { + String originalUrl = (String) session.getAttribute(OAUTH2_ORIGINAL_URL_KEY); + log.info("OAuth2 세션에서 원본 URL 조회: {}", originalUrl); + return originalUrl; + } + + public void clearOriginalUrl(HttpSession session) { + session.removeAttribute(OAUTH2_ORIGINAL_URL_KEY); + } +} diff --git a/src/main/java/org/bbagisix/user/service/OAuth2Service.java b/src/main/java/org/bbagisix/user/service/OAuth2Service.java new file mode 100644 index 00000000..ea0daee7 --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/OAuth2Service.java @@ -0,0 +1,6 @@ +package org.bbagisix.user.service; + +public interface OAuth2Service { + String getGoogleAccessToken(String code); + // String getNaverAccessToken(String code, String state); +} diff --git a/src/main/java/org/bbagisix/user/service/OAuth2ServiceImpl.java b/src/main/java/org/bbagisix/user/service/OAuth2ServiceImpl.java new file mode 100644 index 00000000..88014dff --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/OAuth2ServiceImpl.java @@ -0,0 +1,52 @@ +package org.bbagisix.user.service; + + +import org.bbagisix.user.dto.GoogleTokenResponse; +import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; + +@Service +@RequiredArgsConstructor +public class OAuth2ServiceImpl implements OAuth2Service { + + private final RestTemplate restTemplate; + + @Value("${GOOGLE_CLIENT_ID}") + private String googleClientId; + @Value("${GOOGLE_CLIENT_SECRET}") + private String googleClientSecret; + @Value("${BASE_URL}") + private String baseUrl; + + // @Value("{NAVER_CLIENT_ID}") + // private String naverClientId; + // @Value("{NAVER_CLIENT_SECRET}") + // private String naverClientSecret; + + private static final String GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"; + // private static final String NAVER_TOKEN_URI = "https://nid.naver.com/oauth2.0/token"; + + @Override + public String getGoogleAccessToken(String code) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", code); + params.add("client_id", googleClientId); + params.add("client_secret", googleClientSecret); + params.add("redirect_uri", baseUrl + "/login/oauth2/code/google"); + params.add("grant_type", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpEntity> request = new HttpEntity<>(params, headers); + + GoogleTokenResponse response = restTemplate.postForObject(GOOGLE_TOKEN_URI, request, GoogleTokenResponse.class); + return response != null ? response.getAccessToken() : null; + } +} diff --git a/src/main/java/org/bbagisix/user/service/UserService.java b/src/main/java/org/bbagisix/user/service/UserService.java index 461ddcb4..e53af48a 100644 --- a/src/main/java/org/bbagisix/user/service/UserService.java +++ b/src/main/java/org/bbagisix/user/service/UserService.java @@ -1,4 +1,183 @@ package org.bbagisix.user.service; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.Cookie; + +import org.bbagisix.common.exception.BusinessException; +import org.bbagisix.common.exception.ErrorCode; +import org.bbagisix.user.domain.UserVO; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.dto.SignUpRequest; +import org.bbagisix.user.dto.UserResponse; +import org.bbagisix.user.mapper.UserMapper; +import org.bbagisix.user.util.CookieUtil; +import org.bbagisix.user.util.JwtUtil; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserService { + + private final UserMapper userMapper; + private final EmailService emailService; + private final VerificationStorageService verificationStorageService; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserResponse getCurrentUser(Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + UserVO userVO = userMapper.findByUserId(currentUser.getUserId()); + if (userVO == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + return UserResponse.builder() + .userId(userVO.getUserId()) + .name(userVO.getName()) + .email(userVO.getEmail()) + .nickname(userVO.getNickname()) + .age(userVO.getAge()) + .role(userVO.getRole()) + .job(userVO.getJob()) + .assetConnected(userVO.isAssetConnected()) + .savingConnected(userVO.isSavingConnected()) + .tierId(userVO.getTierId()) + .build(); + } + + @Transactional + public boolean isEmailDuplicate(String email) { + return userMapper.countByEmail(email) > 0; + } + + @Transactional + public String updateNickname(Authentication authentication, String nickname) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + userMapper.updateNickname(currentUser.getUserId(), nickname); + return "닉네임 변경 완료"; + } + + @Transactional + public String updateAssetConnected(Boolean assetConnected, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + userMapper.updateAssetConnected(currentUser.getUserId(), assetConnected); + return "계좌 연동 여부 업데이트 완료: " + assetConnected; + } + + @Transactional + public String updateSavingConnected(Boolean savingConnected, Authentication authentication) { + CustomOAuth2User currentUser = (CustomOAuth2User)authentication.getPrincipal(); + userMapper.updateSavingConnected(currentUser.getUserId(), savingConnected); + return "저금통 연결 여부 업데이트 완료: " + savingConnected; + } + + @Transactional + public String signUp(SignUpRequest signUpRequest, HttpServletResponse response, HttpServletRequest request) { + if (isEmailDuplicate(signUpRequest.getEmail())) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + String encodedPassword = passwordEncoder.encode(signUpRequest.getPassword()); + + UserVO user = UserVO.builder() + .name(signUpRequest.getName()) + .nickname(signUpRequest.getNickname()) + .password(encodedPassword) + .email(signUpRequest.getEmail()) + .age(signUpRequest.getAge()) + .job(signUpRequest.getJob()) + .emailVerified(false) + .assetConnected(false) + .role("USER") + .build(); + + userMapper.insertUser(user); + + String token = jwtUtil.createToken( + user.getEmail(), + user.getRole(), + user.getName(), + user.getNickname(), + user.getUserId(), + 24 * 60 * 60 * 1000L + ); + + CookieUtil.addJwtCookie(response, token, request); + return "회원가입 완료."; + } + + @Transactional + public String login(String email, String password, HttpServletResponse response, HttpServletRequest request) { + UserVO user = userMapper.findByEmail(email); + if (user == null || !passwordEncoder.matches(password, user.getPassword())) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + + // JWT 토큰 생성 + String token = jwtUtil.createToken( + user.getEmail(), + user.getRole(), + user.getName(), + user.getNickname(), + user.getUserId(), + 24 * 60 * 60 * 1000L + ); + + // JWT 토큰을 HttpOnly 쿠키로 설정 + CookieUtil.addJwtCookie(response, token, request); + + return "로그인 성공."; + } + + @Transactional + public String logout(HttpServletResponse response, HttpServletRequest request) { + CookieUtil.deleteJwtCookie(response); + Cookie jsessionCookie = new Cookie("JSESSIONID", ""); + jsessionCookie.setPath("/"); + jsessionCookie.setMaxAge(0); + response.addCookie(jsessionCookie); + + // JSESSIONID 삭제 + response.addHeader("Set-Cookie", + "JSESSIONID=" + + "; Path=/" + + "; Max-Age=0" + + "; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + + // 세션 무효화 + if (request.getSession(false) != null) { + request.getSession().invalidate(); + } + + return "로그아웃 완료."; + } + + @Transactional + public void processOAuth2Login(String email, String role, String name, String nickname, Long userId, + HttpServletResponse response, HttpServletRequest request) { + log.info("OAuth2 로그인 처리 시작: {}", email); + String token = jwtUtil.createToken(email, role, name, nickname, userId, 24 * 60 * 60 * 1000L); + CookieUtil.addJwtCookie(response, token, request); + log.info("OAuth2 로그인 JWT 쿠키 설정 완료: {}", email); + } + + @Transactional + public String sendVerificationCode(String email) { + if (userMapper.countByEmail(email) > 0) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + String code = emailService.generateVerificationCode(); + verificationStorageService.saveCode(email, code); + emailService.sendVerificationCode(email, code); + return "인증코드 발송 완료"; + } } diff --git a/src/main/java/org/bbagisix/user/service/VerificationStorageService.java b/src/main/java/org/bbagisix/user/service/VerificationStorageService.java new file mode 100644 index 00000000..2dfc9c72 --- /dev/null +++ b/src/main/java/org/bbagisix/user/service/VerificationStorageService.java @@ -0,0 +1,28 @@ +package org.bbagisix.user.service; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class VerificationStorageService { + + private final StringRedisTemplate redisTemplate; + private static final String KEY_PREFIX = "verification-code:"; + + public void saveCode(String email, String code) { + redisTemplate.opsForValue().set(KEY_PREFIX + email, code, Duration.ofMinutes(5)); + } + + public String getCode(String email) { + return redisTemplate.opsForValue().get(KEY_PREFIX + email); + } + + public void removeCode(String email) { + redisTemplate.delete(KEY_PREFIX + email); + } +} diff --git a/src/main/java/org/bbagisix/user/util/CookieUtil.java b/src/main/java/org/bbagisix/user/util/CookieUtil.java new file mode 100644 index 00000000..8b5e4559 --- /dev/null +++ b/src/main/java/org/bbagisix/user/util/CookieUtil.java @@ -0,0 +1,78 @@ +package org.bbagisix.user.util; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class CookieUtil { + + private static final String JWT_COOKIE_NAME = "accessToken"; + private static final int COOKIE_MAX_AGE = 24 * 60 * 60; // 24시간 (초 단위) + + public static void addJwtCookie(HttpServletResponse response, String token, HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + String sameSitePolicy = getSameSitePolicy(userAgent); + + String cookieHeader = String.format( + "%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure%s", + JWT_COOKIE_NAME, + token, + COOKIE_MAX_AGE, + sameSitePolicy + ); + response.addHeader("Set-Cookie", cookieHeader); + + // 삼성 브라우저/Safari용 추가 쿠키 (SameSite 없음) + if (isSamsungBrowser(userAgent) || isSafari(userAgent)) { + String fallbackCookieHeader = String.format( + "%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure", + JWT_COOKIE_NAME + "_fallback", + token, + COOKIE_MAX_AGE + ); + response.addHeader("Set-Cookie", fallbackCookieHeader); + } + } + + private static String getSameSitePolicy(String userAgent) { + if (userAgent == null) { + return "; SameSite=Lax"; + } + + // 삼성 브라우저나 Safari는 SameSite=Lax 사용 + if (isSamsungBrowser(userAgent) || isSafari(userAgent)) { + return "; SameSite=Lax"; + } + + // 크롬은 SameSite=None 사용 + return "; SameSite=None"; + } + + private static boolean isSamsungBrowser(String userAgent) { + return userAgent != null && userAgent.contains("SamsungBrowser"); + } + + private static boolean isSafari(String userAgent) { + return userAgent != null && + userAgent.contains("Safari") && + !userAgent.contains("Chrome") && + !userAgent.contains("Chromium"); + } + + public static void deleteJwtCookie(HttpServletResponse response) { + // 기본 쿠키 삭제 + String cookieHeader = String.format( + "%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + JWT_COOKIE_NAME + ); + response.addHeader("Set-Cookie", cookieHeader); + + // fallback 쿠키도 삭제 + String fallbackCookieHeader = String.format( + "%s=; Path=/; Max-Age=0; HttpOnly; Secure; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + JWT_COOKIE_NAME + "_fallback" + ); + response.addHeader("Set-Cookie", fallbackCookieHeader); + + System.out.println("쿠키 삭제 요청 전송됨: " + JWT_COOKIE_NAME); + } +} diff --git a/src/main/java/org/bbagisix/user/util/JwtUtil.java b/src/main/java/org/bbagisix/user/util/JwtUtil.java new file mode 100644 index 00000000..4e9de47c --- /dev/null +++ b/src/main/java/org/bbagisix/user/util/JwtUtil.java @@ -0,0 +1,67 @@ +package org.bbagisix.user.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtil { + + @Value("${JWT_SECRET}") + private String secretKey; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + public String createToken(String email, String role, String name, String nickname, Long userId, long expirationTime) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .setSubject(email) + .claim("role", role) + .claim("name", name) + .claim("email", email) + .claim("nickname", nickname) + .claim("userId", userId) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + private Claims getClaims(String token) { + return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody(); + } + + public String getName(String token) { + return getClaims(token).getSubject(); + } + + public String getRole(String token) { + return getClaims(token).get("role", String.class); + } + + public String getEmail(String token) { + return getClaims(token).get("email", String.class); + } + + public String getNickname(String token) { + return getClaims(token).get("nickname", String.class); + } + + public Long getUserId(String token) { + return getClaims(token).get("userId", Long.class); + } + + public boolean isExpired(String token) { + return getClaims(token).getExpiration().before(new Date()); + } +} diff --git a/src/main/resources/mappers/asset/AssetMapper.xml b/src/main/resources/mappers/asset/AssetMapper.xml index 01686023..6aeced4e 100644 --- a/src/main/resources/mappers/asset/AssetMapper.xml +++ b/src/main/resources/mappers/asset/AssetMapper.xml @@ -5,5 +5,145 @@ + + + + + + - \ No newline at end of file + + + + + + + + + + + + + + + INSERT INTO user_asset (user_id, + asset_name, + bank_name, + bank_account, + bank_id, + bank_pw, + connected_id, + balance, + created_at, + status) + VALUES (#{userId}, + #{assetName}, + #{bankName}, + #{bankAccount, typeHandler=org.bbagisix.asset.encryption.AESEncryptedTypeHandler}, + #{bankId, typeHandler=org.bbagisix.asset.encryption.AESEncryptedTypeHandler}, + #{bankPw, typeHandler=org.bbagisix.asset.encryption.AESEncryptedTypeHandler}, + #{connectedId, typeHandler=org.bbagisix.asset.encryption.AESEncryptedTypeHandler}, + #{balance}, + NOW(), + #{status}) + + + + + + + + delete + from user_asset + where user_id = #{userId} + AND status = #{status} + + + + DELETE + FROM expenditure + WHERE asset_id IN (SELECT asset_id + FROM user_asset + WHERE user_id = #{userId}) + + + + + + + + UPDATE user_asset + SET balance = #{newBalance} + WHERE asset_id = #{assetId} + + + + + + + + UPDATE user_asset + SET balance=balance + #{totalSaving} + WHERE asset_id = #{assetId} + + + diff --git a/src/main/resources/mappers/category/CategoryMapper.xml b/src/main/resources/mappers/category/CategoryMapper.xml index 648611c9..a5401629 100644 --- a/src/main/resources/mappers/category/CategoryMapper.xml +++ b/src/main/resources/mappers/category/CategoryMapper.xml @@ -5,9 +5,12 @@ \ No newline at end of file diff --git a/src/main/resources/mappers/challenge/ChallengeMapper.xml b/src/main/resources/mappers/challenge/ChallengeMapper.xml index 47056c12..707f11dd 100644 --- a/src/main/resources/mappers/challenge/ChallengeMapper.xml +++ b/src/main/resources/mappers/challenge/ChallengeMapper.xml @@ -1,9 +1,163 @@ - + + + + + + + + + + + + + + + INSERT INTO user_challenge (user_id, + challenge_id, + status, + period, + progress, + start_date, + end_date, + saving) + VALUES (#{userId}, + #{challengeId}, + 'ongoing', + #{period}, + 0, + NOW(), + DATE_ADD(NOW(), INTERVAL #{period} DAY), + #{saving}) + + + + + + + + + + + + + + + + + update user_challenge + set status=#{status}, + progress=#{progress}, + end_date=#{endDate} + where user_challenge_id = #{userChallengeId} + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mappers/chat/ChatMapper.xml b/src/main/resources/mappers/chat/ChatMapper.xml index 79ee6b76..0dd6f52a 100644 --- a/src/main/resources/mappers/chat/ChatMapper.xml +++ b/src/main/resources/mappers/chat/ChatMapper.xml @@ -5,5 +5,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO chat_message (challenge_id, + user_id, + message, + sent_at, + message_type) + VALUES (#{challengeId}, + #{userId}, + #{message}, + #{sentAt}, + #{messageType}) + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mappers/codef/CodefAccessTokenMapper.xml b/src/main/resources/mappers/codef/CodefAccessTokenMapper.xml new file mode 100644 index 00000000..238de8dd --- /dev/null +++ b/src/main/resources/mappers/codef/CodefAccessTokenMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + insert into access_token (access_token, expires_at) + values (#{accessToken}, #{expiresAt}) + + + + + update access_token + set access_token = #{accessToken}, + expires_at = #{expiresAt} + where token_id = #{tokenId} + + \ No newline at end of file diff --git a/src/main/resources/mappers/expense/ExpenseMapper.xml b/src/main/resources/mappers/expense/ExpenseMapper.xml index 35346d2b..790c86da 100644 --- a/src/main/resources/mappers/expense/ExpenseMapper.xml +++ b/src/main/resources/mappers/expense/ExpenseMapper.xml @@ -4,6 +4,161 @@ "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + INSERT INTO expenditure (user_id, category_id, asset_id, amount, description, expenditure_date, user_modified, codef_transaction_id) + VALUES (#{userId}, #{categoryId}, #{assetId}, #{amount}, #{description}, #{expenditureDate}, #{userModified}, #{codefTransactionId}) + + + + + + UPDATE expenditure + SET category_id = #{categoryId}, + asset_id = #{assetId}, + amount = #{amount}, + description = #{description}, + expenditure_date = #{expenditureDate}, + user_modified = TRUE + WHERE expenditure_id = #{expenditureId} + AND user_id = #{userId} + AND deleted_at IS NULL + + + + + + + + + + + INSERT INTO expenditure ( + user_id, + asset_id, + category_id, + amount, + description, + expenditure_date, + created_at, + user_modified, + codef_transaction_id + ) VALUES + + ( + #{expense.userId}, + #{expense.assetId}, + #{expense.categoryId}, + #{expense.amount}, + #{expense.description}, + #{expense.expenditureDate}, + NOW(), + COALESCE(#{expense.userModified}, FALSE), + #{expense.codefTransactionId} + ) + + + + + + + + + UPDATE expenditure + SET deleted_at = NOW(), + user_modified = TRUE + WHERE expenditure_id = #{param1} + AND user_id = #{param2} + AND deleted_at IS NULL + + + \ No newline at end of file diff --git a/src/main/resources/mappers/finproduct/FinProductMapper.xml b/src/main/resources/mappers/finproduct/FinProductMapper.xml new file mode 100644 index 00000000..c4d067e7 --- /dev/null +++ b/src/main/resources/mappers/finproduct/FinProductMapper.xml @@ -0,0 +1,127 @@ + + + + + + INSERT INTO saving_base ( + dcls_month, fin_co_no, fin_prdt_cd, kor_co_nm, fin_prdt_nm, + join_way, mtrt_int, spcl_cnd, join_deny, join_member, + etc_note, max_limit, dcls_strt_day, dcls_end_day, fin_co_subm_day + ) + VALUES ( + #{dclsMonth}, #{finCoNo}, #{finPrdtCd}, #{korCoNm}, #{finPrdtNm}, + #{joinWay}, #{mtrtInt}, #{spclCnd}, #{joinDeny}, #{joinMember}, + #{etcNote}, #{maxLimit}, #{dclsStrtDay}, #{dclsEndDay}, #{finCoSubmDay} + ) + ON DUPLICATE KEY UPDATE + kor_co_nm = VALUES(kor_co_nm), + fin_prdt_nm = VALUES(fin_prdt_nm), + join_way = VALUES(join_way), + mtrt_int = VALUES(mtrt_int), + spcl_cnd = VALUES(spcl_cnd), + join_deny = VALUES(join_deny), + join_member = VALUES(join_member), + etc_note = VALUES(etc_note), + max_limit = VALUES(max_limit), + dcls_strt_day = VALUES(dcls_strt_day), + dcls_end_day = VALUES(dcls_end_day), + fin_co_subm_day = VALUES(fin_co_subm_day); + + + + INSERT INTO saving_option ( + saving_base_id, dcls_month, fin_co_no, fin_prdt_cd, intr_rate_type, + intr_rate_type_nm, rsrv_type, rsrv_type_nm, save_trm, intr_rate, intr_rate2 + ) + VALUES ( + #{savingBaseId}, #{dclsMonth}, #{finCoNo}, #{finPrdtCd}, #{intrRateType}, + #{intrRateTypeNm}, #{rsrvType}, #{rsrvTypeNm}, #{saveTrm}, #{intrRate}, #{intrRate2} + ) + ON DUPLICATE KEY UPDATE + intr_rate_type = VALUES(intr_rate_type), + intr_rate_type_nm = VALUES(intr_rate_type_nm), + intr_rate = VALUES(intr_rate), + intr_rate2 = VALUES(intr_rate2); + + + + + + + + + diff --git a/src/main/resources/mappers/reward/RewardMapper.xml b/src/main/resources/mappers/reward/RewardMapper.xml deleted file mode 100644 index 3fc0b8bb..00000000 --- a/src/main/resources/mappers/reward/RewardMapper.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/main/resources/mappers/saving/SavingMapper.xml b/src/main/resources/mappers/saving/SavingMapper.xml new file mode 100644 index 00000000..5b216dae --- /dev/null +++ b/src/main/resources/mappers/saving/SavingMapper.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mappers/tier/TierMapper.xml b/src/main/resources/mappers/tier/TierMapper.xml new file mode 100644 index 00000000..e19c21c9 --- /dev/null +++ b/src/main/resources/mappers/tier/TierMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mappers/user/UserMapper.xml b/src/main/resources/mappers/user/UserMapper.xml index ca45da2f..e5683d79 100644 --- a/src/main/resources/mappers/user/UserMapper.xml +++ b/src/main/resources/mappers/user/UserMapper.xml @@ -5,5 +5,87 @@ + + insert into user (name, nickname, password, email, age, email_verified, role, job, social_id) + values (#{name}, #{nickname}, #{password}, #{email}, #{age}, #{emailVerified}, #{role}, #{job}, #{socialId}) + + + + + + + + + + + + + + + + + + + update user + set nickname = #{nickname} + where user_id = #{userId} + + + + update user + set asset_connected = #{assetConnected} + where user_id = #{userId} + + + + update user + set saving_connected = #{savingConnected} + where user_id = #{userId} + + + + update user + set tier_id = #{tierId} + where user_id = #{userId} + + + \ No newline at end of file diff --git a/src/main/resources/mybatis-config.xml b/src/main/resources/mybatis-config.xml index c0889755..46f1e8d1 100644 --- a/src/main/resources/mybatis-config.xml +++ b/src/main/resources/mybatis-config.xml @@ -3,4 +3,12 @@ PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/chat-test.jsp b/src/main/webapp/WEB-INF/views/chat-test.jsp index 7a4f12aa..5e51dd31 100644 --- a/src/main/webapp/WEB-INF/views/chat-test.jsp +++ b/src/main/webapp/WEB-INF/views/chat-test.jsp @@ -1,51 +1,238 @@ <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> -<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> +<% + String contextPath = request.getContextPath(); +%> - WebSocket Echo Test + 챌린지 채팅 테스트 + + -

WebSocket Echo 테스트

+

챌린지 채팅방 테스트

- - +

현재 채팅방: 없음

+ + + +<%----%> +<%----%> + + + + + + + + + +

-
+
연결되지 않음
+ +
+ + + + +
메시지 로그:
- + \ No newline at end of file diff --git a/src/test/java/org/bbagisix/chat/handler/ChatWebSocketHandlerTest.java b/src/test/java/org/bbagisix/chat/handler/ChatWebSocketHandlerTest.java deleted file mode 100644 index fe0eec87..00000000 --- a/src/test/java/org/bbagisix/chat/handler/ChatWebSocketHandlerTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.bbagisix.chat.handler; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.io.IOException; - -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; - -public class ChatWebSocketHandlerTest { - - @Test - void 메시지를_받으면_Echo로_응답한다() throws Exception { - // given - ChatWebSocketHandler handler = new ChatWebSocketHandler(); - WebSocketSession session = mock(WebSocketSession.class); - TextMessage incoming = new TextMessage("hello"); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TextMessage.class); - - // when - handler.handleTextMessage(session, incoming); - - // then - verify(session).sendMessage(captor.capture()); - assertEquals("Echo: hello", captor.getValue().getPayload()); - } -} diff --git a/src/main/java/org/bbagisix/config/RootConfig.java b/src/test/java/org/bbagisix/config/TestRootConfig.java similarity index 79% rename from src/main/java/org/bbagisix/config/RootConfig.java rename to src/test/java/org/bbagisix/config/TestRootConfig.java index 6fb72655..b965879b 100644 --- a/src/main/java/org/bbagisix/config/RootConfig.java +++ b/src/test/java/org/bbagisix/config/TestRootConfig.java @@ -1,7 +1,9 @@ package org.bbagisix.config; -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.sql.DataSource; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.logging.log4j.LogManager; @@ -19,47 +21,42 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ControllerAdvice; -import javax.sql.DataSource; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; @Configuration -@PropertySource({"classpath:/application.properties"}) +@PropertySource({"classpath:/application-test.properties"}) @MapperScan(basePackages = {"org.bbagisix.**.mapper"}) @ComponentScan( - basePackages = "org.bbagisix", // 스캔 범위는 동일 - excludeFilters = { // 제외할 필터를 지정 + basePackages = { + "org.bbagisix.expense", + "org.bbagisix.category", + "org.bbagisix.user", + "org.bbagisix.analytics", + "org.bbagisix.classify", + }, + excludeFilters = { @ComponentScan.Filter(type = org.springframework.context.annotation.FilterType.ANNOTATION, classes = { Controller.class, ControllerAdvice.class}) } ) -public class RootConfig { - private static final Logger log = LogManager.getLogger(RootConfig.class); +public class TestRootConfig { + private static final Logger log = LogManager.getLogger(TestRootConfig.class); @Value("${jdbc.driver}") String driver; - - // docker-compose.yml로부터 환경 변수 주입 - @Value("${DB_HOST:localhost}") - String dbHost; - @Value("${DB_PORT:3306}") - String dbPort; - @Value("${DB_NAME:dondothat}") - String dbName; - @Value("${DB_USER:root}") + @Value("${jdbc.url}") + String url; + @Value("${jdbc.username}") String username; - @Value("${DB_PASSWORD:1234}") + @Value("${jdbc.password}") String password; @Bean public DataSource dataSource() { HikariConfig config = new HikariConfig(); - // 환경 변수를 사용하여 JDBC URL 구성 - String jdbcUrl = String.format("jdbc:log4jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName); - config.setDriverClassName(driver); - config.setJdbcUrl(jdbcUrl); + config.setJdbcUrl(url); config.setUsername(username); config.setPassword(password); @@ -77,13 +74,13 @@ public DataSource dataSource() { HikariDataSource dataSource = new HikariDataSource(config); - log.info("DB Connection: {}", maskDbUrl(jdbcUrl)); + log.info("DB Connection: {}", maskDbUrl(url)); return dataSource; } private String maskDbUrl(String url) { - // jdbc:log4jdbc:mysql://mysql-server:3306/dondothat -> jdbc:log4jdbc:mysql://my...er:3306/dondothat + // jdbc:log4jdbc:mysql://localhost:3306/dondothat -> jdbc:log4jdbc:mysql://lo...st:3306/dondothat Pattern pattern = Pattern.compile("(?<=//)([^:/]+)"); Matcher matcher = pattern.matcher(url); if (matcher.find()) { @@ -118,4 +115,4 @@ public DataSourceTransactionManager transactionManager() { return manager; } -} +} \ No newline at end of file diff --git a/src/test/java/org/bbagisix/tier/controller/TierControllerTest.java b/src/test/java/org/bbagisix/tier/controller/TierControllerTest.java new file mode 100644 index 00000000..f31474e5 --- /dev/null +++ b/src/test/java/org/bbagisix/tier/controller/TierControllerTest.java @@ -0,0 +1,192 @@ +package org.bbagisix.tier.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import org.bbagisix.tier.dto.TierDTO; +import org.bbagisix.tier.service.TierService; +import org.bbagisix.user.dto.CustomOAuth2User; +import org.bbagisix.user.dto.UserResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class TierControllerTest { + + private MockMvc mockMvc; + + @Mock + private TierService tierService; + + @InjectMocks + private TierController tierController; + + private ObjectMapper objectMapper = new ObjectMapper(); + private TierDTO bronzeTier; + private TierDTO silverTier; + private TierDTO goldTier; + private CustomOAuth2User mockUser; + private Authentication mockAuthentication; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(tierController).build(); + + // 테스트 데이터 준비 + bronzeTier = TierDTO.builder() + .tierId(1L) + .name("브론즈") + .build(); + + silverTier = TierDTO.builder() + .tierId(2L) + .name("실버") + .build(); + + goldTier = TierDTO.builder() + .tierId(3L) + .name("골드") + .build(); + + // Mock 사용자 생성 + UserResponse userResponse = UserResponse.builder() + .userId(1L) + .name("테스트유저") + .email("test@test.com") + .nickname("테스트닉네임") + .role("ROLE_USER") + .build(); + + mockUser = new CustomOAuth2User(userResponse); + mockAuthentication = new UsernamePasswordAuthenticationToken( + mockUser, null, mockUser.getAuthorities()); + } + + @Test + @DisplayName("사용자 현재 티어 조회 테스트 - 성공") + void getUserCurrentTier_Success() throws Exception { + // given + when(tierService.getUserCurrentTier(1L)).thenReturn(silverTier); + + // when + MvcResult result = mockMvc.perform(get("/api/user/me/tiers") + .principal(mockAuthentication) + .characterEncoding("UTF-8")) + .andExpect(status().isOk()) + .andReturn(); + + // then - JSON 응답을 UTF-8로 읽기 + String jsonResponse = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + TierDTO responseDto = objectMapper.readValue(jsonResponse, TierDTO.class); + + assertEquals(2L, responseDto.getTierId()); + assertEquals("실버", responseDto.getName()); + + verify(tierService, times(1)).getUserCurrentTier(1L); + } + + @Test + @DisplayName("사용자 현재 티어 조회 테스트 - 티어 없음") + void getUserCurrentTier_NoTier() throws Exception { + // given + when(tierService.getUserCurrentTier(1L)).thenReturn(null); + + // when + MvcResult result = mockMvc.perform(get("/api/user/me/tiers") + .principal(mockAuthentication) + .characterEncoding("UTF-8")) + .andExpect(status().isOk()) + .andReturn(); + + // then + String content = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertTrue(content.isEmpty() || content.equals("null")); + + verify(tierService, times(1)).getUserCurrentTier(1L); + } + + @Test + @DisplayName("전체 티어 목록 조회 테스트 - 성공") + void getAllTiers_Success() throws Exception { + // given + List allTiers = Arrays.asList(bronzeTier, silverTier, goldTier); + when(tierService.getAllTiers()).thenReturn(allTiers); + + // when + MvcResult result = mockMvc.perform(get("/api/tiers/all") + .principal(mockAuthentication) + .characterEncoding("UTF-8")) + .andExpect(status().isOk()) + .andReturn(); + + // then - JSON 배열을 UTF-8로 읽기 + String jsonResponse = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + TierDTO[] responseArray = objectMapper.readValue(jsonResponse, TierDTO[].class); + + assertEquals(3, responseArray.length); + assertEquals(1L, responseArray[0].getTierId()); + assertEquals("브론즈", responseArray[0].getName()); + assertEquals(2L, responseArray[1].getTierId()); + assertEquals("실버", responseArray[1].getName()); + assertEquals(3L, responseArray[2].getTierId()); + assertEquals("골드", responseArray[2].getName()); + + verify(tierService, times(1)).getAllTiers(); + } + + @Test + @DisplayName("컨트롤러 직접 호출 테스트 - getUserCurrentTier") + void directCall_getUserCurrentTier() { + // given + when(tierService.getUserCurrentTier(1L)).thenReturn(silverTier); + + // when + ResponseEntity response = tierController.getUserCurrentTier(mockAuthentication); + + // then + assertEquals(200, response.getStatusCodeValue()); + assertEquals(2L, response.getBody().getTierId()); + assertEquals("실버", response.getBody().getName()); + + verify(tierService, times(1)).getUserCurrentTier(1L); + } + + @Test + @DisplayName("컨트롤러 직접 호출 테스트 - getAllTiers") + void directCall_getAllTiers() { + // given + List allTiers = Arrays.asList(bronzeTier, silverTier, goldTier); + when(tierService.getAllTiers()).thenReturn(allTiers); + + // when + ResponseEntity> response = tierController.getAllTiers(); + + // then + assertEquals(200, response.getStatusCodeValue()); + assertEquals(3, response.getBody().size()); + assertEquals("브론즈", response.getBody().get(0).getName()); + assertEquals("실버", response.getBody().get(1).getName()); + assertEquals("골드", response.getBody().get(2).getName()); + + verify(tierService, times(1)).getAllTiers(); + } +} \ No newline at end of file diff --git a/src/test/java/org/bbagisix/tier/service/TierServiceTest.java b/src/test/java/org/bbagisix/tier/service/TierServiceTest.java new file mode 100644 index 00000000..aa603d2f --- /dev/null +++ b/src/test/java/org/bbagisix/tier/service/TierServiceTest.java @@ -0,0 +1,181 @@ +package org.bbagisix.tier.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; + +import org.bbagisix.tier.domain.TierVO; +import org.bbagisix.tier.dto.TierDTO; +import org.bbagisix.tier.mapper.TierMapper; +import org.bbagisix.user.domain.UserVO; +import org.bbagisix.user.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TierServiceTest { + + @Mock + private TierMapper tierMapper; + + @Mock + private UserMapper userMapper; + + @InjectMocks + private TierService tierService; + + private TierVO bronzeTier; + private TierVO silverTier; + private TierVO goldTier; + private UserVO testUser; + + @BeforeEach + void setUp() { + // 테스트 데이터 준비 + bronzeTier = TierVO.builder() + .tierId(1L) + .name("브론즈") + .build(); + + silverTier = TierVO.builder() + .tierId(2L) + .name("실버") + .build(); + + goldTier = TierVO.builder() + .tierId(3L) + .name("골드") + .build(); + + testUser = UserVO.builder() + .userId(1L) + .name("테스트유저") + .email("test@test.com") + .tierId(1L) // 브론즈 티어 + .build(); + } + + @Test + @DisplayName("전체 티어 목록 조회 테스트") + void getAllTiers() { + // given + List mockTiers = Arrays.asList(bronzeTier, silverTier, goldTier); + when(tierMapper.findAllTiers()).thenReturn(mockTiers); + + // when + List result = tierService.getAllTiers(); + + // then + assertEquals(3, result.size()); + assertEquals("브론즈", result.get(0).getName()); + assertEquals("실버", result.get(1).getName()); + assertEquals("골드", result.get(2).getName()); + + verify(tierMapper, times(1)).findAllTiers(); + } + + @Test + @DisplayName("사용자 현재 티어 조회 테스트 - 정상") + void getUserCurrentTier_Success() { + // given + when(userMapper.findByUserId(1L)).thenReturn(testUser); + when(tierMapper.findById(1L)).thenReturn(bronzeTier); + + // when + TierDTO result = tierService.getUserCurrentTier(1L); + + // then + assertNotNull(result); + assertEquals(1L, result.getTierId()); + assertEquals("브론즈", result.getName()); + + verify(userMapper, times(1)).findByUserId(1L); + verify(tierMapper, times(1)).findById(1L); + } + + @Test + @DisplayName("사용자 현재 티어 조회 테스트 - tier_id가 null") + void getUserCurrentTier_TierIdNull() { + // given + UserVO userWithoutTier = UserVO.builder() + .userId(1L) + .name("신규유저") + .tierId(null) // 티어 없음 + .build(); + + when(userMapper.findByUserId(1L)).thenReturn(userWithoutTier); + + // when + TierDTO result = tierService.getUserCurrentTier(1L); + + // then + assertNull(result); + verify(userMapper, times(1)).findByUserId(1L); + verify(tierMapper, never()).findById(any()); + } + + @Test + @DisplayName("티어 승급 테스트 - 첫 승급 (null → 1)") + void promoteUserTier_FirstPromotion() { + // given + UserVO newUser = UserVO.builder() + .userId(1L) + .tierId(null) // 아직 티어 없음 + .build(); + + when(userMapper.findByUserId(1L)).thenReturn(newUser); + when(tierMapper.findById(1L)).thenReturn(bronzeTier); + + // when + tierService.promoteUserTier(1L); + + // then + verify(userMapper, times(1)).findByUserId(1L); + verify(tierMapper, times(1)).findById(1L); + verify(userMapper, times(1)).updateUserTier(1L, 1L); + } + + @Test + @DisplayName("티어 승급 테스트 - 일반 승급 (1 → 2)") + void promoteUserTier_NormalPromotion() { + // given + when(userMapper.findByUserId(1L)).thenReturn(testUser); + when(tierMapper.findById(2L)).thenReturn(silverTier); + + // when + tierService.promoteUserTier(1L); + + // then + verify(userMapper, times(1)).findByUserId(1L); + verify(tierMapper, times(1)).findById(2L); + verify(userMapper, times(1)).updateUserTier(1L, 2L); + } + + @Test + @DisplayName("티어 승급 테스트 - 최고 티어 (승급 안됨)") + void promoteUserTier_MaxTier() { + // given + UserVO maxTierUser = UserVO.builder() + .userId(1L) + .tierId(3L) // 골드 티어 (최고) + .build(); + + when(userMapper.findByUserId(1L)).thenReturn(maxTierUser); + when(tierMapper.findById(4L)).thenReturn(null); // 다음 티어 없음 + + // when + tierService.promoteUserTier(1L); + + // then + verify(userMapper, times(1)).findByUserId(1L); + verify(tierMapper, times(1)).findById(4L); + verify(userMapper, never()).updateUserTier(any(), any()); // 승급 안됨 + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 00000000..521d396a --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,12 @@ +jdbc.driver=net.sf.log4jdbc.sql.jdbcapi.DriverSpy +jdbc.url=jdbc:log4jdbc:mysql://localhost:3306/dondothat +jdbc.username=root +jdbc.password=1234 + +# ???? ?? ?? +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.connection-timeout=20000 +spring.datasource.hikari.validation-timeout=5000 + +logging.level.org.bbagisix=DEBUG +logging.level.org.springframework.web=DEBUG \ No newline at end of file diff --git a/src/test/resources/mybatis-config.xml b/src/test/resources/mybatis-config.xml deleted file mode 100644 index 4954b21f..00000000 --- a/src/test/resources/mybatis-config.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/test/resources/org/bbagisix/mapper/TestMapper.xml b/src/test/resources/org/bbagisix/mapper/TestMapper.xml deleted file mode 100644 index 0749fecc..00000000 --- a/src/test/resources/org/bbagisix/mapper/TestMapper.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - -