diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml index 31cbed4..6097c03 100644 --- a/.github/ISSUE_TEMPLATE/issue-form.yml +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -14,6 +14,7 @@ body: - 'refactor: 코드 리팩토링' - 'docs: 문서 작업' - 'chore: 빌드, 설정 등 기타 작업' + - 'perf: 최적화' validations: required: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..509c0e5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,77 @@ +name: Deploy to OCI +on: + push: + branches: [ dev ] + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + 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: Build with Gradle + run: ./gradlew clean build -x test + - name: Send to Server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_IP }} + username: ubuntu + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: "build/libs/AccountBookForMoms-0.0.1-SNAPSHOT.jar,docker-compose.yml,Dockerfile,nginx.conf" + target: "/home/ubuntu/app" + - name: Create secret file + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_IP }} + username: ubuntu + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cat << 'EOF' > /home/ubuntu/app/application-secret.yml + ${{ secrets.APPLICATION_SECRET }} + EOF + - name: Create FCM secret JSON file + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_IP }} + username: ubuntu + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cat << 'EOF' > /home/ubuntu/app/firebase-service-account.json + ${{ secrets.FCM_JSON_CONFIG }} + EOF + - name: Run on Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_IP }} + username: ubuntu + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /home/ubuntu/app && \ + cp build/libs/AccountBookForMoms-0.0.1-SNAPSHOT.jar . && \ + sudo docker compose up -d --no-deps --build app --remove-orphans && \ + sudo docker compose restart nginx + - name: Send Discord Notification + if: always() + run: | + if [ "${{ job.status }}" == "success" ]; then + STATUS="✅ 배포 성공!" + COLOR=5763719 + else + STATUS="❌ 배포 실패!" + COLOR=15548997 + fi + TITLE="백엔드 CI/CD 알림" + BRANCH="${{ github.ref_name }}" + COMMIT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -n 1 | sed 's/"/\\"/g') + LOG_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + curl -H "Content-Type: application/json" \ + -d "{\"embeds\": [{\"title\": \"$TITLE\", \"color\": $COLOR, \"description\": \"**결과:** $STATUS\n**브랜치:** \`$BRANCH\`\n**커밋:** $COMMIT_MSG\n\n[빌드 결과 확인하기]($LOG_URL)\"}]}" \ + ${{ secrets.DISCORD_WEBHOOK }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c044170..da81715 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,10 @@ out/ .DS_Store ### JWT Secret Key ### +application-secret.yml src/main/resources/application-secret.yml +### FCM Secret Key ### +src/main/resources/jointliving-notification.json +*.jar +frontend/node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74c1604 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:17-jre-jammy + +WORKDIR /app + +COPY *.jar app.jar + +ENTRYPOINT ["java", "-Dserver.address=0.0.0.0", "-Xms256m", "-Xmx512m", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index c26e364..03ba949 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,41 @@ # Joint Living / 공동구매 주부를 위한 가계부 포트폴리오 및 공동구매 커뮤니티 -# 기능 +> **"주부를 위한 스마트한 가계부 포트폴리오 및 공동구매 커뮤니티"** +> +> MODI는 체계적인 자산 관리부터 주변 이웃과의 예산 비교, 그리고 합리적인 소비를 위한 공동구매까지 지원하는 종합 가계부 플랫폼입니다. -## 회원 관리 -### 기술: Spring Security + JWT + OAuth2 Client / Redis / FCM + Spring Application Event / Spring Boot Starter Mail + Google SMTP -### Entity: User, UserSetting, Notification, InterestCategory -- **인증 및 로그인**: 일반 회원가입/로그인, 소셜 간편 로그인 연동, 로그인 유지 기능 -- **계정 관리**: 이메일 인증을 통한 계정 찾기, 로그아웃, 회원 탈퇴 -- **권한 및 프로필**: 일반/관리자 권한 접근 제어, 마이페이지 프로필 설정 -- **사용자 맞춤 설정**: 정기 고정 수입/지출 스케줄 설정 -- **알림 설정**: 예산 소진 알림 조건 설정, 공동구매 관심 카테고리 알림 설정 +## 🚀 프로젝트 핵심 기능 (Core Features) -## 가계부 포트폴리오 -### 기술: RDB(DB Indexing) / Redis(Caching) / Spring @Scheduled + ShedLock / Apache POI -### Entity: Account, Transaction, FixedTransaction, Category, Budget -- **자산 및 내역 관리**: 계좌 초기 잔고 설정 및 실시간 잔고 업데이트, 수입/지출 내역 -- **카테고리 관리**: 시스템 기본 카테고리 제공 및 사용자 맞춤형 커스텀 카테고리 기능 -- **소비 분석**: 월별 및 사용자 지정 기간별 수입/지출 총액 및 상세 내역 조회 -- **예산 관리**: 다가오는 달 및 이번 달 총 예산 설정, 추가 예상 지출 지정 -- **데이터 관리 및 공유**: 가계부 내역 파일 내보내기(CSV/Excel 다운로드), 포트폴리오 타인 공개 여부 설정 +### 🔐 1. 회원 및 인증 관리 (Identity & Access) +* **인증 인가:** Spring Security + JWT, OAuth2 Client를 활용한 간편 로그인 +* **알림 시스템:** FCM (Firebase Cloud Messaging) + Spring Application Event를 활용한 비동기 알림 처리 (예산 소진, 관심 공동구매 알림 등) +* **계정/권한:** 이메일(Google SMTP) 기반 인증 및 관리자/일반 유저 권한 제어 +* **맞춤 설정:** 정기 고정 수입/지출 스케줄링 및 개인 프로필 관리 +### 📊 2. 스마트 가계부 포트폴리오 (Account Book) +* **자산 추적:** 계좌 초기 잔고 설정 및 실시간 변동 내역 반영 (DB Indexing 활용) +* **소비 분석:** 커스텀 카테고리 지원, 월별/기간별 지출 통계 (Redis Caching으로 조회 성능 극대화) +* **예산 관리:** 이번 달/다음 달 예산 설정 및 추가 지출 추적 +* **자동화 및 내보내기:** Spring `@Scheduled` + `ShedLock`을 이용한 분산 스케줄링 제어, Apache POI를 활용한 엑셀/CSV 데이터 내보내기 +### 🗺️ 3. 위치 기반 예산/지출 비교 (Location-based Comparison) +* **초개인화 분석:** 나와 타 사용자의 연령대별, 카테고리별, 단순 금액별 예산/수입/지출 비교 +* **지리적 데이터 연동:** Kakao Geocoding API와 Redis GEO를 결합하여 내 주변(반경 N km) 이웃들의 평균 소비 포트폴리오를 도출 및 비교 +### 🤝 4. 커뮤니티 및 노하우 공유 (Community Q&A) +* **게시판 구성:** 자유로운 Q&A 및 주부 노하우 공유 특화 게시판 +* **빠른 검색:** Elasticsearch 연동을 통한 대용량 게시글/제목 초고속 검색 지원 +* **계층형 댓글:** JPA + QueryDSL을 이용한 복잡한 대댓글 쿼리 최적화 +### 🛒 5. 실시간 공동구매 시스템 (Group Purchase) +* **모집 및 매칭:** 구매할 물품과 목표 인원을 등록하여 모집 +* **동시성 제어:** 다수의 사용자가 단기간에 몰릴 때 발생하는 동시성 이슈를 RDB Lock 및 Redis를 통해 안전하게 방어 +* **상태 관리 및 알림:** Spring Scheduler + EventListener로 제한시간 내 목표 인원 도달 여부를 판별하여 상태를 자동 업데이트하고 참여자들에게 결과 알림 전송 +* **결제 안내:** 목표 달성 시 1인당 결제 금액 및 입금 계좌 고지 +--- +## 🛠 기술 스택 (Tech Stack) +### Backend Ecosystem +- **Framework:** Java, Spring Boot, Spring Security, Spring Data JPA, QueryDSL +- **Database:** MySQL (RDB), Redis (Cache, GEO, Concurrency), Elasticsearch (Search Engine) +- **Messaging & Event:** FCM (Push Notification), Spring Application Event, Google SMTP +- **Task Scheduling:** Spring `@Scheduled`, ShedLock (다중 서버 스케줄링 동기화) +- **External API:** Kakao Geocoding API +- **Utils:** Apache POI (Excel Export) -## 다른 사용자와의 예산 및 지출 비교 -### 기술: DB Indexing / Spring Scheduler / Kakao Geocoding API / Redis GEO -### Entity: - -- 타 사용자와의 예산, 지출, 수입 비교 -- 나이대, 단순 금액, 카테고리 별 비교 -- 위치 기반으로 사용자 근처 사용자들의 평균 수입 포트폴리오와의 나이대, 단순 금액, 카테고리 별 비교 - -## Q&A 게시판 및 노하우 공유 게시판 -### 기술: RDB / JPA / QueryDSL / Elasticsearch / Redis Cache -### Entity: Board, Comment, Image -- 특정 주제에 대해 Q&A를 진행 가능한 게시판 -- 특정 주제에 대한 노하우 공유 게시판 -- 게시물 목록 조회, 작성, 수정, 삭제, 조회, 검색 -- 댓글 작성, 댓글 수정, 댓글 삭제, 대댓글 작성, 대댓글 수정, 대댓글 삭제 - -## 공동구매 -### 기술: RDB 동시성 제어 / Redis / Spring Schedular / EventListener -### Entity: 공동구매게시글, 공동구매참여인원정보 -- 공동구매를 원하는 물품, 인원 수 등록 -- 관심 카테고리로 설정해 둔 사용자에게 알림 전송 -- 제한시간 내 인원이 모이지 않는다면 - - 지원한 사용자들에게 해당 알림 전송 -- 제한시간 내 인원이 모인다면 - - 참여 인원 저장 - - 1인당 결제 금액 고지 - - 입금 계좌 고지 - - 여유 되면 PG 붙여보기 diff --git a/build.gradle b/build.gradle index 1739dd9..a2782e4 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'jacoco' id 'org.springframework.boot' version '3.2.2' // 🔥 가장 안정적인 버전으로 고정 id 'io.spring.dependency-management' version '1.1.4' } @@ -46,8 +47,149 @@ dependencies { // 6. 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + // 6-b. 벤치마크 (Testcontainers + datasource-proxy) + // testcontainers 1.19.x 는 docker-java 3.3.x → API v1.32 로 최신 Docker(v1.40+) 와 호환 안 됨. + // 1.20+ 로 강제 upgrade. + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:testcontainers:1.20.4' + testImplementation 'org.testcontainers:junit-jupiter:1.20.4' + testImplementation 'org.testcontainers:mysql:1.20.4' + testImplementation 'net.ttddyy:datasource-proxy:1.9' + + // 7. Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.1' + + // 8. OAuth2 Client (소셜 로그인) + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // 9. Elasticsearch (게시판 검색 — nori 분석기) + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' + + // 10. Firebase Admin SDK (FCM 알림 발송) + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // 11. ShedLock (분산 환경에서 @Scheduled 중복 실행 방지) + implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0' + implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0' + // 11. 이메일 전송 + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // 12. Excel 파일 생성 + implementation 'org.apache.poi:poi:5.2.3' + implementation 'org.apache.poi:poi-ooxml:5.2.3' + + // 13. AWS S3 (파일 업로드) + implementation 'software.amazon.awssdk:s3:2.23.14' +} + +// 벤치마크 컴파일이 pre-existing GroupPurchase 테스트 컴파일 오류로 실패하지 않도록 +// 벤치 실행 시에만 문제되는 파일들을 제외 (일반 test 태스크는 그대로 유지 시 실패). +// (일반 test 도 현재 동일 이유로 깨져있는 상태이므로 아래 제외 목록으로 임시 우회) +def brokenTestSources = [ + '**/grouppruchase/application/GroupPurchaseServiceTest.java', + '**/grouppruchase/api/GroupPurchaseCategoryControllerTest.java', + '**/grouppruchase/api/GroupPurchaseControllerTest.java' +] + +sourceSets { + test { + java { + brokenTestSources.each { exclude it } + } + } } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + excludeTags 'benchmark' + } + finalizedBy jacocoTestReport +} + +// 벤치마크 전용 태스크 (일반 test 에서는 excludeTags 로 제외) +tasks.register('bench', Test) { + description = 'Runs DB I/O benchmarks (tagged @Tag("benchmark"))' + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'benchmark' + } + // 시스템 프로퍼티 pass-through (bench.batch.size, bench.run.scenarios 등) + systemProperties System.getProperties().findAll { k, v -> k.toString().startsWith('bench.') } + // OrbStack / Docker Desktop 소켓 지정 (testcontainers 자동 탐지 실패 대비) + // docker-java 3.4+ 는 기본 API version=1.41 를 사용하지만, 환경변수 우선순위가 있으므로 명시. + environment 'DOCKER_HOST', System.getenv('DOCKER_HOST') ?: 'unix:///Users/' + System.getProperty('user.name') + '/.orbstack/run/docker.sock' + environment 'TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE', '/var/run/docker.sock' + environment 'DOCKER_API_VERSION', '1.43' + systemProperty 'api.version', '1.43' + // 벤치는 리소스 많이 씀 + maxParallelForks = 1 + forkEvery = 0 + testLogging { + showStandardStreams = true + events 'started', 'passed', 'failed', 'skipped' + } +} + +jacoco { + toolVersion = '0.8.12' } + +def jacocoExcludes = [ + '**/AccountBookForMomsApplication*', + '**/dto/**', + '**/entity/**', + '**/enums/**', + '**/error/**', + '**/global/config/**', + '**/global/common/**', + '**/global/event/**', + '**/domain/grouppruchase/domain/**', +] + +jacocoTestReport { + dependsOn test + reports { + html.required = true + xml.required = true + } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExcludes) + })) + } +} + +// 점진 적용: 테스트가 갖춰진 도메인만 검증 대상. 새 도메인 테스트 추가 시 includes에 합류시킨다. +def jacocoVerificationIncludes = [ + '**/domain/board/application/**', +] + +jacocoTestCoverageVerification { + dependsOn test + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, includes: jacocoVerificationIncludes, excludes: jacocoExcludes) + })) + } + violationRules { + rule { + element = 'BUNDLE' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.8 + } + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.8 + } + } + } +} + +check.dependsOn jacocoTestCoverageVerification diff --git a/claude.md b/claude.md index acc6b3b..268f5ff 100644 --- a/claude.md +++ b/claude.md @@ -111,4 +111,311 @@ ### 비교 페이지 - 예산/수입/지출/종합 비교는 단일 페이지 탭 방식 - 위치 기반 비교는 별도 페이지로 분리 -- 공개 설정한 사용자만 비교 대상에 포함 \ No newline at end of file +- 공개 설정한 사용자만 비교 대상에 포함 + +--- + +## 배포 전 보안 감사 (2026-07-10) — 요금 폭탄 방지 + +포트폴리오 배포 시 공격받으면 실비용이 발생할 수 있는 항목 감사 결과. 상세 완화 방안은 아래 "후속 작업 우선순위" 로 통합. + +### 🔴 치명적 — 배포 전 반드시 패치 + +**S1. 이메일 인증 코드 발송 남용** +- 위치: `AuthController.sendSignupCode/sendPasswordCode` (line 50, 59) → `EmailVerificationService` +- 문제: Redis lock 1분 재발송 방지만 있고 IP/이메일별 총량 제한 없음. lock 만료 후 무제한 재발송 가능 +- 영향: Gmail SMTP는 하루 500건 계정 정지 임계값. 자동화 시 계정 정지 + 인증 흐름 마비 +- 완화: IP당·이메일당 하루 5회, 5분 lock, 캡차 도입 검토 + +**S2. Pageable 크기 제한 없음** +- 위치: `TransactionController.getAllUserTransactions` (line 55), `BoardController.search` (line 135), 대부분의 list 엔드포인트 +- 문제: `?size=100000` override 가능. 특히 ES는 대량 검색 요청 몇 개로 노드 재시작 위험 +- 영향: 1회 요청으로 서버 OOM, ES 클러스터 재부팅 시 데이터 손실 가능 +- 완화: 모든 Pageable 파라미터에 `@Max(100)` 검증, ES query timeout 5s + +**S3. AsyncConfig TaskExecutor 미구성** +- 위치: `global/config/AsyncConfig.java` +- 문제: `@EnableAsync` 만 있고 `ThreadPoolTaskExecutor` 빈 없음 → `SimpleAsyncTaskExecutor`(매 호출 새 스레드) +- 영향: FCM 알림·ES 인덱싱 트리거 경로에서 스레드 폭발, 메모리 누수 +- 완화: `ThreadPoolTaskExecutor` 빈 정의 (corePoolSize=5, maxPoolSize=10, queueCapacity=100) + +### 🟠 높음 — 배포 후 1주일 내 + +**S4. FCM 토큰 등록 검증 부재** +- 위치: `UserDeviceController` (추정) +- 문제: 임의 토큰 등록 가능. 사용자당 기기 수 제한 없음 +- 영향: 스팸 알림 자동화로 FCM API 호출 폭증. Blaze plan 전환 시 과금 +- 완화: FCM 토큰 format 검증, 1사용자당 최대 5개 디바이스 + +**S5. Redis GEO 무제한 저장** +- 위치: `UserLocationService.updateLocation` +- 문제: 위치 갱신 debounce 없음. 자동화 요청으로 Redis 메모리 폭증 +- 완화: 최소 1시간 debounce, `user:geo` ZSET 크기 모니터링 + +**S6. Kakao Geocoding 캐싱 상태 확인 필요** +- 문제: 매 요청마다 호출되는 경로가 있으면 Kakao 무료 할당량(30만/일) 초과 시 유료 전환 +- 완화: 주소 → 좌표 결과를 Redis에 24시간 이상 캐싱 (address hash 키) + +### 🟡 중간 — 배포 후 1개월 내 + +**S7. Transaction Export 크기 제한 없음** +- 위치: `TransactionController.exportTransactions` (line 102-114) → `TransactionExportService.exportToCsv` +- 문제: 3년치 전체 거래 50만 건 Excel 변환 → 메모리 1GB+ +- 완화: 월별 단위 export 강제 또는 비동기 job queue + +**S8. Compare 위치 기반 조회 N+1** +- 위치: `ExpenseCompareService.compareByLocation` (line 155-177) +- 문제: GEORADIUS로 근처 사용자 100명 조회 후 각각 `sumPublicByUserIds` 호출 +- 완화: 그룹 결과 Redis 1시간 사전 계산 + +**S9. ES 딥 페이지네이션** +- 문제: `page=1000, size=100` 요청 시 100,000개 건너뜀 → CPU/메모리 폭증 +- 완화: Search-After 커서 기반 pagination + +### 🟢 낮음 + +**S10. 로그인 bcrypt CPU 소모 공격** — IP당 실패 5회 후 block +**S11. 회원가입 자동화 DB row 폭증** — IP당 하루 5계정 제한 + +### 인프라 방어 체크리스트 (코드 외) + +- **Cloudflare 무료 플랜** 프론트/API 앞단 → L7 DDoS, 봇 자동 차단 (가성비 최고) +- **AWS/Kakao/FCM/Gmail 각각 Budget Alert** 설정 (사고 조기 감지) +- **외부 API 호출 카운터 로깅** (SMTP/FCM/Kakao) → 이상 감지 +- RDS slow query log, S3 egress 비용, FCM API 호출량 CloudWatch 모니터링 + +--- + +## DB 효율성 감사 (2026-08-05) — 전 도메인 스캔 + +Board / Comment / Transaction 도메인 최적화 완료 후, 전 도메인 대상으로 인덱스 / N+1 / fetch 전략 / 캐시 정합성 감사 실시. + +### 이미 완료된 개선 (참고) + +- Board `idx_board_user_type (user_id, type)` / Comment `idx_comment_user (user_id, created_at)` 복합 인덱스 +- 전역 `hibernate.jdbc.batch_size=20`, `order_inserts/updates=true`, `default_batch_fetch_size=100`, `batch_versioned_data=true` +- User↔UserSetting LAZY 명시 +- `spring.data.web.pageable.max-page-size: 100` 전역 설정 +- Comment `@SQLRestriction` 미부착은 **의도된 설계** (대댓글 트리 유지 위해 soft-deleted 로드 필요). 감사 시 오탐 금지. +- 벤치마크 결과 (`docs/benchmark/results/2026-08-05_db_io.csv`): + - Board findByUserIdAndType: 3.54 → 1.57 ms (-55.5%) + - Comment findByUserId ORDER BY created_at DESC: 22.01 → 0.65 ms (-97.1%) + - Comment 200건 bulk update: SQL 4020 → 40 (-99.0%) + +### 도메인별 심각도 (2026-08-05 시점) + +| 도메인 | High | Med | 상태 | +|---|---|---|---| +| account | 1 | 1 | ⚠️ 인덱스 전무 | +| notification | 1 | 2 | ⚠️ N+1 삭제 | +| userdevice | 1 | 1 | ⚠️ 인덱스+FCM 검증 | +| grouppruchase | 0 | 2 | ⚠️ 인덱스 전무 | +| budget | 0 | 2 | ⚠️ 소프트삭제 일관성 | +| expense/income compare | 0 | 1 | ⚠️ 캐시 evict 누락 | +| transactioncategory | 0 | 1 | 인덱스 보강 여지 | +| user, transaction, board, comment, like, bookmark, follow, tag, image, usersetting | 0 | 0~1 | ✓ 양호 | + +### 🔴 High (배포 전 필수) + +**D1. Notification 계정 삭제 시 N+1 DELETE** +- 파일: `domain/notification/application/NotificationService.java:103` +- 현상: `notificationRepository.deleteAll(notificationRepository.findAllByUserId(userId))` — 알림 N건 → SELECT 1 + DELETE N 왕복 +- 영향: 1000건 알림 계정 삭제 시 트랜잭션 장시간 점유 +- 완화: `@Modifying @Query("DELETE FROM Notification n WHERE n.userId = :userId") int deleteByUserId(Long userId)` 로 단일 벌크 DELETE + +**D2. UserDevice 인덱스·검증 부재** (S4 요금폭탄 항목과 연결) +- 파일: `domain/notification/entity/UserDevice.java`, `dao/UserDeviceRepository.java` +- 현상: + - `@Table(indexes=...)` 없음 → `findByUser` 호출 시 user_id 인덱스 없어 풀스캔 가능성 + - FCM 토큰 컬럼 length/format 검증 없음, 사용자당 device 수 제한 없음 +- 영향: 스팸 토큰 등록 자동화 시 FCM API 호출 폭증 (Blaze plan 과금) +- 완화: `@Index(user_id)` + `findByUserId(Long)` 신설 + `@Column(length=250)` + FCM 토큰 format 검증 + 1 user 최대 5 device + +**D3. Account 인덱스 전무** +- 파일: `domain/asset/entity/Account.java:29~34` +- 현상: `@Table` 자체 없음 → `findByUserId` 매 거래/조회 시 풀스캔 +- 영향: 계정 데이터 누적 시 매 거래 생성 latency 상승 +- 완화: `@Table(name="account", indexes={@Index("user_id"), @Index("user_id, account_name")})` + +### 🟠 Medium (한 줄 요약) + +- **D4. GroupPurchase 인덱스 전무** — 실 쿼리 매핑 후 `idx_gp_status_category_deadline (status, category_id, deadline)` 1개 복합으로 확정. `creator_id`, `deadline` 단일은 실 쿼리 없어 제외. +- ~~**D5. Budget `@SQLRestriction` 부재**~~ — 오탐. `Budget.java:30` 에 이미 `@SQLRestriction("deleted_at IS NULL")` 존재. native `findByUserIdAndYearMonthAndCategoryIdIncludingDeleted` 는 삭제된 카테고리 restore-on-recreate 흐름용 (버그 아님). +- **D6. Budget category null 방어** — `BudgetService.java:221` `budget.getTransactionCategory().getId()` NPE 위험. `resolveCategoryId()` 헬퍼 재사용으로 snapshot fallback + null filter. +- ~~**D7. Compare 캐시 evict 누락**~~ — 오탐. 그룹 캐시(age/amount/category × 공개 사용자 집합)는 `RedisConfig` 주석 명시대로 warm-up 30분 주기와 정합되도록 **의도적으로 30분 staleness 허용**. 개별 write마다 allEntries evict 시 cross-user warm hit 전면 파괴 → 성능 역행. +- **D8. InterestCategoryService.deleteAllByUserId / AccountService fixedTx forEach delete** — D1 과 동일 패턴, `@Modifying DELETE` 치환 +- **D9. TransactionCategory 인덱스** — `(user_id, name, type)` 복합 고려 +- **D10. Bookmark/Follow raw Long 참조** — FK 없음. 스냅샷 목적이면 문서화, 아니면 `@ManyToOne` 전환 검토 + +### 공통 안티패턴 + +1. **findAll → forEach delete** (Notification / InterestCategory / Account.fixedTx) — 3 도메인 반복 +2. **인덱스 불균형** — Board/Comment/Like/Bookmark 완비, Account/GroupPurchase/UserDevice 전무 +3. **raw `Long userId` vs `@ManyToOne` 혼재** — Board/Comment/Like/Bookmark/GroupPurchase 5 도메인. 스냅샷 보존 의도 명시 필요 +4. **`@Cacheable` 대비 `@CacheEvict` 배선 누락** — compare 계열 + +### 마이그레이션 SQL 초안 (prod 배포 시) + +```sql +CREATE INDEX idx_userdevice_user ON user_device (user_id); +CREATE INDEX idx_account_user ON account (user_id); +CREATE INDEX idx_account_user_name ON account (user_id, account_name); +CREATE INDEX idx_gp_creator ON group_purchase (creator_id); +CREATE INDEX idx_gp_category_status ON group_purchase (category_id, status); +CREATE INDEX idx_gp_deadline ON group_purchase (deadline); +``` + +--- + +## 후속 작업 우선순위 + +### P0 — 배포 전 필수 (요금 폭탄 / 데이터 손실 직결) + +**운영 설정 정비 (Option B)** +- **[P0-1] Profile 분리** — `application.yml` 을 공통/local/prod 3분할. prod에 `ddl-auto: update`/`show-sql: true` 가 그대로 가면 자동 ALTER로 데이터 손실 / 로그 IO 폭증 + - 공통: `spring.application.name`, `datasource.driver`, `spring.config.import: application-secret.yml`, `spring.profiles.active: ${SPRING_PROFILES_ACTIVE:local}` + - `application-local.yml`: 현재 동작 그대로 (`ddl-auto: update`, `show-sql: true`) + - `application-prod.yml`: `ddl-auto: validate`, `show-sql: false`, Hibernate SQL log WARN +- **[P0-2] Prod DDL 전략 = validate** — 스키마 변경은 PR에 `docs/db/migration/` SQL 스냅샷 동봉, 배포 전 수동 실행. Flyway 도입 전 임시 가드 +- **[P0-3] 검증** — local/prod profile 부팅 확인, prod + 로컬 DB 로 validate 통과 확인 + +**보안 (요금 폭탄 방어)** +- **[P0-4] 이메일 인증 발송 rate limit 강화** (S1) — IP·이메일별 하루 5회, 5분 lock +- **[P0-5] 모든 Pageable `@Max(100)` 검증** (S2) — 특히 ES 검색, Transaction list +- **[P0-6] AsyncConfig TaskExecutor 빈 정의** (S3) — 스레드 폭발 방지 (기존 최적화 백로그 항목이기도 함) + +**인프라 방어** +- **[P0-7] Cloudflare 무료 플랜 프론트/API 앞단 배치** +- **[P0-8] AWS/Kakao/FCM/Gmail Budget Alert 설정** + +**런타임 크래시 (Profile 분리 검증 중 발견, 2026-08-30)** +- **[P0-9] SecurityConfig CORS 바인딩 버그 — 로컬 부팅 전면 실패** + - 위치: `SecurityConfig.java:37-38` + - 현상: `@Value("${app.cors.allowed-origins}") private List allowedOrigins;` — Spring이 YAML 시퀀스를 indexed 프로퍼티(`app.cors.allowed-origins[0]`, `[1]`...)로 평탄화하므로 `@Value`가 정확한 키를 찾지 못함 → `Could not resolve placeholder 'app.cors.allowed-origins'` startup 실패 + - 도입: 커밋 `f9edfe3` (2026-08-06, #162 CORS 보안 구현). 이후 로컬 `./gradlew bootRun` 지속 실패 상태 (git HEAD 원본 config에서도 재현 확인) + - prod 영향: 배포 이력에 따라 미상. 배포 컨테이너가 8/6 이전 이미지이거나 별도 env var 주입 중이라면 정상 부팅 가능. 확인 필요. + - 완화 (택1): + - (권장) `@ConfigurationProperties(prefix="app.cors")` 클래스 도입 — YAML 리스트 형식 유지 + - (임시) SpEL로 comma-separated 변환: `@Value("#{'${app.cors.allowed-origins:}'.split(',')}")` + YAML을 문자열로 변환 + +--- + +### P1 — 배포 후 1주일 내 + +**운영 설정 정비 (Option B 잔여)** +- **[P1-1] HikariCP 튜닝** — prod: `maximum-pool-size: 20`, `minimum-idle: 5`, `connection-timeout: 3000`, `idle-timeout: 300000`, `max-lifetime: 1200000`, `leak-detection-threshold: 60000`. `max-lifetime` 은 MySQL `wait_timeout` 보다 짧게 설정해 broken pipe 방지 +- **[P1-2] JDBC Batch** — `hibernate.jdbc.batch_size: 20`, `batch_versioned_data: true`, `order_inserts/updates: true`. ⚠️ IDENTITY PK 라 insert batch 자동 비활성화, 현 효과는 update/delete 한정 + +**보안** +- **[P1-3] FCM 토큰 등록 검증** (S4) — format 검증, 1사용자당 최대 5기기 +- **[P1-4] Redis GEO 위치 갱신 debounce** (S5) — 최소 1시간 +- **[P1-5] Kakao Geocoding 결과 Redis 캐싱 확인/구현** (S6) — 24시간 TTL +- **[P1-6] 외부 API 호출 카운터 로깅** — SMTP/FCM/Kakao + +--- + +### P2 — 배포 후 1개월 내 + +**보안** +- **[P2-1] Transaction Export 크기 제한** (S7) — 월별 단위 강제 또는 비동기 처리 +- **[P2-2] Compare 위치 기반 N+1 해소** (S8) — 그룹 결과 Redis 1시간 사전 계산 +- **[P2-3] ES Search-After 커서 pagination** (S9) +- **[P2-4] 로그인 실패 IP rate limit** (S10) — 5회 실패 후 5분 block +- **[P2-5] 회원가입 IP 제한** (S11) — 하루 5계정 + +**게시판 검색 (#26) 마무리** +- **[P2-6] ES 인덱싱 실패 처리 강화** — Spring Retry 또는 outbox 패턴으로 영구 유실 방지 +- **[P2-7] docker-compose 정비** — ES 8.x + nori plugin 포함 컨테이너 정의 +- **[P2-8] ES 통합 테스트** — Testcontainers Elasticsearch로 nori 검색 동작 검증 + +**기능 미구현** +- **[P2-9] Budget CRUD 구현** — `BudgetService` 부재. Budget 등록 API 없음. 구현 시 `compare:budget` 캐시에 `@CacheEvict(allEntries=true)` 필수 +- **[P2-10] 이미지 S3 presigned URL 인프라** — `POST /api/v1/images/presigned-url` + 클라이언트 직접 업로드 +- **[P2-11] Follow 상대 알림** — 팔로우 시 Notification 도메인 이벤트 발행 + +**최적화 백로그** +- **[P2-12] 댓글 캐시 (Redis)** — `CommentService.list` 에 `@Cacheable`/`@CacheEvict` 적용. 의사결정 #5 와 현 코드 불일치 +- **[P2-13] BudgetCompareService.compareByCategory 중복 쿼리** — `averageCategoryBudget()` + `findMyCategoryBudget()` 를 단일 쿼리로 통합 +- **[P2-14] Expense/IncomeCompareService.compareByCategory 중복 쿼리** — 그룹 평균 결과에 본인 포함되도록 쿼리 변경 +- **[P2-15] User↔UserSetting fetch 전략** — 기본 EAGER 상태. 명시적 LAZY + 필요 시점 fetch join + +--- + +### P3 — 기술 부채 (분기 이상) + +**Option B 종료 후 잔여 빚** +- **[P3-1] Flyway 도입** — 스키마 변경의 코드/DB 동기 관리. `shedlock` 테이블 DDL 도 V*.sql 로 이전 +- **[P3-2] OSIV 비활성화** (`spring.jpa.open-in-view: false`) — Lazy 노출 사냥 필요, 회귀 위험 큼 +- **[P3-3] IDENTITY PK → SEQUENCE/pooled-lo 전환 검토** — batch insert 효과 회수 +- **[P3-4] Prod 비밀 관리** — `application-secret.yml` 을 환경변수/Secrets Manager 로 이전 + +**아키텍처 개선** +- **[P3-5] HOT 랭킹 실시간성** — Redis ZSET 기반 실시간 랭킹, like 이벤트마다 ZINCRBY. 현재는 5분 stale 허용 + +--- + +## 완료된 작업 이력 (참고용) + +### 게시판(Q&A / 노하우) 프론트 연동 관련 +프론트(`fix/ACC-102-add-feature` 및 `feat/ACC-33-board-feature-link`)에서 Board/Comment CRUD·검색·수정·삭제·닉네임 노출까지 연동 완료. + +#### 1차 구현 완료 +- **게시판 카테고리 도메인** (`domain/boardcategory/`): `BoardCategory` 엔티티 + `GET /api/v1/board-categories?type=QNA|KNOWHOW`. `BoardCategorySeeder`(ApplicationRunner)로 시드 데이터 자동 삽입. +- **좋아요 도메인** (`domain/like/`): `PostLike` 단일 테이블 + `targetType`(BOARD/COMMENT). `POST /api/v1/boards/{id}/like`, `POST /api/v1/comments/{id}/like` 토글. `BoardResponse`/`CommentResponse` 에 `likeCount`, `liked` 포함. +- **북마크 도메인** (`domain/bookmark/`): `Bookmark`(user_id, board_id) + `POST /api/v1/boards/{id}/bookmark`. `BoardResponse.bookmarked` 포함. +- **Q&A 상태 필드**: `Board.isResolved`, `Board.isUrgent` + 작성자 전용 토글 (`PATCH /api/v1/boards/{id}/resolved|urgent`). `Comment.accepted` + Q&A 작성자만 채택 (`PATCH /api/v1/comments/{id}/accept`, 채택 시 board.resolved=true 자동 전환). +- **/users/me 응답에 id 추가**. + +#### 2차 구현 완료 +- **Tag 도메인** (`domain/tag/`): `Tag` + `BoardTag` 조인 테이블, `TagService.setTagsForBoard/tagsOfBoard/tagsByBoards/boardIdsWithTag`, `GET /api/v1/tags`. `BoardCreateRequest/BoardUpdateRequest.tags`. `GET /api/v1/boards?tag=` 필터. +- **Image 도메인** (`domain/image/`): `Image` 통합 테이블(reference_type, reference_id, sort_order). `POST /api/v1/boards/{postId}/images`(URL 저장), `GET /api/v1/boards/{postId}/images`, `DELETE /api/v1/images/{imageId}`. `BoardResponse.imageUrls`. +- **HOT 랭킹**: `GET /api/v1/boards/hot?type=KNOWHOW&days=7&limit=3`. Score = views + likeCount * 3. +- **댓글 페이지네이션**: `GET /api/v1/comments/{postId}/threads` — `Page`. 기존 flat `GET /comments/{postId}` 유지. +- **Follow 도메인** (`domain/follow/`): `Follow(follower_id, following_id)`. `POST /api/v1/users/{userId}/follow` 토글, `GET /followers|following`. +- **User 프로필 통계** (`domain/userstats/`): `GET /api/v1/users/{userId}/stats`. +- **`UserProfileResponse.role`** 노출. +- **관리자 UI 프론트**: `AdminView.tsx` + Sidebar에 role=ROLE_ADMIN 시 노출. `DELETE /api/v1/admin/boards/{id}` 연동. +- **서버 페이지네이션 프론트**: QnaListView/KnowhowListView. + +#### 3차 구현 완료 — ES 태그 통합 +- `BoardDocument.tags: List` (`FieldType.Keyword`) +- `BoardDocument.from(board, tags)` 오버로드, `BoardIndexEventListener` 색인 시 태그 주입 +- `BoardSearchQueryRepository.search` bool query: `title^2 / content` (multi_match) OR `tags` (term), minimum_should_match=1 +- `BoardSearchResponse.tags` 필드 + 프론트 렌더링 + +#### 4차 구현 완료 — ES 재색인 + HOT 캐싱 +- **ES 초기 재색인 배치**: `BoardReindexService.reindexAll()` 200건 페이지 로드 → `tagService.tagsByBoards` 벌크 → `saveAll` 배치 upsert. `POST /api/v1/admin/boards/reindex` (ROLE_ADMIN). `BoardReindexRunner` — `app.board.reindex-on-startup=true` 부팅 시 자동 재색인. +- **HOT 랭킹 Redis 캐싱**: `BoardService.hot` 에 `@Cacheable(cacheNames="board:hot", key="type:days:limit")`. `RedisConfig.CACHE_BOARD_HOT` 5분 TTL. `BoardHotWarmupScheduler` 4분 주기 warm-up + ShedLock. `app.cache.warmup.board-hot.enabled` 로 on/off. 좋아요 토글/삭제 시 명시적 evict 없음 (5분 TTL + warm-up 로 stale 흡수). + +#### 5차 구현 완료 — 좋아요/조회수 카운터 +- **좋아요 카운터 Redis 캐싱** (Cache-aside + INCR/DECR 하이브리드): + - `LikeCountCacheService` — 키 `like:count:{BOARD|COMMENT}:{id}`, TTL 30분. + - `getCount`: GET → 미스 시 DB `countByTargetIdAndTargetType` 후 SET. + - `getCounts`: MGET → 미스 ID 벌크 DB COUNT → MSET. + - `applyDelta(±1)`: `hasKey` 확인 후에만 INCR/DECR. 캐시 미스 상태 INCR-from-null 로 인한 drift 방지. + - `evict`: DEL. + - `PostLikeService.count/countByTargets` 캐시 경유. `toggle` 은 DB mutation 후 `applyDelta`. + - `BoardService.delete` / `CommentService.delete` 에서 `likeService.evictCount(...)`. +- **조회수 Redis 캐싱 완비**: `BoardViewCountService.evict(boardId)` 신설 (dirty set SREM + counter DEL). 삭제된 게시물의 delta 유실 로그 해소. + +#### 6차 구현 완료 — 관리자 API +- **관리자 게시물/댓글 리스트 API**: + - `GET /api/v1/admin/boards?type=&includeDeleted=` — `AdminBoardResponse`(원문 무마스킹 + `adminDeleted`/`userDeleted` + `deletedAt`). `includeDeleted=true` 시 `@SQLRestriction` 우회 위해 `AdminBoardRepository` native query (created_at DESC 하드코딩). + - `GET /api/v1/admin/comments?referenceType=&referenceId=` — `findAll(pageable)` 로 소프트 삭제 포함. + - 둘 다 `/api/v1/admin/**` ROLE_ADMIN 강제. +- **좋아요 카운터 정합성 검증 배치**: + - `LikeCountReconcileService.reconcile(maxKeys)` — SCAN `like:count:*` → 타입별 `countByTargets` 벌크 → drift 시 WARN + DB 정본으로 SET. 손상 값 DEL. `ReconcileReport(scanned, mismatched, corrected)`. + - `LikeCountReconcileScheduler` — 매시 7분 (`0 7 * * * *`), ShedLock, 500 키/회. `app.like.reconcile.enabled=true`. + - `POST /api/v1/admin/likes/reconcile?limit=` 수동 트리거. + +#### 7차 구현 완료 — 관리자 프론트 +- **관리자 프론트 UI 확장** (`AdminView.tsx`): 3 탭 (게시물/댓글/운영 작업). + - 게시물 탭: `GET /admin/boards?type=&includeDeleted=`, `adminDeleted`/`userDeleted`/정상 3단 배지. + - 댓글 탭: `GET /admin/comments?referenceType=&referenceId=`, `parentId` 대댓글 표시, 유저 소프트 삭제 dim. + - 운영 작업 탭: ES 재색인 (`POST /admin/boards/reindex`) + 좋아요 정합성 (`POST /admin/likes/reconcile?limit=500`). + - 프론트 `boardApi.ts` 에 `adminListBoards`, `adminListComments`, `adminReindexBoards`, `adminReconcileLikes` + 타입. + +### 이전 세션 결정 사항 +- `GET /api/v1/boards?type=QNA|KNOWHOW` 필터 (BoardRepository.findByType) +- `BoardResponse` / `CommentResponse` 에 `authorNickname` 포함, list 경로 `UserRepository.findAllById` 일괄 조회로 N+1 방지 +- 게시물/댓글 수정(PATCH) 프론트 상세 화면 인라인 편집 연결 diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..aa6fa1e --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,48 @@ +# 로컬 개발용 — ES를 같은 docker compose 스택에서 기동 +# 사용: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d + +services: + app: + environment: + SPRING_PROFILES_ACTIVE: local + ELASTICSEARCH_URIS: http://elasticsearch:9200 + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/jointliving?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + SPRING_DATA_REDIS_HOST: redis + FIREBASE_CREDENTIALS_PATH: file:/app/does-not-exist.json + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + elasticsearch: + condition: service_started + + mysql: + ports: + - "3306:3306" + + redis: + ports: + - "6379:6379" + + elasticsearch: + build: + context: ./docker/elasticsearch + container_name: joint-living-es + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.ml.enabled=false + - ES_JAVA_OPTS=-Xms128m -Xmx256m + ports: + - "9200:9200" + volumes: + - es-data:/usr/share/elasticsearch/data + healthcheck: + test: ["CMD-SHELL", "curl -fsSL http://localhost:9200/_cluster/health | grep -vq '\"status\":\"red\"'"] + interval: 10s + timeout: 5s + retries: 12 + +volumes: + es-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b22b24 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: joint-living-backend + ports: + - "8000:8000" + environment: + SPRING_PROFILES_ACTIVE: prod + ELASTICSEARCH_URIS: http://10.0.0.197:9200 + FRONTEND_REDIRECT_URL: https://moneydiary.cloud/oauth2/redirect + MAIL_FROM: noreply@moneydiary.cloud + volumes: + - ./application-secret.yml:/app/application-secret.yml + - /home/ubuntu/app/firebase-service-account.json:/app/firebase-service-account.json + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + logging: *default-logging + restart: always + + mysql: + image: mysql:8.0 + container_name: joint-living-mysql + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=jointliving + - MYSQL_USER=jointliving + - MYSQL_PASSWORD=jointliving + - TZ=Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + expose: + - "3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + interval: 10s + timeout: 5s + retries: 12 + logging: *default-logging + + redis: + image: redis:7-alpine + container_name: joint-living-redis + expose: + - "6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 12 + logging: *default-logging + + nginx: + image: nginx:latest + container_name: joint-living-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + depends_on: + - app + logging: *default-logging + restart: always + +volumes: + mysql-data: + redis-data: diff --git a/docker/elasticsearch/Dockerfile b/docker/elasticsearch/Dockerfile new file mode 100644 index 0000000..c5d3cc0 --- /dev/null +++ b/docker/elasticsearch/Dockerfile @@ -0,0 +1,3 @@ +FROM docker.elastic.co/elasticsearch/elasticsearch:8.11.4 + +RUN bin/elasticsearch-plugin install --batch analysis-nori diff --git a/docs/benchmark/results/2026-08-05_db_io.csv b/docs/benchmark/results/2026-08-05_db_io.csv new file mode 100644 index 0000000..3f5a327 --- /dev/null +++ b/docs/benchmark/results/2026-08-05_db_io.csv @@ -0,0 +1,41 @@ +scenario,variant,batchSize,iterations,avg_ms,p50_ms,p95_ms,min_ms,max_ms,sql_count +R1,index_used,-,100,1.572,1.431,2.888,0.506,6.988,100 +R1,full_scan,-,100,3.536,3.488,4.053,3.096,4.856,100 +R2,index_used,-,100,0.646,0.624,0.899,0.435,1.523,100 +R2,full_scan,-,100,22.010,21.679,23.564,20.722,37.112,100 +U1,batch,20,20,27.774,27.896,35.377,22.560,35.688,40 +U1,batch,1,20,37.582,35.017,46.010,31.144,63.037,4020 +R3,index_used,-,100,1.700,1.735,2.432,0.855,3.090,100 +R3,full_scan,-,100,12.300,12.211,14.178,9.582,34.053,100 +R4,index_used,-,100,0.906,0.845,1.356,0.527,2.144,100 +R4,full_scan,-,100,1.673,1.529,2.371,1.350,4.986,100 +R5,index_used,-,100,0.637,0.587,0.970,0.370,3.473,100 +R5,full_scan,-,100,1.131,1.091,1.368,0.962,1.909,100 +U2,individual_update,-,10,22.516,22.795,23.966,20.637,23.966,2010 +U2,bulk_update,-,10,5.228,5.143,6.531,4.204,6.531,10 +R3,index_used,-,100,1.807,1.743,3.803,0.711,5.452,100 +R3,full_scan,-,100,12.138,12.051,14.114,9.588,26.880,100 +R4,index_used,-,100,0.507,0.466,0.724,0.334,0.872,100 +R4,full_scan,-,100,1.555,1.354,2.333,1.209,6.737,100 +R5,index_used,-,100,0.689,0.584,1.427,0.435,1.740,100 +R5,full_scan,-,100,1.203,1.099,1.697,0.937,3.043,100 +U2,individual_update,-,10,24.620,24.066,27.964,22.776,27.964,2010 +U2,bulk_update,-,10,6.108,5.844,8.550,4.728,8.550,10 +R3,index_used,-,100,1.659,1.496,3.289,0.746,4.390,100 +R3,full_scan,-,100,12.214,12.508,13.924,9.532,14.724,100 +R4,index_used,-,100,0.699,0.607,1.356,0.419,2.409,100 +R4,full_scan,-,100,1.466,1.440,1.670,1.221,2.271,100 +R5,index_used,-,100,0.728,0.610,1.637,0.401,3.091,100 +R5,full_scan,-,100,1.151,1.120,1.462,0.931,2.022,100 +R6,index_used,-,100,1.610,1.413,2.911,0.892,3.402,100 +R6,full_scan,-,100,4.659,4.492,6.010,3.863,9.573,100 +R7,index_used,-,100,0.797,0.658,1.456,0.426,2.264,100 +R7,full_scan,-,100,0.827,0.798,1.163,0.482,1.621,100 +R6,index_used,-,100,2.431,2.138,5.454,1.151,7.656,100 +R6,full_scan,-,100,6.138,5.836,7.203,5.079,17.143,100 +R7,index_used,-,100,1.533,1.201,3.363,0.663,7.759,100 +R7,full_scan,-,100,1.682,1.466,2.833,0.919,4.472,100 +U3,individual_update,-,10,18.416,17.139,27.130,13.695,27.130,1010 +U3,bulk_update,-,10,6.730,5.286,16.054,4.035,16.054,10 +U4,individual_update,-,10,16.177,16.429,18.484,14.628,18.484,1010 +U4,bulk_update,-,10,5.088,5.066,6.780,4.095,6.780,10 diff --git a/docs/db/migration/2026-08-05_add_account_notification_indexes.sql b/docs/db/migration/2026-08-05_add_account_notification_indexes.sql new file mode 100644 index 0000000..9de10b3 --- /dev/null +++ b/docs/db/migration/2026-08-05_add_account_notification_indexes.sql @@ -0,0 +1,14 @@ +-- 2026-08-05: Account / Notification / UserDevice 에 user_id 기반 인덱스 추가 +-- +-- 대상 쿼리: +-- Account: findByUserId (사용자 계정 목록, 매 거래 생성 경로에서 반복) +-- Notification: softDeleteByUserId (계정 삭제 시 벌크 UPDATE) + findByUser 목록 +-- UserDevice: findByUserId (알림 발송 시 FCM 토큰 조회) +-- +-- 실행 순서: prod ddl-auto=validate 이므로 배포 전에 아래 문장들을 수동 실행. +-- 롤백: DROP INDEX ... + +CREATE INDEX idx_account_user ON account (user_id); +CREATE INDEX idx_account_user_name ON account (user_id, account_name); +CREATE INDEX idx_notification_user ON notification (user_id); +CREATE INDEX idx_userdevice_user ON user_device (user_id); diff --git a/docs/db/migration/2026-08-05_add_grouppurchase_txcategory_indexes.sql b/docs/db/migration/2026-08-05_add_grouppurchase_txcategory_indexes.sql new file mode 100644 index 0000000..44d1dc4 --- /dev/null +++ b/docs/db/migration/2026-08-05_add_grouppurchase_txcategory_indexes.sql @@ -0,0 +1,18 @@ +-- 2026-08-05: GroupPurchase / TransactionCategory 조회 인덱스 추가 +-- +-- 대상 쿼리: +-- GroupPurchase: findActiveGroupPurchases (WHERE status=? AND category_id=? ORDER BY deadline) +-- TransactionCategory: existsByUserIdAndNameAndType, findByUserIdAndNameAndTypeIncludingDeleted, +-- findAllByUserOrSystem (user_id 필터) +-- +-- 실행 순서: prod ddl-auto=validate 이므로 배포 전에 아래 문장들을 수동 실행. +-- 롤백: DROP INDEX ... +-- +-- 참고: category_id 가 null 로 넘어오는 케이스는 status prefix 만 사용됨 (leftmost). 필터 걸린 경우 +-- (status, category_id, deadline) 3열 모두 활용해 filter + sort 를 인덱스로 흡수. + +CREATE INDEX idx_gp_status_category_deadline + ON group_purchase (status, category_id, deadline); + +CREATE INDEX idx_txcategory_user_name_type + ON transaction_category (user_id, name, type); diff --git a/docs/db/migration/2026-08-05_add_interest_fixed_indexes.sql b/docs/db/migration/2026-08-05_add_interest_fixed_indexes.sql new file mode 100644 index 0000000..029e6da --- /dev/null +++ b/docs/db/migration/2026-08-05_add_interest_fixed_indexes.sql @@ -0,0 +1,12 @@ +-- 2026-08-05: InterestCategory / FixedTransaction 벌크 삭제 관련 인덱스 +-- +-- 대상 쿼리: +-- InterestCategory: findByUserId (관심 카테고리 목록), softDeleteByUserId (계정 삭제 시 벌크 UPDATE) +-- FixedTransaction: softDeleteByAccountId (계정 삭제 시), softDeleteByTransactionCategoryId (카테고리 삭제 시) +-- +-- 실행 순서: prod ddl-auto=validate 이므로 배포 전에 아래 문장들을 수동 실행. +-- 롤백: DROP INDEX ... + +CREATE INDEX idx_interest_user ON interest_category (user_id); +CREATE INDEX idx_fixed_transaction_account ON fixed_transaction (account_id); +CREATE INDEX idx_fixed_transaction_category ON fixed_transaction (category_id); diff --git a/docs/db/migration/2026-08-05_add_user_indexes.sql b/docs/db/migration/2026-08-05_add_user_indexes.sql new file mode 100644 index 0000000..3bc64c5 --- /dev/null +++ b/docs/db/migration/2026-08-05_add_user_indexes.sql @@ -0,0 +1,14 @@ +-- 2026-08-05: Board / Comment 에 user_id 기반 인덱스 추가 +-- +-- 대상 쿼리: +-- Board: findByUserIdAndType, findByUserId (사용자 프로필 게시글 목록) +-- Comment: 관리자 사용자별 댓글 조회, 사용자 활동 이력 +-- +-- 실행 순서: prod ddl-auto=validate 이므로 배포 전에 아래 두 문장을 수동 실행. +-- 롤백: DROP INDEX ... +-- +-- 참고: 두 테이블 모두 IDENTITY PK + BaseEntity(created_at) 상속. +-- Board.user_id 는 (user_id, type) 복합으로 잡아 findByUserId 단일 조건에도 커버됨. + +CREATE INDEX idx_board_user_type ON board (user_id, type); +CREATE INDEX idx_comment_user ON comment (user_id, created_at); diff --git a/frontend/dist/assets/index-B4IUySYH.js b/frontend/dist/assets/index-B4IUySYH.js new file mode 100644 index 0000000..d6462ed --- /dev/null +++ b/frontend/dist/assets/index-B4IUySYH.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function w(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ie(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?ie(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ce(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ce(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),ce(o,r,i,``,function(e){return e})):o!=null&&(w(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,w());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var ee=!1,S=-1,C=5,te=-1;function ne(){return g?!0:!(e.unstable_now()-tet&&ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?w():ee=!1}}}var w;if(typeof y==`function`)w=function(){y(re)};else if(typeof MessageChannel<`u`){var ie=new MessageChannel,ae=ie.port2;ie.port1.onmessage=re,w=function(){ae.postMessage(null)}}else w=function(){_(re,0)};function oe(t,n){S=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(S),S=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,w()))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1pe||(e.current=fe[pe],fe[pe]=null,pe--)}function O(e,t){pe++,fe[pe]=e.current,e.current=t}var he=me(null),ge=me(null),_e=me(null),ve=me(null);function ye(e,t){switch(O(_e,t),O(ge,e),O(he,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}D(he),O(he,e)}function be(){D(he),D(ge),D(_e)}function k(e){e.memoizedState!==null&&O(ve,e);var t=he.current,n=Hd(t,e.type);t!==n&&(O(ge,e),O(he,n))}function xe(e){ge.current===e&&(D(he),D(ge)),ve.current===e&&(D(ve),Qf._currentValue=de)}var Se,Ce;function we(e){if(Se===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Se=t&&t[1]||``,Ce=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?we(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return we(e.type);case 16:return we(`Lazy`);case 13:return e.child!==t&&t!==null?we(`Suspense Fallback`):we(`Suspense`);case 19:return we(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return we(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,We=null;function Ge(e){if(typeof Ve==`function`&&He(e),We&&typeof We.setStrictMode==`function`)try{We.setStrictMode(Ue,e)}catch{}}var Ke=Math.clz32?Math.clz32:Ye,qe=Math.log,Je=Math.LN2;function Ye(e){return e>>>=0,e===0?32:31-(qe(e)/Je|0)|0}var Xe=256,Ze=262144,Qe=4194304;function $e(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function et(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=$e(n))):i=$e(o):i=$e(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=$e(n))):i=$e(o)):i=$e(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function tt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function nt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function rt(){var e=Qe;return Qe<<=1,!(Qe&62914560)&&(Qe=4194304),e}function it(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function at(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ot(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),yn=!1;if(vn)try{var bn={};Object.defineProperty(bn,"passive",{get:function(){yn=!0}}),window.addEventListener(`test`,bn,bn),window.removeEventListener(`test`,bn,bn)}catch{yn=!1}var xn=null,Sn=null,Cn=null;function wn(){if(Cn)return Cn;var e,t=Sn,n=t.length,r,i=`value`in xn?xn.value:xn.textContent,a=i.length;for(e=0;e=nr),ar=` `,or=!1;function sr(e,t){switch(e){case`keyup`:return er.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function cr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var lr=!1;function ur(e,t){switch(e){case`compositionend`:return cr(t);case`keypress`:return t.which===32?(or=!0,ar):null;case`textInput`:return e=t.data,e===ar&&or?null:e;default:return null}}function dr(e,t){if(lr)return e===`compositionend`||!tr&&sr(e,t)?(e=wn(),Cn=Sn=xn=null,lr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Nr(n)}}function Fr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Fr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ir(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Gt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Gt(e.document)}return t}function Lr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Rr=vn&&`documentMode`in document&&11>=document.documentMode,zr=null,Br=null,Vr=null,Hr=!1;function Ur(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Hr||zr==null||zr!==Gt(r)||(r=zr,`selectionStart`in r&&Lr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Vr&&Mr(Vr,r)||(Vr=r,r=Ed(Br,`onSelect`),0>=o,i-=o,Fi=1<<32-Ke(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),j&&Li(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),j&&Li(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return j&&Li(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),j&&Li(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===w&&Fa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ha(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=Si(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=xi(o.type,o.key,o.props,null,e.mode,c),Ha(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=Ti(o,e.mode,c),c.return=e,e=c}return s(e);case w:return o=Fa(o),b(e,r,o,c)}if(ue(o))return h(e,r,o,c);if(se(o)){if(l=se(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Va(o),c);if(o.$$typeof===S)return b(e,r,ua(e,o),c);Ua(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=Ci(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ba=0;var i=b(e,t,n,r);return za=null,i}catch(t){if(t===ka||t===ja)throw t;var a=_i(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ga=Wa(!0),Ka=Wa(!1),qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ya(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Xa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Za(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=mi(e),pi(e,null,n),t}return ui(e,r,t,n),mi(e)}function Qa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ct(e,n)}}function $a(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var eo=!1;function to(){if(eo){var e=ba;if(e!==null)throw e}}function no(e,t,n,r){eo=!1;var i=e.updateQueue;qa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===ya&&(eo=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:qa=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function ro(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function io(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=T.T,s={};T.T=s,zs(e,!1,t,n);try{var c=i(),l=T.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Rs(e,t,Ca(c,r),pu(e)):Rs(e,t,r,pu(e))}catch(n){Rs(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{E.p=a,o!==null&&s.types!==null&&(o.types=s.types),T.T=o}}function Os(){}function ks(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=As(e).queue;Ds(e,a,t,de,n===null?Os:function(){return js(e),n(r)})}function As(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:de,baseState:de,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:de},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function js(e){var t=As(e);t.next===null&&(t=e.alternate.memoizedState),Rs(e,t.next.queue,{},pu())}function Ms(){return la(Qf)}function Ns(){return R().memoizedState}function Ps(){return R().memoizedState}function Fs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Xa(n);var r=Za(t,e,n);r!==null&&(hu(r,t,n),Qa(r,t,n)),t={cache:ha()},e.payload=t;return}t=t.return}}function Is(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Bs(e)?Vs(t,n):(n=di(e,t,n,r),n!==null&&(hu(n,e,r),Hs(n,t,r)))}function Ls(e,t,n){Rs(e,t,n,pu())}function Rs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Bs(e))Vs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,jr(s,o))return ui(e,t,i,0),G===null&&li(),!1}catch{}if(n=di(e,t,i,r),n!==null)return hu(n,e,r),Hs(n,t,r),!0}return!1}function zs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Bs(e)){if(t)throw Error(i(479))}else t=di(e,n,r,2),t!==null&&hu(t,e,2)}function Bs(e){var t=e.alternate;return e===P||t!==null&&t===P}function Vs(e,t){xo=bo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Hs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ct(e,n)}}var Us={readContext:la,use:Ro,useCallback:L,useContext:L,useEffect:L,useImperativeHandle:L,useLayoutEffect:L,useInsertionEffect:L,useMemo:L,useReducer:L,useRef:L,useState:L,useDebugValue:L,useDeferredValue:L,useTransition:L,useSyncExternalStore:L,useId:L,useHostTransitionStatus:L,useFormState:L,useActionState:L,useOptimistic:L,useMemoCache:L,useCacheRefresh:L};Us.useEffectEvent=L;var Ws={readContext:la,use:Ro,useCallback:function(e,t){return Fo().memoizedState=[e,t===void 0?null:t],e},useContext:la,useEffect:ms,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),fs(4194308,4,bs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fs(4194308,4,e,t)},useInsertionEffect:function(e,t){fs(4,2,e,t)},useMemo:function(e,t){var n=Fo();t=t===void 0?null:t;var r=e();if(So){Ge(!0);try{e()}finally{Ge(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Fo();if(n!==void 0){var i=n(t);if(So){Ge(!0);try{n(t)}finally{Ge(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Is.bind(null,P,e),[r.memoizedState,e]},useRef:function(e){var t=Fo();return e={current:e},t.memoizedState=e},useState:function(e){e=Xo(e);var t=e.queue,n=Ls.bind(null,P,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ss,useDeferredValue:function(e,t){return Ts(Fo(),e,t)},useTransition:function(){var e=Xo(!1);return e=Ds.bind(null,P,e.queue,!0,!1),Fo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=P,a=Fo();if(j){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||Go(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ms(qo.bind(null,r,o,e),[e]),r.flags|=2048,us(9,{destroy:void 0},Ko.bind(null,r,o,n,t),null),n},useId:function(){var e=Fo(),t=G.identifierPrefix;if(j){var n=Ii,r=Fi;n=(r&~(1<<32-Ke(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Co++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[ht]=t,o[gt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Lc(t)}}return B(t),Rc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=_e.current,Yi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Hi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ht]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Ki(t,!0)}else e=Bd(e).createTextNode(r),e[ht]=t,t.stateNode=e}return B(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Yi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[ht]=t}else Xi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),e=!1}else n=Zi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(_o(t),t):(_o(t),null);if(t.flags&128)throw Error(i(558))}return B(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Yi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[ht]=t}else Xi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),a=!1}else a=Zi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(_o(t),t):(_o(t),null)}return _o(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Bc(t,t.updateQueue),B(t),null);case 4:return be(),e===null&&Sd(t.stateNode.containerInfo),B(t),null;case 10:return ra(t.type),B(t),null;case 19:if(D(N),r=t.memoizedState,r===null)return B(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Vc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=vo(e),o!==null){for(t.flags|=128,Vc(r,!1),e=o.updateQueue,t.updateQueue=e,Bc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)bi(n,e),n=n.sibling;return O(N,N.current&1|2),j&&Li(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Pe()>nu&&(t.flags|=128,a=!0,Vc(r,!1),t.lanes=4194304)}else{if(!a)if(e=vo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Bc(t,e),Vc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!j)return B(t),null}else 2*Pe()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Vc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(B(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Pe(),e.sibling=null,n=N.current,O(N,a?n&1|2:n&1),j&&Li(t,r.treeForkCount),e);case 22:case 23:return _o(t),lo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(B(t),t.subtreeFlags&6&&(t.flags|=8192)):B(t),n=t.updateQueue,n!==null&&Bc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&D(Ta),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ra(M),B(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Uc(e,t){switch(Bi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ra(M),be(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return xe(t),null;case 31:if(t.memoizedState!==null){if(_o(t),t.alternate===null)throw Error(i(340));Xi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(_o(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Xi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return D(N),null;case 4:return be(),null;case 10:return ra(t.type),null;case 22:case 23:return _o(t),lo(),e!==null&&D(Ta),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ra(M),null;case 25:return null;default:return null}}function Wc(e,t){switch(Bi(t),t.tag){case 3:ra(M),be();break;case 26:case 27:case 5:xe(t);break;case 4:be();break;case 31:t.memoizedState!==null&&_o(t);break;case 13:_o(t);break;case 19:D(N);break;case 10:ra(t.type);break;case 22:case 23:_o(t),lo(),e!==null&&D(Ta);break;case 24:ra(M)}}function Gc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Kc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function qc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{io(t,n)}catch(t){Z(e,e.return,t)}}}function Jc(e,t,n){n.props=Zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Yc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Xc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Zc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Qc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[gt]=t}catch(t){Z(e,e.return,t)}}function $c(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function el(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||$c(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ln));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(nl(e,t,n),e=e.sibling;e!==null;)nl(e,t,n),e=e.sibling}function rl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[ht]=e,t[gt]=n}catch(t){Z(e,e.return,t)}}var il=!1,V=!1,al=!1,ol=typeof WeakSet==`function`?WeakSet:Set,H=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Ir(e),Lr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,H=t;H!==null;)if(t=H,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,H=e;else for(;H!==null;){switch(t=H,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[ht]=e,Ot(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Pr(s,h),v=Pr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,T.T=null,n=lu,lu=null;var o=au,s=su;if(X=0,ou=au=null,su=0,W&6)throw Error(i(331));var c=W;if(W|=4,Il(o.current),Ol(o,o.current,s,n),W=c,id(0,!1),We&&typeof We.onPostCommitFiberRoot==`function`)try{We.onPostCommitFiberRoot(Ue,o)}catch{}return!0}finally{E.p=a,T.T=r,Vu(e,t)}}function Wu(e,t,n){t=Di(n,t),t=rc(e.stateNode,t,2),e=Za(e,t,2),e!==null&&(at(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=Di(n,e),n=ic(2),r=Za(t,n,2),r!==null&&(ac(n,r,t,e),at(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Wl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Pe()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=rt()),e=fi(e,t),e!==null&&(at(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Ae(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ke(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=q,a=et(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||tt(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Pe(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}X!==0&&X!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=qt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Ot(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+qt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+qt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+qt(n.imageSizes)+`"]`)):i+=`[href="`+qt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Ot(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+qt(r)+`"][href="`+qt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Ot(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Dt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Ot(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Dt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ot(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Dt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ot(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=_e.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Dt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Dt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Dt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+qt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Ot(t),e.head.appendChild(t))}function Pf(e){return`[src="`+qt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+qt(n.href)+`"]`);if(r)return t.instance=r,Ot(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Ot(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Ot(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Ot(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Ot(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Ot(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Ot(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Ot(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),b=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),x=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),ee=e=>{let t=x(e);return t.charAt(0).toUpperCase()+t.slice(1)},S={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},C=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},te=(0,_.createContext)({}),ne=()=>(0,_.useContext)(te),re=(0,_.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>{let{size:l=24,strokeWidth:u=2,absoluteStrokeWidth:d=!1,color:f=`currentColor`,className:p=``}=ne()??{},m=r??d?Number(n??u)*24/Number(t??l):n??u;return(0,_.createElement)(`svg`,{ref:c,...S,width:t??l??S.width,height:t??l??S.height,stroke:e??f,strokeWidth:m,className:y(`lucide`,p,i),...!a&&!C(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,_.createElement)(e,t)),...Array.isArray(a)?a:[a]])}),w=(e,t)=>{let n=(0,_.forwardRef)(({className:n,...r},i)=>(0,_.createElement)(re,{ref:i,iconNode:t,className:y(`lucide-${b(ee(e))}`,`lucide-${e}`,n),...r}));return n.displayName=ee(e),n},ie=w(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]),ae=w(`arrow-up-right`,[[`path`,{d:`M7 7h10v10`,key:`1tivn9`}],[`path`,{d:`M7 17 17 7`,key:`1vkiza`}]]),oe=w(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]),se=w(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]),ce=w(`chart-column`,[[`path`,{d:`M3 3v16a2 2 0 0 0 2 2h16`,key:`c24i48`}],[`path`,{d:`M18 17V9`,key:`2bz60n`}],[`path`,{d:`M13 17V5`,key:`1frdt8`}],[`path`,{d:`M8 17v-3`,key:`17ska0`}]]),le=w(`chart-pie`,[[`path`,{d:`M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z`,key:`pzmjnu`}],[`path`,{d:`M21.21 15.89A10 10 0 1 1 8 2.83`,key:`k2fpak`}]]),ue=w(`chevron-left`,[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]),T=w(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),E=w(`file-text`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),de=w(`layout-dashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),fe=w(`lightbulb`,[[`path`,{d:`M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5`,key:`1gvzjb`}],[`path`,{d:`M9 18h6`,key:`x1upvd`}],[`path`,{d:`M10 22h4`,key:`ceow96`}]]),pe=w(`message-square`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]),me=w(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),D=w(`shopping-bag`,[[`path`,{d:`M16 10a4 4 0 0 1-8 0`,key:`1ltviw`}],[`path`,{d:`M3.103 6.034h17.794`,key:`awc11p`}],[`path`,{d:`M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z`,key:`o988cm`}]]),O=w(`sliders-vertical`,[[`path`,{d:`M10 8h4`,key:`1sr2af`}],[`path`,{d:`M12 21v-9`,key:`17s77i`}],[`path`,{d:`M12 8V3`,key:`13r4qs`}],[`path`,{d:`M17 16h4`,key:`h1uq16`}],[`path`,{d:`M19 12V3`,key:`o1uvq1`}],[`path`,{d:`M19 21v-5`,key:`qua636`}],[`path`,{d:`M3 14h4`,key:`bcjad9`}],[`path`,{d:`M5 10V3`,key:`cb8scm`}],[`path`,{d:`M5 21v-7`,key:`1w1uti`}]]),he=w(`trending-down`,[[`path`,{d:`M16 17h6v-6`,key:`t6n2it`}],[`path`,{d:`m22 17-8.5-8.5-5 5L2 7`,key:`x473p`}]]),ge=w(`trending-up`,[[`path`,{d:`M16 7h6v6`,key:`box55l`}],[`path`,{d:`m22 7-8.5 8.5-5-5L2 17`,key:`1t1m79`}]]),_e=w(`user`,[[`path`,{d:`M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2`,key:`975kel`}],[`circle`,{cx:`12`,cy:`7`,r:`4`,key:`17ys0d`}]]),ve=w(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),ye=w(`wallet`,[[`path`,{d:`M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1`,key:`18etb6`}],[`path`,{d:`M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4`,key:`xoc0q4`}]]),be=o((e=>{var t=Symbol.for(`react.transitional.element`);function n(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.jsx=n,e.jsxs=n})),k=o(((e,t)=>{t.exports=be()}))();function xe(){let[e,t]=(0,_.useState)(`dashboard`);return(0,k.jsxs)(`aside`,{className:`sidebar`,children:[(0,k.jsxs)(`div`,{className:`logo-container`,children:[(0,k.jsx)(`div`,{className:`logo-icon-wrapper`,children:(0,k.jsx)(ve,{size:18,fill:`#ffffff`})}),(0,k.jsx)(`span`,{className:`logo-text`,children:`Joint Living`})]}),(0,k.jsx)(`nav`,{className:`menu-list`,children:[{id:`dashboard`,name:`대시보드`,icon:de},{id:`history`,name:`내역 관리`,icon:E},{id:`analysis`,name:`소비/예산 분석`,icon:le},{id:`compare`,name:`이웃 자산 비교`,icon:ce},{id:`qna`,name:`Q&A 게시판`,icon:pe},{id:`knowhow`,name:`노하우 공유`,icon:fe},{id:`grouppurchase`,name:`동네 공동구매`,icon:D,hasHot:!0}].map(n=>{let r=n.icon;return(0,k.jsxs)(`button`,{className:`menu-item ${e===n.id?`active`:``}`,onClick:()=>t(n.id),children:[(0,k.jsxs)(`div`,{className:`menu-item-left`,children:[(0,k.jsx)(r,{size:18}),(0,k.jsx)(`span`,{children:n.name})]}),n.hasHot&&(0,k.jsx)(`span`,{className:`hot-badge`,children:`HOT`})]},n.id)})}),(0,k.jsx)(`div`,{className:`sidebar-footer`,children:(0,k.jsx)(`button`,{className:`menu-item ${e===`settings`?`active`:``}`,onClick:()=>t(`settings`),children:(0,k.jsxs)(`div`,{className:`menu-item-left`,children:[(0,k.jsx)(me,{size:18}),(0,k.jsx)(`span`,{children:`설정 및 프로필`})]})})})]})}function Se(){return(0,k.jsxs)(`div`,{className:`subheader`,children:[(0,k.jsxs)(`div`,{className:`status-badge-container`,children:[(0,k.jsx)(`span`,{className:`status-indicator`}),(0,k.jsx)(`span`,{className:`status-badge-text`,children:`가계부 접속중`})]}),(0,k.jsxs)(`div`,{className:`subheader-actions`,children:[(0,k.jsx)(`button`,{className:`bell-btn`,"aria-label":`Notifications`,children:(0,k.jsx)(oe,{size:18})}),(0,k.jsx)(`button`,{className:`user-avatar-btn`,"aria-label":`Profile`,children:(0,k.jsx)(`div`,{className:`user-avatar-circle`,children:(0,k.jsx)(_e,{size:16})})}),(0,k.jsxs)(`button`,{className:`budget-settings-btn`,onClick:()=>{alert(`가계부 예산 설정 메뉴로 이동합니다.`)},children:[(0,k.jsx)(O,{size:14}),(0,k.jsx)(`span`,{children:`예산 설정`})]})]})]})}function Ce(){let[e,t]=(0,_.useState)(`dashboard`),[n,r]=(0,_.useState)(`expense`);return(0,k.jsxs)(`div`,{className:`dashboard-wrapper`,children:[(0,k.jsxs)(`div`,{className:`tab-date-wrapper`,children:[(0,k.jsxs)(`div`,{className:`dashboard-tabs`,children:[(0,k.jsx)(`button`,{className:`dashboard-tab ${e===`dashboard`?`active`:``}`,onClick:()=>t(`dashboard`),children:`대시보드`}),(0,k.jsx)(`button`,{className:`dashboard-tab ${e===`analysis`?`active`:``}`,onClick:()=>t(`analysis`),children:`소비 분석`}),(0,k.jsx)(`button`,{className:`dashboard-tab ${e===`budget`?`active`:``}`,onClick:()=>t(`budget`),children:`예산 관리`}),(0,k.jsx)(`button`,{className:`dashboard-tab ${e===`portfolio`?`active`:``}`,onClick:()=>t(`portfolio`),children:`자산 포트폴리오`}),(0,k.jsx)(`button`,{className:`dashboard-tab ${e===`compare`?`active`:``}`,onClick:()=>t(`compare`),children:`이웃 자산 비교`})]}),(0,k.jsxs)(`div`,{className:`date-controller`,children:[(0,k.jsx)(`button`,{className:`nav-arrow-btn`,"aria-label":`Previous month`,children:(0,k.jsx)(ue,{size:16})}),(0,k.jsxs)(`button`,{className:`date-display-btn`,children:[(0,k.jsx)(se,{size:14}),(0,k.jsx)(`span`,{children:`2026년 6월 소비분석표`})]}),(0,k.jsx)(`button`,{className:`nav-arrow-btn`,"aria-label":`Next month`,children:(0,k.jsx)(T,{size:16})})]})]}),(0,k.jsxs)(`div`,{className:`metric-grid`,children:[(0,k.jsxs)(`div`,{className:`metric-card`,children:[(0,k.jsxs)(`div`,{className:`metric-card-header`,children:[(0,k.jsx)(`span`,{className:`metric-card-title`,children:`월 총 수입`}),(0,k.jsx)(ge,{size:16,className:`metric-card-icon`,style:{color:`var(--color-green)`}})]}),(0,k.jsx)(`span`,{className:`metric-card-value`,children:`+14,546,049`}),(0,k.jsx)(`div`,{className:`metric-card-footer`,children:(0,k.jsx)(`span`,{className:`badge badge-success`,children:`전월 대비 +620,883원`})})]}),(0,k.jsxs)(`div`,{className:`metric-card`,children:[(0,k.jsxs)(`div`,{className:`metric-card-header`,children:[(0,k.jsx)(`span`,{className:`metric-card-title`,children:`월 총 지출`}),(0,k.jsx)(he,{size:16,className:`metric-card-icon`,style:{color:`var(--color-red)`}})]}),(0,k.jsx)(`span`,{className:`metric-card-value`,children:`-6,859,106`}),(0,k.jsx)(`div`,{className:`metric-card-footer`,children:(0,k.jsx)(`span`,{className:`badge badge-danger`,children:`전월 대비 +44,056원`})})]}),(0,k.jsxs)(`div`,{className:`metric-card`,children:[(0,k.jsxs)(`div`,{className:`metric-card-header`,children:[(0,k.jsx)(`span`,{className:`metric-card-title`,children:`가계부 예산 잔액`}),(0,k.jsx)(ye,{size:16,className:`metric-card-icon`})]}),(0,k.jsx)(`span`,{className:`metric-card-value`,children:`3,140,894`}),(0,k.jsx)(`div`,{className:`metric-card-footer`,children:(0,k.jsx)(`span`,{className:`metric-card-footer-text`,children:`월 예산: 10,000,000원`})})]}),(0,k.jsxs)(`div`,{className:`metric-card black-theme`,children:[(0,k.jsxs)(`div`,{className:`metric-card-header`,children:[(0,k.jsx)(`span`,{className:`metric-card-title`,children:`현재 종합 자산`}),(0,k.jsx)(ae,{size:16,className:`metric-card-icon`})]}),(0,k.jsx)(`span`,{className:`metric-card-value`,children:`663,000,000`}),(0,k.jsx)(`div`,{className:`metric-card-footer`,children:(0,k.jsx)(`span`,{className:`metric-card-footer-text`,children:`유동: 28.5M │ 투자: 64.5M`})})]})]}),(0,k.jsxs)(`div`,{className:`charts-grid`,children:[(0,k.jsxs)(`div`,{className:`dashboard-card chart-card`,children:[(0,k.jsxs)(`div`,{className:`chart-card-title-row`,children:[(0,k.jsx)(`h3`,{className:`chart-card-title`,children:`소비 분석`}),(0,k.jsxs)(`div`,{className:`donut-tabs-row`,children:[(0,k.jsx)(`button`,{className:`donut-tab`,children:`수입`}),(0,k.jsx)(`button`,{className:`donut-tab ${n===`expense`?`active`:``}`,onClick:()=>r(`expense`),children:`지출`}),(0,k.jsx)(`button`,{className:`donut-tab`,children:`저축`})]})]}),(0,k.jsxs)(`div`,{className:`donut-chart-container`,children:[(0,k.jsxs)(`svg`,{width:`150`,height:`150`,viewBox:`0 0 42 42`,children:[(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#e2e8f0`,strokeWidth:`4.2`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#3b82f6`,strokeWidth:`4.2`,strokeDasharray:`36 64`,strokeDashoffset:`100`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#10b981`,strokeWidth:`4.2`,strokeDasharray:`22 78`,strokeDashoffset:`64`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#ef4444`,strokeWidth:`4.2`,strokeDasharray:`17 83`,strokeDashoffset:`42`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#6366f1`,strokeWidth:`4.2`,strokeDasharray:`13 87`,strokeDashoffset:`25`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#94a3b8`,strokeWidth:`4.2`,strokeDasharray:`12 88`,strokeDashoffset:`12`})]}),(0,k.jsxs)(`div`,{className:`donut-label-center`,children:[(0,k.jsx)(`span`,{className:`donut-label-value`,children:`6,859,106`}),(0,k.jsx)(`span`,{className:`donut-label-title`,children:`총 지출 (원)`})]})]}),(0,k.jsx)(`div`,{className:`donut-legend-list`,children:[{name:`식비`,percent:36,amount:25e5,color:`#3b82f6`},{name:`주거비`,percent:22,amount:15e5,color:`#10b981`},{name:`육아용품`,percent:17,amount:12e5,color:`#ef4444`},{name:`교통/통신`,percent:13,amount:859106,color:`#6366f1`},{name:`기타/예비비`,percent:12,amount:8e5,color:`#94a3b8`}].map(e=>(0,k.jsxs)(`div`,{className:`donut-legend-item`,children:[(0,k.jsxs)(`div`,{className:`legend-left`,children:[(0,k.jsx)(`span`,{className:`legend-color-dot`,style:{backgroundColor:e.color}}),(0,k.jsx)(`span`,{className:`legend-name`,children:e.name})]}),(0,k.jsxs)(`div`,{className:`legend-right`,children:[(0,k.jsxs)(`span`,{className:`legend-percent`,children:[e.percent,`%`]}),(0,k.jsx)(`span`,{className:`legend-amount`,children:e.amount.toLocaleString()})]})]},e.name))}),(0,k.jsx)(`div`,{className:`chart-card-title-row`,style:{marginTop:`16px`,borderBottom:`1px solid var(--border-color)`},children:(0,k.jsx)(`h3`,{className:`chart-card-title`,children:`하위 카테고리 상세`})}),(0,k.jsxs)(`div`,{className:`donut-chart-container`,children:[(0,k.jsxs)(`svg`,{width:`150`,height:`150`,viewBox:`0 0 42 42`,children:[(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#e2e8f0`,strokeWidth:`4.2`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#60a5fa`,strokeWidth:`4.2`,strokeDasharray:`17 83`,strokeDashoffset:`100`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#3b82f6`,strokeWidth:`4.2`,strokeDasharray:`19 81`,strokeDashoffset:`83`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#10b981`,strokeWidth:`4.2`,strokeDasharray:`15 85`,strokeDashoffset:`64`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#f87171`,strokeWidth:`4.2`,strokeDasharray:`7 93`,strokeDashoffset:`49`}),(0,k.jsx)(`circle`,{cx:`21`,cy:`21`,r:`15.915`,fill:`transparent`,stroke:`#a7f3d0`,strokeWidth:`4.2`,strokeDasharray:`17 83`,strokeDashoffset:`42`})]}),(0,k.jsxs)(`div`,{className:`donut-label-center`,children:[(0,k.jsx)(`span`,{className:`donut-label-value`,children:`5,200,000`}),(0,k.jsx)(`span`,{className:`donut-label-title`,children:`상위 5종 (원)`})]})]}),(0,k.jsx)(`div`,{className:`donut-legend-list`,children:[{name:`식비 > 외식`,percent:17,amount:12e5,color:`#60a5fa`},{name:`식비 > 식자재 (공구)`,percent:19,amount:13e5,color:`#3b82f6`},{name:`주거비 > 대출이자`,percent:15,amount:1e6,color:`#10b981`},{name:`주거비 > 관리비`,percent:7,amount:5e5,color:`#f87171`},{name:`육아용품 > 기저귀/분유`,percent:17,amount:12e5,color:`#a7f3d0`}].map(e=>(0,k.jsxs)(`div`,{className:`donut-legend-item`,children:[(0,k.jsxs)(`div`,{className:`legend-left`,children:[(0,k.jsx)(`span`,{className:`legend-color-dot`,style:{backgroundColor:e.color}}),(0,k.jsx)(`span`,{className:`legend-name`,children:e.name})]}),(0,k.jsxs)(`div`,{className:`legend-right`,children:[(0,k.jsxs)(`span`,{className:`legend-percent`,children:[e.percent,`%`]}),(0,k.jsx)(`span`,{className:`legend-amount`,children:e.amount.toLocaleString()})]})]},e.name))})]}),(0,k.jsxs)(`div`,{className:`dashboard-card chart-card`,children:[(0,k.jsx)(`div`,{className:`chart-card-title-row`,children:(0,k.jsx)(`h3`,{className:`chart-card-title`,children:`월별 현금흐름 분석`})}),(0,k.jsxs)(`div`,{className:`bar-chart-stats`,children:[(0,k.jsxs)(`div`,{className:`stat-item`,children:[(0,k.jsxs)(`div`,{className:`stat-label-group`,children:[(0,k.jsx)(`span`,{className:`stat-dot blue`}),(0,k.jsx)(`span`,{children:`평균 수입금액`})]}),(0,k.jsx)(`span`,{className:`stat-value`,children:`16,400,000원`}),(0,k.jsx)(`span`,{style:{fontSize:`0.7rem`,color:`var(--text-muted)`},children:`최근 6개월 평균`})]}),(0,k.jsxs)(`div`,{className:`stat-item`,children:[(0,k.jsxs)(`div`,{className:`stat-label-group`,children:[(0,k.jsx)(`span`,{className:`stat-dot red`}),(0,k.jsx)(`span`,{children:`평균 지출금액`})]}),(0,k.jsx)(`span`,{className:`stat-value`,children:`6,750,000원`}),(0,k.jsx)(`span`,{style:{fontSize:`0.7rem`,color:`var(--text-muted)`},children:`최근 6개월 평균`})]})]}),(0,k.jsx)(`div`,{className:`bar-chart-visual`,children:[{m:`1월`,h1:85,h2:30,h3:20},{m:`2월`,h1:65,h2:25,h3:35},{m:`3월`,h1:60,h2:35,h3:32},{m:`4월`,h1:65,h2:45,h3:30},{m:`5월`,h1:70,h2:40,h3:38},{m:`6월`,h1:80,h2:50,h3:42}].map(e=>(0,k.jsxs)(`div`,{className:`bar-column-wrapper`,children:[(0,k.jsxs)(`div`,{className:`bar-stack`,children:[(0,k.jsx)(`div`,{className:`bar-fill income`,style:{height:`${e.h1}%`}}),(0,k.jsx)(`div`,{className:`bar-fill save`,style:{height:`${e.h2}%`}}),(0,k.jsx)(`div`,{className:`bar-fill expense`,style:{height:`${e.h3}%`}})]}),(0,k.jsx)(`span`,{className:`bar-month-label`,children:e.m})]},e.m))}),(0,k.jsx)(`div`,{style:{marginTop:`10px`},children:(0,k.jsxs)(`table`,{className:`data-table`,children:[(0,k.jsx)(`thead`,{children:(0,k.jsxs)(`tr`,{children:[(0,k.jsx)(`th`,{children:`월`}),(0,k.jsx)(`th`,{children:`수입`}),(0,k.jsx)(`th`,{children:`지출`}),(0,k.jsx)(`th`,{children:`저축`}),(0,k.jsx)(`th`,{children:`저축률`})]})}),(0,k.jsx)(`tbody`,{children:[{month:`1월`,income:`2,820`,expense:`663`,save:`300`,ratio:`10.6%`},{month:`2월`,income:`1,390`,expense:`704`,save:`300`,ratio:`21.6%`},{month:`3월`,income:`1,388`,expense:`691`,save:`300`,ratio:`21.6%`},{month:`4월`,income:`1,392`,expense:`651`,save:`300`,ratio:`21.6%`},{month:`5월`,income:`1,393`,expense:`682`,save:`300`,ratio:`21.5%`},{month:`6월`,income:`1,455`,expense:`680`,save:`300`,ratio:`20.6%`}].map(e=>(0,k.jsxs)(`tr`,{children:[(0,k.jsx)(`td`,{className:`table-month`,children:e.month}),(0,k.jsx)(`td`,{className:`table-income`,children:e.income}),(0,k.jsx)(`td`,{className:`table-expense`,children:e.expense}),(0,k.jsx)(`td`,{className:`table-save`,children:e.save}),(0,k.jsx)(`td`,{className:`table-ratio`,children:e.ratio})]},e.month))})]})})]}),(0,k.jsxs)(`div`,{className:`dashboard-card chart-card`,children:[(0,k.jsxs)(`div`,{className:`chart-card-title-row`,children:[(0,k.jsx)(`h3`,{className:`chart-card-title`,children:`월흐름 추세`}),(0,k.jsx)(`span`,{className:`chart-card-subtitle`,children:`2026년 반기`})]}),(0,k.jsxs)(`div`,{className:`line-chart-legend`,children:[(0,k.jsxs)(`div`,{className:`legend-indicator`,children:[(0,k.jsx)(`span`,{className:`legend-color-dot`,style:{backgroundColor:`var(--color-primary)`}}),(0,k.jsx)(`span`,{children:`수입`})]}),(0,k.jsxs)(`div`,{className:`legend-indicator`,children:[(0,k.jsx)(`span`,{className:`legend-color-dot`,style:{backgroundColor:`var(--color-green)`}}),(0,k.jsx)(`span`,{children:`저축`})]}),(0,k.jsxs)(`div`,{className:`legend-indicator`,children:[(0,k.jsx)(`span`,{className:`legend-color-dot`,style:{backgroundColor:`var(--color-red)`}}),(0,k.jsx)(`span`,{children:`지출`})]})]}),(0,k.jsx)(`div`,{className:`line-chart-svg-container`,children:(0,k.jsxs)(`svg`,{width:`100%`,height:`100%`,viewBox:`0 0 300 120`,preserveAspectRatio:`none`,children:[(0,k.jsx)(`line`,{x1:`0`,y1:`20`,x2:`300`,y2:`20`,stroke:`#f1f5f9`,strokeWidth:`1`}),(0,k.jsx)(`line`,{x1:`0`,y1:`60`,x2:`300`,y2:`60`,stroke:`#f1f5f9`,strokeWidth:`1`}),(0,k.jsx)(`line`,{x1:`0`,y1:`100`,x2:`300`,y2:`100`,stroke:`#f1f5f9`,strokeWidth:`1`}),(0,k.jsx)(`path`,{d:`M 10 30 L 60 45 L 110 50 L 160 35 L 210 40 L 260 25`,fill:`none`,stroke:`var(--color-primary)`,strokeWidth:`2`}),(0,k.jsx)(`path`,{d:`M 10 80 L 60 70 L 110 75 L 160 85 L 210 78 L 260 68`,fill:`none`,stroke:`var(--color-green)`,strokeWidth:`2`,strokeDasharray:`3 3`}),(0,k.jsx)(`path`,{d:`M 10 95 L 60 90 L 110 85 L 160 92 L 210 90 L 260 88`,fill:`none`,stroke:`var(--color-red)`,strokeWidth:`2`}),(0,k.jsx)(`circle`,{cx:`260`,cy:`25`,r:`3.5`,fill:`var(--color-primary)`}),(0,k.jsx)(`circle`,{cx:`260`,cy:`68`,r:`3.5`,fill:`var(--color-green)`}),(0,k.jsx)(`circle`,{cx:`260`,cy:`88`,r:`3.5`,fill:`var(--color-red)`})]})}),(0,k.jsxs)(`div`,{className:`asset-save-summary-box`,children:[(0,k.jsxs)(`div`,{className:`summary-box-item`,children:[(0,k.jsx)(`span`,{className:`summary-box-label`,children:`총 자산`}),(0,k.jsx)(`span`,{className:`summary-box-val`,children:`663,000,000원`})]}),(0,k.jsxs)(`div`,{className:`summary-box-item`,children:[(0,k.jsx)(`span`,{className:`summary-box-label`,children:`이달 저축`}),(0,k.jsx)(`span`,{className:`summary-box-val`,style:{color:`var(--color-green)`},children:`3,000,000원`})]})]}),(0,k.jsx)(`div`,{className:`chart-card-title-row`,style:{marginTop:`8px`,borderBottom:`1px solid var(--border-color)`},children:(0,k.jsx)(`h3`,{className:`chart-card-title`,children:`상세 내역`})}),(0,k.jsxs)(`div`,{style:{display:`flex`,justifyContent:`space-between`,fontSize:`0.8rem`,padding:`0 4px`},children:[(0,k.jsx)(`span`,{style:{color:`var(--text-muted)`},children:`전월 대비`}),(0,k.jsx)(`span`,{className:`badge badge-success`,style:{padding:`2px 8px`},children:`+ 4.5%`})]}),(0,k.jsxs)(`div`,{style:{display:`flex`,justifyContent:`space-between`,fontSize:`0.8rem`,padding:`0 4px`,marginTop:`-8px`},children:[(0,k.jsx)(`span`,{style:{color:`var(--text-muted)`},children:`연초 대비`}),(0,k.jsx)(`span`,{className:`badge badge-success`,style:{padding:`2px 8px`},children:`+ 8.2%`})]}),(0,k.jsx)(`div`,{className:`asset-ratio-list`,style:{marginTop:`4px`},children:[{name:`유동자산`,amount:`28.5M`,percent:4.3,colorClass:`blue`},{name:`투자자산`,amount:`64.5M`,percent:9.7,colorClass:`teal`},{name:`비유동자산`,amount:`570.0M`,percent:86,colorClass:`purple`}].map(e=>(0,k.jsxs)(`div`,{className:`asset-ratio-item`,children:[(0,k.jsxs)(`div`,{className:`ratio-label-row`,children:[(0,k.jsx)(`span`,{className:`ratio-name`,children:e.name}),(0,k.jsxs)(`span`,{className:`ratio-val`,children:[e.amount,` (`,e.percent,`%)`]})]}),(0,k.jsx)(`div`,{className:`ratio-progress-bg`,children:(0,k.jsx)(`div`,{className:`ratio-progress-fill ${e.colorClass}`,style:{width:`${e.percent}%`}})})]},e.name))})]})]}),(0,k.jsxs)(`div`,{className:`gp-section`,children:[(0,k.jsxs)(`div`,{className:`gp-section-header`,children:[(0,k.jsxs)(`div`,{className:`gp-section-title-group`,children:[(0,k.jsx)(`div`,{className:`gp-icon-wrapper`,children:(0,k.jsx)(D,{size:18})}),(0,k.jsx)(`h2`,{className:`gp-section-title`,children:`동네 공동구매 현황`})]}),(0,k.jsxs)(`button`,{className:`gp-more-btn`,children:[(0,k.jsx)(`span`,{children:`전체보기`}),(0,k.jsx)(ie,{size:14})]})]}),(0,k.jsx)(`div`,{className:`gp-grid`,children:[{id:1,category:`생필품`,title:`친환경 세탁세제 대용량 공구`,status:`RECRUITING`,statusText:`모집중 (D-2)`,rate:125,price:`15,000`,colorClass:`green`},{id:2,category:`식품`,title:`제주 유기농 흑돼지 1kg`,status:`WARNING`,statusText:`마감임박 (D-1)`,rate:85,price:`28,000`,colorClass:`blue`},{id:3,category:`육아용품`,title:`프리미엄 기저귀 박스떼기`,status:`CLOSED`,statusText:`진행완료 (마감)`,rate:100,price:`45,000`,colorClass:`green`}].map(e=>(0,k.jsxs)(`div`,{className:`gp-card`,children:[(0,k.jsxs)(`div`,{className:`gp-badge-row`,children:[(0,k.jsx)(`span`,{className:`gp-cat-badge`,children:e.category}),(0,k.jsx)(`span`,{className:`gp-status-badge ${e.status===`RECRUITING`?`d-day`:e.status===`WARNING`?`warning`:`closed`}`,children:e.statusText})]}),(0,k.jsx)(`h4`,{className:`gp-card-title`,children:e.title}),(0,k.jsxs)(`div`,{className:`gp-progress-container`,children:[(0,k.jsxs)(`div`,{className:`gp-progress-label`,children:[(0,k.jsx)(`span`,{style:{color:`var(--text-muted)`,fontSize:`0.75rem`},children:`목표 달성률`}),(0,k.jsxs)(`span`,{style:{color:e.colorClass===`green`?`var(--color-green)`:`var(--color-primary)`,fontSize:`0.75rem`},children:[e.rate,`%`]})]}),(0,k.jsx)(`div`,{className:`gp-progress-bar-bg`,children:(0,k.jsx)(`div`,{className:`gp-progress-bar-fill ${e.colorClass}`,style:{width:`${Math.min(100,e.rate)}%`}})})]}),(0,k.jsxs)(`div`,{className:`gp-price-row`,children:[(0,k.jsx)(`span`,{className:`gp-price-label`,children:`공구 가격`}),(0,k.jsxs)(`span`,{className:`gp-price-val`,children:[e.price,` 원`]})]})]},e.id))})]})]})}function we(){return(0,k.jsxs)(`div`,{className:`app-layout`,children:[(0,k.jsx)(xe,{}),(0,k.jsxs)(`div`,{className:`main-content`,children:[(0,k.jsx)(Se,{}),(0,k.jsx)(`main`,{children:(0,k.jsx)(Ce,{})})]})]})}(0,v.createRoot)(document.getElementById(`root`)).render((0,k.jsx)(_.StrictMode,{children:(0,k.jsx)(we,{})})); \ No newline at end of file diff --git a/frontend/dist/assets/index-l2DSzhvx.css b/frontend/dist/assets/index-l2DSzhvx.css new file mode 100644 index 0000000..4cd1249 --- /dev/null +++ b/frontend/dist/assets/index-l2DSzhvx.css @@ -0,0 +1 @@ +@import "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap";:root{--bg-primary:#f8fafc;--bg-secondary:#fff;--bg-card:#fff;--border-color:#e2e8f0;--color-primary:#3b82f6;--color-primary-light:#eff6ff;--color-green:#10b981;--color-green-light:#ecfdf5;--color-red:#ef4444;--color-red-light:#fef2f2;--color-purple:#6366f1;--color-teal:#14b8a6;--color-dark:#0f172a;--text-main:#1e293b;--text-muted:#64748b;--text-sub:#94a3b8;--card-shadow:0 4px 6px -1px #0000000d, 0 2px 4px -2px #0000000d;--border-radius-lg:16px;--border-radius-md:12px;--border-radius-sm:8px;--transition-smooth:all .2s cubic-bezier(.4, 0, .2, 1);--font-family:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif}*{box-sizing:border-box;margin:0;padding:0}body{font-family:var(--font-family);background-color:var(--bg-primary);color:var(--text-main);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;min-height:100vh;line-height:1.5;overflow-x:hidden}a{color:inherit;text-decoration:none}button{cursor:pointer;color:inherit;background:0 0;border:none;font-family:inherit}ul,ol{list-style:none}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:#0000001a;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#0003}.app-layout{grid-template-columns:240px 1fr;min-height:100vh;display:grid}.main-content{padding:0 40px 40px;overflow-y:auto}.dashboard-card{background:var(--bg-card);border:1px solid var(--border-color);box-shadow:var(--card-shadow);border-radius:var(--border-radius-lg);padding:24px}.badge{border-radius:4px;align-items:center;padding:4px 8px;font-size:.75rem;font-weight:600;display:inline-flex}.badge-success{background-color:var(--color-green-light);color:var(--color-green)}.badge-danger{background-color:var(--color-red-light);color:var(--color-red)}.badge-primary{background-color:var(--color-primary-light);color:var(--color-primary)}.sidebar{background-color:var(--bg-secondary);border-right:1px solid var(--border-color);flex-direction:column;width:240px;height:100vh;padding:24px 16px;display:flex;position:sticky;top:0}.logo-container{align-items:center;gap:10px;padding:12px 12px 32px;display:flex}.logo-icon-wrapper{border-radius:var(--border-radius-sm);background-color:var(--color-primary);color:#fff;justify-content:center;align-items:center;width:32px;height:32px;display:flex}.logo-text{color:var(--color-dark);letter-spacing:-.5px;font-size:1.2rem;font-weight:800}.menu-list{flex-direction:column;flex-grow:1;gap:6px;display:flex}.menu-item{border-radius:var(--border-radius-md);color:var(--text-muted);transition:var(--transition-smooth);justify-content:space-between;align-items:center;width:100%;padding:12px 16px;font-size:.95rem;font-weight:600;display:flex}.menu-item-left{align-items:center;gap:12px;display:flex}.menu-item:hover,.menu-item.active{background-color:var(--color-primary-light);color:var(--color-primary)}.hot-badge{background-color:var(--color-red);color:#fff;letter-spacing:.5px;border-radius:4px;padding:2px 6px;font-size:.65rem;font-weight:700}.sidebar-footer{border-top:1px solid var(--border-color);padding-top:16px}.subheader{border-bottom:1px solid var(--border-color);background-color:var(--bg-primary);justify-content:space-between;align-items:center;height:70px;margin-bottom:24px;display:flex}.status-badge-container{background-color:var(--color-green-light);border:1px solid #10b98126;border-radius:20px;align-items:center;gap:8px;padding:6px 12px;display:flex}.status-indicator{background-color:var(--color-green);border-radius:50%;width:8px;height:8px}.status-badge-text{color:var(--color-green);font-size:.8rem;font-weight:600}.subheader-actions{align-items:center;gap:16px;display:flex}.bell-btn{color:var(--text-muted);border:1px solid var(--border-color);transition:var(--transition-smooth);background:#fff;border-radius:50%;justify-content:center;align-items:center;padding:8px;display:flex}.bell-btn:hover{color:var(--color-dark);background-color:#f1f5f9}.user-avatar-btn{border:1px solid var(--border-color);width:36px;height:36px;transition:var(--transition-smooth);background-color:#fff;border-radius:50%;justify-content:center;align-items:center;display:flex}.user-avatar-btn:hover{background-color:#f1f5f9}.user-avatar-circle{width:28px;height:28px;color:var(--text-muted);background-color:#e2e8f0;border-radius:50%;justify-content:center;align-items:center;font-size:.8rem;font-weight:700;display:flex}.budget-settings-btn{background-color:var(--color-dark);color:#fff;transition:var(--transition-smooth);border-radius:6px;align-items:center;gap:6px;padding:8px 16px;font-size:.85rem;font-weight:600;display:flex}.budget-settings-btn:hover{background-color:#1e293b;box-shadow:0 4px 12px #0f172a26}.tab-date-wrapper{justify-content:space-between;align-items:center;margin-bottom:24px;display:flex}.dashboard-tabs{border-radius:var(--border-radius-sm);background-color:#f1f5f9;gap:8px;padding:4px;display:flex}.dashboard-tab{color:var(--text-muted);border-radius:var(--border-radius-sm);transition:var(--transition-smooth);padding:8px 16px;font-size:.85rem;font-weight:600}.dashboard-tab:hover{color:var(--text-main)}.dashboard-tab.active{background-color:var(--color-dark);color:#fff}.date-controller{align-items:center;gap:8px;display:flex}.nav-arrow-btn{border:1px solid var(--border-color);width:32px;height:32px;color:var(--text-muted);transition:var(--transition-smooth);background-color:#fff;border-radius:4px;justify-content:center;align-items:center;display:flex}.nav-arrow-btn:hover{color:var(--color-dark);background-color:#f1f5f9}.date-display-btn{border:1px solid var(--border-color);border-radius:var(--border-radius-sm);color:var(--text-main);background-color:#fff;align-items:center;gap:8px;padding:8px 16px;font-size:.85rem;font-weight:600;display:flex}.metric-grid{grid-template-columns:repeat(1,1fr);gap:20px;margin-bottom:24px;display:grid}@media (width>=640px){.metric-grid{grid-template-columns:repeat(2,1fr)}}@media (width>=1024px){.metric-grid{grid-template-columns:repeat(4,1fr)}}.metric-card{background-color:var(--bg-secondary);border:1px solid var(--border-color);box-shadow:var(--card-shadow);border-radius:var(--border-radius-lg);flex-direction:column;gap:16px;padding:24px;display:flex;position:relative}.metric-card.black-theme{background-color:var(--color-dark);color:#fff;border:none}.metric-card-header{justify-content:space-between;align-items:center;display:flex}.metric-card-title{color:var(--text-muted);font-size:.75rem;font-weight:600}.metric-card.black-theme .metric-card-title{color:#94a3b8}.metric-card-icon{color:var(--text-sub)}.metric-card.black-theme .metric-card-icon{color:#475569}.metric-card-value{color:var(--color-dark);letter-spacing:-.5px;font-size:1.6rem;font-weight:800}.metric-card.black-theme .metric-card-value{color:#fff}.metric-card-footer{align-items:center;gap:8px;display:flex}.metric-card-footer-text{color:var(--text-muted);font-size:.75rem}.metric-card.black-theme .metric-card-footer-text{color:#94a3b8}.charts-grid{grid-template-columns:repeat(1,1fr);gap:24px;margin-bottom:24px;display:grid}@media (width>=768px){.charts-grid{grid-template-columns:repeat(2,1fr)}}@media (width>=1200px){.charts-grid{grid-template-columns:repeat(3,1fr)}}.chart-card{flex-direction:column;gap:20px;display:flex}.chart-card-title-row{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;margin-bottom:4px;padding-bottom:16px;display:flex}.chart-card-title{color:var(--color-dark);letter-spacing:-.3px;font-size:1.05rem;font-weight:700}.chart-card-subtitle{color:var(--text-muted);font-size:.8rem;font-weight:500}.donut-tabs-row{background-color:#f1f5f9;border-radius:6px;align-self:flex-start;gap:4px;padding:3px;display:flex}.donut-tab{color:var(--text-muted);transition:var(--transition-smooth);border-radius:4px;padding:4px 12px;font-size:.75rem;font-weight:600}.donut-tab.active{color:var(--color-dark);background-color:#fff;box-shadow:0 1px 3px #0000000d}.donut-chart-container{justify-content:center;align-items:center;height:160px;display:flex;position:relative}.donut-label-center{flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute}.donut-label-value{color:var(--color-dark);font-size:1.25rem;font-weight:800}.donut-label-title{color:var(--text-muted);font-size:.7rem;font-weight:500}.donut-legend-list{flex-direction:column;gap:12px;display:flex}.donut-legend-item{justify-content:space-between;align-items:center;font-size:.85rem;display:flex}.legend-left{align-items:center;gap:8px;display:flex}.legend-color-dot{border-radius:50%;width:8px;height:8px}.legend-name{color:var(--text-muted);font-weight:500}.legend-right{align-items:center;gap:12px;font-weight:600;display:flex}.legend-percent{color:var(--color-dark)}.legend-amount{color:var(--text-muted);text-align:right;min-width:75px;font-size:.8rem}.bar-chart-stats{gap:24px;display:flex}.stat-item{flex-direction:column;flex:1;gap:4px;display:flex}.stat-label-group{color:var(--text-muted);align-items:center;gap:6px;font-size:.75rem;font-weight:500;display:flex}.stat-dot{border-radius:50%;width:6px;height:6px}.stat-dot.blue{background-color:var(--color-primary)}.stat-dot.green{background-color:var(--color-green)}.stat-dot.red{background-color:var(--color-red)}.stat-value{color:var(--color-dark);font-size:1.1rem;font-weight:800}.bar-chart-visual{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:flex-end;height:120px;padding:10px 0;display:flex}.bar-column-wrapper{flex-direction:column;flex:1;align-items:center;gap:8px;display:flex}.bar-stack{background-color:#f1f5f9;border-radius:5px;flex-direction:column-reverse;width:10px;height:80px;display:flex;overflow:hidden}.bar-fill{width:100%}.bar-fill.income{background-color:var(--color-primary)}.bar-fill.save{background-color:var(--color-green)}.bar-fill.expense{background-color:var(--color-red)}.bar-month-label{color:var(--text-muted);font-size:.75rem;font-weight:500}.data-table{border-collapse:collapse;width:100%}.data-table th{color:var(--text-muted);text-align:left;border-bottom:1px solid var(--border-color);padding-bottom:10px;font-size:.75rem;font-weight:600}.data-table td{color:var(--text-main);border-bottom:1px dashed #e2e8f080;padding:8px 0;font-size:.8rem;font-weight:500}.data-table tr:last-child td{border-bottom:none}.table-month{color:var(--color-dark);font-weight:600}.table-income,.table-expense{color:var(--color-dark)}.table-save{color:var(--color-green);font-weight:600}.table-ratio{color:var(--text-muted)}.line-chart-legend{color:var(--text-muted);align-self:center;gap:16px;font-size:.75rem;font-weight:500;display:flex}.legend-indicator{align-items:center;gap:6px;display:flex}.line-chart-svg-container{width:100%;height:120px}.asset-save-summary-box{border-radius:var(--border-radius-md);border:1px solid var(--border-color);background-color:#f8fafc;grid-template-columns:1fr 1fr;gap:12px;padding:16px;display:grid}.summary-box-item{flex-direction:column;gap:4px;display:flex}.summary-box-label{color:var(--text-muted);font-size:.75rem;font-weight:500}.summary-box-val{color:var(--color-dark);font-size:1.05rem;font-weight:800}.asset-ratio-list{flex-direction:column;gap:16px;display:flex}.asset-ratio-item{flex-direction:column;gap:8px;display:flex}.ratio-label-row{justify-content:space-between;font-size:.8rem;font-weight:500;display:flex}.ratio-name{color:var(--text-muted)}.ratio-val{color:var(--color-dark);font-weight:600}.ratio-progress-bg{background-color:#f1f5f9;border-radius:3px;width:100%;height:6px}.ratio-progress-fill{border-radius:3px;height:100%}.ratio-progress-fill.blue{background-color:var(--color-primary)}.ratio-progress-fill.teal{background-color:var(--color-teal)}.ratio-progress-fill.purple{background-color:var(--color-purple)}.gp-section{background-color:var(--bg-secondary);border:1px solid var(--border-color);box-shadow:var(--card-shadow);border-radius:var(--border-radius-lg);padding:24px}.gp-section-header{justify-content:space-between;align-items:center;margin-bottom:24px;display:flex}.gp-section-title-group{align-items:center;gap:10px;display:flex}.gp-icon-wrapper{border-radius:var(--border-radius-md);background-color:var(--color-primary-light);width:36px;height:36px;color:var(--color-primary);justify-content:center;align-items:center;display:flex}.gp-section-title{color:var(--color-dark);font-size:1.15rem;font-weight:700}.gp-more-btn{color:var(--text-muted);transition:var(--transition-smooth);align-items:center;gap:4px;font-size:.8rem;font-weight:600;display:flex}.gp-more-btn:hover{color:var(--color-primary)}.gp-grid{grid-template-columns:repeat(1,1fr);gap:20px;display:grid}@media (width>=768px){.gp-grid{grid-template-columns:repeat(3,1fr)}}.gp-card{border:1px solid var(--border-color);border-radius:var(--border-radius-md);transition:var(--transition-smooth);background-color:#fff;flex-direction:column;gap:16px;padding:20px;display:flex}.gp-card:hover{transform:translateY(-2px);box-shadow:0 8px 20px #00000008}.gp-badge-row{justify-content:space-between;align-items:center;display:flex}.gp-cat-badge{color:var(--text-muted);background-color:#f1f5f9;border-radius:4px;padding:4px 8px;font-size:.7rem;font-weight:600}.gp-status-badge{border-radius:4px;padding:4px 8px;font-size:.7rem;font-weight:600}.gp-status-badge.d-day{background-color:var(--color-primary-light);color:var(--color-primary)}.gp-status-badge.warning{background-color:var(--color-red-light);color:var(--color-red)}.gp-status-badge.closed{color:var(--text-muted);background-color:#f1f5f9}.gp-card-title{color:var(--color-dark);-webkit-line-clamp:2;-webkit-box-orient:vertical;height:40px;font-size:.95rem;font-weight:700;line-height:1.4;display:-webkit-box;overflow:hidden}.gp-progress-container{flex-direction:column;gap:6px;display:flex}.gp-progress-label{justify-content:space-between;font-size:.75rem;font-weight:600;display:flex}.gp-progress-bar-bg{background-color:#f1f5f9;border-radius:2.5px;width:100%;height:5px;overflow:hidden}.gp-progress-bar-fill{border-radius:2.5px;height:100%}.gp-progress-bar-fill.green{background-color:var(--color-green)}.gp-progress-bar-fill.blue{background-color:var(--color-primary)}.gp-price-row{border-top:1px dashed var(--border-color);justify-content:space-between;align-items:center;margin-top:4px;padding-top:12px;display:flex}.gp-price-label{color:var(--text-muted);font-size:.75rem;font-weight:500}.gp-price-val{color:var(--color-dark);font-size:1.05rem;font-weight:800} diff --git a/frontend/dist/favicon.svg b/frontend/dist/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/dist/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/dist/icons.svg b/frontend/dist/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/dist/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..55d3904 --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + frontend + + + + +
+ + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3a12253 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,46 @@ +events { + worker_connections 1032; +} + +http { + client_max_body_size 6m; + + resolver 127.0.0.11 valid=10s ipv6=off; + + upstream backend { + server joint-living-backend:8000; + } + + server { + listen 80; + server_name api.moneydiary.cloud; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + server_name api.moneydiary.cloud; + + ssl_certificate /etc/letsencrypt/live/api.moneydiary.cloud/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.moneydiary.cloud/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + set $backend_upstream http://joint-living-backend:8000; + proxy_pass $backend_upstream; + 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; + } + } +} \ No newline at end of file diff --git a/patch_service.py b/patch_service.py new file mode 100644 index 0000000..c3047c7 --- /dev/null +++ b/patch_service.py @@ -0,0 +1,83 @@ +import sys + +file_path = "/Users/ongsaem/JointLiving/Backend/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseService.java" + +with open(file_path, 'r') as f: + content = f.read() + +# 1. Modify createGroupPurchase +create_target = """ GroupPurchase saved = groupPurchaseRepository.save(groupPurchase); + + String categoryName = groupPurchaseCategoryRepository.findById(request.categoryId())""" + +create_replacement = """ GroupPurchase saved = groupPurchaseRepository.save(groupPurchase); + + // deduct budget for creator + deductBudgetForUser(creatorId, request.accountId(), saved); + + String categoryName = groupPurchaseCategoryRepository.findById(request.categoryId())""" + +if create_target in content: + content = content.replace(create_target, create_replacement) +else: + print("Could not find createGroupPurchase target") + +# 2. Modify joinGroupPurchase +join_target = """ GroupPurchaseParticipant participant = GroupPurchaseParticipant.builder() + .groupPurchaseId(groupPurchaseId) + .userId(userId) + .accountId(request.accountId()) + .build(); + groupPurchaseParticipantRepository.save(participant); + + groupPurchase.join();""" + +join_replacement = """ GroupPurchaseParticipant participant = GroupPurchaseParticipant.builder() + .groupPurchaseId(groupPurchaseId) + .userId(userId) + .accountId(request.accountId()) + .build(); + groupPurchaseParticipantRepository.save(participant); + + // deduct budget for joiner + deductBudgetForUser(userId, request.accountId(), groupPurchase); + + groupPurchase.join();""" + +if join_target in content: + content = content.replace(join_target, join_replacement) +else: + print("Could not find joinGroupPurchase target") + +# 3. Modify updateGroupPurchaseStatusByAdmin +admin_target = """ @Transactional + public void updateGroupPurchaseStatusByAdmin(Long id, PurchaseStatus status) { + GroupPurchase gp = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + gp.updateStatusByAdmin(status); + }""" + +admin_replacement = """ @Transactional + public void updateGroupPurchaseStatusByAdmin(Long id, PurchaseStatus status) { + GroupPurchase gp = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + gp.updateStatusByAdmin(status); + + if (status == PurchaseStatus.FAILED) { + refundBudgetForUser(gp.getCreatorId(), gp.getCreatorAccountId(), gp); + List participants = groupPurchaseParticipantRepository.findByGroupPurchaseId(id); + for (GroupPurchaseParticipant participant : participants) { + refundBudgetForUser(participant.getUserId(), participant.getAccountId(), gp); + } + } + }""" + +if admin_target in content: + content = content.replace(admin_target, admin_replacement) +else: + print("Could not find updateGroupPurchaseStatusByAdmin target") + +with open(file_path, 'w') as f: + f.write(content) + +print("Patch applied.") diff --git a/query_db.py b/query_db.py new file mode 100644 index 0000000..7a45986 --- /dev/null +++ b/query_db.py @@ -0,0 +1,33 @@ +import mysql.connector + +try: + conn = mysql.connector.connect( + host="localhost", + user="jointliving", + password="jointliving", + database="jointliving" + ) + cursor = conn.cursor() + + tables = ["group_purchase", "board", "comment", "group_purchase_category", "users"] + for t in tables: + print(f"\n--- Table: {t} ---") + try: + if t == "users": + cursor.execute(f"SELECT id, username, email FROM {t} LIMIT 10") + elif t == "group_purchase_category": + cursor.execute(f"SELECT id, name FROM {t} LIMIT 10") + elif t == "comment": + cursor.execute(f"SELECT id, content FROM {t} LIMIT 10") + else: + cursor.execute(f"SELECT id, title FROM {t} LIMIT 10") + + rows = cursor.fetchall() + for row in rows: + print(row) + except Exception as e: + print(f"Error querying {t}: {e}") + + conn.close() +except Exception as e: + print(f"Connection error: {e}") diff --git a/scripts/run-bench.sh b/scripts/run-bench.sh new file mode 100755 index 0000000..7f29c21 --- /dev/null +++ b/scripts/run-bench.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run DB I/O benchmarks in two passes to compare JDBC batch_size effect. +# +# Pass 1 (batch_size=20): R1 (Board index) + R2 (Comment index) + U1 (batch=20) +# Pass 2 (batch_size=1): U1 (batch=1) only — R1/R2 do not depend on batch_size. +# +# CSV is appended across runs. Delete the file to start fresh. +set -euo pipefail + +cd "$(dirname "$0")/.." +CSV="docs/benchmark/results/2026-08-05_db_io.csv" + +if [[ "${1:-}" == "--fresh" ]]; then + echo "[bench] removing existing CSV: $CSV" + rm -f "$CSV" +fi + +echo "===========================================================" +echo "[bench] PASS 1: batch_size=20 (R1, R2, U1)" +echo "===========================================================" +./gradlew --no-daemon bench \ + -Dbench.batch.size=20 \ + -Dbench.run.scenarios=R1,R2,U1 + +echo +echo "===========================================================" +echo "[bench] PASS 2: batch_size=1 (U1 only)" +echo "===========================================================" +./gradlew --no-daemon bench \ + -Dbench.batch.size=1 \ + -Dbench.run.scenarios=U1 + +echo +echo "[bench] done. Result CSV:" +cat "$CSV" diff --git a/src/main/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplication.java b/src/main/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplication.java index 45124c0..2ef093e 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplication.java +++ b/src/main/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplication.java @@ -2,10 +2,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.cache.annotation.EnableCaching; + +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableJpaAuditing +@EnableScheduling +@EnableCaching +@EnableAsync public class AccountBookForMomsApplication { public static void main(String[] args) { diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AccountController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AccountController.java new file mode 100644 index 0000000..9652a99 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AccountController.java @@ -0,0 +1,102 @@ +package com.chaewookim.accountbookformoms.domain.asset.api; + +import com.chaewookim.accountbookformoms.domain.asset.application.AccountService; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountGoalRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountUpdateRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.AccountResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "계좌(Account)", description = "자산 계좌 관리 API") +@RestController +@RequestMapping("/api/v1/account") +@RequiredArgsConstructor +public class AccountController { + + private final AccountService accountService; + + @Operation(summary = "계좌 생성", description = "사용자의 새로운 자산 계좌 생성") + @PostMapping + public ResponseEntity> createAccount( + @AuthenticationPrincipal UserPrincipal user, + @Valid @RequestBody AccountRequest dto + ) { + return ResponseEntity.ok(ApiResponse.success(accountService.createAccount(user.getUserId(), dto))); + } + + @Operation(summary = "계좌 목록 조회", description = "사용자가 보유한 모든 계좌 목록 조회") + @GetMapping + public ResponseEntity> getAccounts( + @AuthenticationPrincipal UserPrincipal user + ) { + return ResponseEntity.ok(accountService.getAccounts(user.getUserId())); + } + + @Operation(summary = "계좌 상세 조회", description = "특정 계좌의 상세 정보 조회") + @GetMapping("/{accountId}") + public ResponseEntity> getAccount( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long accountId + ) { + return ResponseEntity.ok(ApiResponse.success(accountService.getAccount(user.getUserId(), accountId))); + } + + @Operation(summary = "계좌 수정", description = "특정 계좌의 정보 수정") + @PatchMapping("/{accountId}") + public ResponseEntity> updateAccount( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long accountId, + @Valid @RequestBody AccountUpdateRequest dto + ) { + accountService.updateAccount(user.getUserId(), accountId, dto); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "계좌 목표 수정", description = "특정 계좌의 목표 금액·목표일 설정") + @PatchMapping("/{accountId}/goal") + public ResponseEntity> updateAccountGoal( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long accountId, + @Valid @RequestBody AccountGoalRequest dto + ) { + accountService.updateAccountGoal(user.getUserId(), accountId, dto); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "계좌 목표 삭제", description = "특정 계좌의 목표 금액·목표일 삭제 (역할은 유지)") + @DeleteMapping("/{accountId}/goal") + public ResponseEntity> clearAccountGoal( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long accountId + ) { + accountService.clearAccountGoal(user.getUserId(), accountId); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "계좌 삭제", description = "특정 계좌 삭제") + @DeleteMapping("/{accountId}") + public ResponseEntity> deleteAccount( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long accountId + ) { + accountService.deleteAccount(user.getUserId(), accountId); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AllocationController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AllocationController.java new file mode 100644 index 0000000..b5de785 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/AllocationController.java @@ -0,0 +1,35 @@ +package com.chaewookim.accountbookformoms.domain.asset.api; + +import com.chaewookim.accountbookformoms.domain.asset.application.MonthlyAllocationService; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationSummaryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "자산 집계(Allocation)", description = "저축률·투자율 등 월별 자산 집계 API") +@RestController +@RequestMapping("/api/v1/allocation") +@RequiredArgsConstructor +public class AllocationController { + + private final MonthlyAllocationService monthlyAllocationService; + + @Operation(summary = "월별 저축·투자 집계", description = "저축·투자 역할 계좌의 월간 이체 순유입 기준 저축률·투자율 조회") + @GetMapping("/{yearMonth}") + public ResponseEntity> getMonthlyAllocation( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + monthlyAllocationService.getMonthlyAllocationSummary(userPrincipal.getUserId(), yearMonth) + )); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/CategoryController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/CategoryController.java new file mode 100644 index 0000000..bdd3c12 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/CategoryController.java @@ -0,0 +1,71 @@ +package com.chaewookim.accountbookformoms.domain.asset.api; + +import com.chaewookim.accountbookformoms.domain.asset.application.CategoryService; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.CategoryRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.CategoryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "카테고리(Category)", description = "커스텀 카테고리 관리 API") +@RestController +@RequestMapping("/api/v1/categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @Operation(summary = "카테고리 목록 조회", description = "기본 카테고리와 커스텀 카테고리 함께 조회") + @GetMapping + public ResponseEntity>> getCategories( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(ApiResponse.success(categoryService.getCategories(userPrincipal.getUserId()))); + } + + @Operation(summary = "커스텀 카테고리 추가", description = "사용자 정의 카테고리 생성") + @PostMapping + public ResponseEntity> createCategory( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody CategoryRequest request + ) { + CategoryResponse response = categoryService.createCustomCategory(userPrincipal.getUser(), request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "커스텀 카테고리 수정", description = "카테고리 이름 및 타입 수정") + @PatchMapping("/{id}") + public ResponseEntity> updateCategory( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id, + @Valid @RequestBody CategoryRequest request + ) { + categoryService.updateCategory(id, userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "커스텀 카테고리 삭제", description = "사용자 정의 카테고리 삭제") + @DeleteMapping("/{id}") + public ResponseEntity> deleteCategory( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + categoryService.deleteCategory(id, userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/FixedTransactionController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/FixedTransactionController.java new file mode 100644 index 0000000..019571c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/FixedTransactionController.java @@ -0,0 +1,101 @@ +package com.chaewookim.accountbookformoms.domain.asset.api; + +import com.chaewookim.accountbookformoms.domain.asset.application.FixedTransactionService; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.FixedTransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.FixedTransactionResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RestController; + +import java.util.List; + +@Tag(name = "고정 수입/지출(FixedTransaction)", description = "고정 수입/지출 내역 관리 API") +@RestController +@RequestMapping("/api/v1/fixed-transactions") +@RequiredArgsConstructor +public class FixedTransactionController { + + private final FixedTransactionService fixedTransactionService; + + @Operation(summary = "고정 수입/지출 생성", description = "새로운 고정 수입/지출 내역 등록") + @PostMapping + public ResponseEntity> createFixedTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody FixedTransactionRequest request + ) { + return ResponseEntity.ok(ApiResponse.success(fixedTransactionService.createFixedTransaction(userPrincipal.getUserId(), userPrincipal.getUser(), request))); + } + + @Operation(summary = "고정 수입/지출 목록 조회", description = "로그인한 사용자의 모든 고정 수입/지출 내역 조회") + @GetMapping + public ResponseEntity>> getFixedTransactions( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(ApiResponse.success(fixedTransactionService.getFixedTransactions(userPrincipal.getUserId()))); + } + + @Operation(summary = "고정 수입/지출 수정", description = "기존에 등록된 고정 내역의 상세 정보 수정") + @PutMapping("/{id}") + public ResponseEntity> updateFixedTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id, + @Valid @RequestBody FixedTransactionRequest request + ) { + fixedTransactionService.updateFixedTransaction(userPrincipal.getUserId(), id, request); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "고정 수입/지출 활성/비활성 토글", description = "등록된 고정 내역의 활성화 상태 변경") + @PatchMapping("/{id}/toggle-active") + public ResponseEntity> toggleActiveStatus( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + fixedTransactionService.toggleActiveStatus(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "고정 수입/지출 삭제", description = "등록된 고정 수입/지출 내역 삭제") + @DeleteMapping("/{id}") + public ResponseEntity> deleteFixedTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + fixedTransactionService.deleteFixedTransaction(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "실행 실패 고정 거래 재실행", description = "잔액/한도 부족으로 실패한 회차를 즉시 재실행") + @PostMapping("/{id}/retry") + public ResponseEntity> retryFailedExecution( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + fixedTransactionService.retryFailedExecution(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "실행 실패 고정 거래 건너뛰기", description = "잔액/한도 부족으로 실패한 회차를 건너뛰고 다음 회차로 진행") + @PostMapping("/{id}/skip") + public ResponseEntity> skipFailedExecution( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + fixedTransactionService.skipFailedExecution(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/TransactionController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/TransactionController.java new file mode 100644 index 0000000..51cd64f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/api/TransactionController.java @@ -0,0 +1,116 @@ +package com.chaewookim.accountbookformoms.domain.asset.api; + +import com.chaewookim.accountbookformoms.domain.asset.application.TransactionExportService; +import com.chaewookim.accountbookformoms.domain.asset.application.TransactionService; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.TransactionResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Tag(name = "거래 내역(Transaction)", description = "수입/지출/이체 내역 관리 API") +@RestController +@RequestMapping("/api/v1/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final TransactionService transactionService; + private final TransactionExportService transactionExportService; + + @Operation(summary = "거래 내역 목록 조회", description = "특정 계좌의 기간별 거래 내역 목록 조회") + @GetMapping + public ResponseEntity>> getTransactions( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam Long accountId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @PageableDefault(size = 20, sort = "transactionDate", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page responses = transactionService.getTransactions(userPrincipal.getUserId(), accountId, startDate, endDate, pageable); + return ResponseEntity.ok(ApiResponse.success(responses)); + } + + @Operation(summary = "전체 거래 내역 조회", description = "삭제된 계좌 거래를 포함한 사용자 전체 거래 내역 조회") + @GetMapping("/all") + public ResponseEntity>> getAllUserTransactions( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @PageableDefault(size = 100, sort = "transactionDate", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page responses = transactionService.getAllUserTransactions( + userPrincipal.getUserId(), startDate, endDate, pageable); + return ResponseEntity.ok(ApiResponse.success(responses)); + } + + @Operation(summary = "거래 내역 상세 조회", description = "특정 거래 내역 상세 정보 조회") + @GetMapping("/{id}") + public ResponseEntity> getTransaction( + @PathVariable Long id + ) { + return ResponseEntity.ok(ApiResponse.success(transactionService.getTransaction(id))); + } + + @Operation(summary = "거래 내역 등록", description = "수입/지출/이체 내역 생성") + @PostMapping + public ResponseEntity> createTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody TransactionRequest request + ) { + Long transactionId = transactionService.createTransaction(userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(transactionId)); + } + + @Operation(summary = "거래 내역 수정", description = "기존 거래 내역 및 잔액 정보 수정") + @PatchMapping("/{id}") + public ResponseEntity> updateTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id, + @Valid @RequestBody TransactionRequest request + ) { + transactionService.updateTransaction(userPrincipal.getUserId(), id, request); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "거래 내역 삭제", description = "거래 내역 삭제 및 잔액 원복") + @DeleteMapping("/{id}") + public ResponseEntity> deleteTransaction( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id + ) { + transactionService.deleteTransaction(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "거래 내역 내보내기", description = "특정 기간(최대 12개월) 거래 내역 엑셀 다운로드") + @GetMapping("/export") + public ResponseEntity exportTransactions( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + Resource resource = transactionExportService.exportToCsv(userPrincipal.getUserId(), startDate, endDate); + String fileName = TransactionExportService.buildFileName(startDate, endDate); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(resource); + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountService.java new file mode 100644 index 0000000..d352d43 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountService.java @@ -0,0 +1,251 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountGoalRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountUpdateRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.AccountResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; + +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.asset.event.GoalAchievedCheckEvent; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AccountService { + + private static final String BALANCE_ADJUSTMENT_CATEGORY = "잔고 조정"; + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final AccountRepository accountRepository; + private final UserRepository userRepository; + private final FixedTransactionRepository fixedTransactionRepository; + private final TransactionRepository transactionRepository; + private final TransactionCategoryRepository categoryRepository; + private final ApplicationEventPublisher eventPublisher; + private final CacheManager cacheManager; + + @Transactional + public Long createAccount(Long userId, AccountRequest request) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + String accountName = request.accountName().trim(); + AccountKind kind = resolveKind(request); + LoanTerms loanTerms = resolveLoanTerms(request, kind); + + Optional deletedAccount = accountRepository.findByUserIdAndAccountNameIncludingDeleted(userId, accountName); + if (deletedAccount.isPresent() && deletedAccount.get().getDeletedAt() != null) { + Account account = deletedAccount.get(); + account.restore(); + account.reinitialize(kind, request.creditLimit(), loanTerms.loanLimit(), + loanTerms.disbursedAmount(), request.initialBalance(), resolveRole(request)); + return accountRepository.save(account).getId(); + } + + if (accountRepository.existsByUserIdAndAccountName(userId, accountName)) { + throw new CustomException(AssetErrorCode.DUPLICATE_ACCOUNT_NAME); + } + + Account account = Account.builder() + .user(user) + .accountName(accountName) + .initialBalance(request.initialBalance()) + .role(resolveRole(request)) + .kind(kind) + .creditLimit(request.creditLimit()) + .loanLimit(loanTerms.loanLimit()) + .disbursedAmount(loanTerms.disbursedAmount()) + .build(); + + return accountRepository.save(account).getId(); + } + + public List getAccounts(Long userId) { + return accountRepository.findByUserId(userId).stream() + .map(AccountResponse::new) + .collect(Collectors.toList()); + } + + public AccountResponse getAccount(Long userId, Long accountId) { + return new AccountResponse(validateAndGet(userId, accountId)); + } + + @Transactional + public void updateAccount(Long userId, Long accountId, AccountUpdateRequest request) { + Account account = validateAndGetWithLock(userId, accountId); + String accountName = request.accountName().trim(); + + if (!accountName.equals(account.getAccountName()) + && accountRepository.existsByUserIdAndAccountNameAndIdNot(userId, accountName, accountId)) { + throw new CustomException(AssetErrorCode.DUPLICATE_ACCOUNT_NAME); + } + + account.updateKindAndLimits( + resolveKind(request, account), request.creditLimit(), request.loanLimit(), + request.initialBalance(), request.currentBalance()); + account.updateAccountName(accountName); + if (request.role() != null) { + account.updateRole(request.role()); + } + + BigDecimal balanceDelta = request.currentBalance().subtract(account.getCurrentBalance()); + account.updateInitialBalanceOnly(request.initialBalance()); + if (balanceDelta.compareTo(BigDecimal.ZERO) == 0) { + return; + } + + TransactionType adjustmentType = balanceDelta.signum() > 0 + ? TransactionType.INCOME + : TransactionType.EXPENSE; + TransactionCategory category = categoryRepository + .findByUserIsNullAndNameAndType(BALANCE_ADJUSTMENT_CATEGORY, adjustmentType) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + account.changeBalance(balanceDelta); + resetGoalAchievementStateIfNeeded(account); + + transactionRepository.save(Transaction.builder() + .user(account.getUser()) + .account(account) + .transactionCategory(category) + .type(adjustmentType) + .amount(balanceDelta.abs()) + .transactionDate(LocalDate.now()) + .description(BALANCE_ADJUSTMENT_CATEGORY) + .build()); + + evictDashboardCache(userId, LocalDate.now()); + eventPublisher.publishEvent(new GoalAchievedCheckEvent(userId, accountId)); + } + + @Transactional + public void updateAccountGoal(Long userId, Long accountId, AccountGoalRequest request) { + Account account = validateAndGet(userId, accountId); + account.updateGoal(request.goalAmount(), request.goalDate()); + eventPublisher.publishEvent(new GoalAchievedCheckEvent(userId, accountId)); + } + + @Transactional + public void clearAccountGoal(Long userId, Long accountId) { + Account account = validateAndGet(userId, accountId); + account.clearGoal(); + } + + private AccountRole resolveRole(AccountRequest request) { + return request.role() != null ? request.role() : AccountRole.CHECKING; + } + + private AccountKind resolveKind(AccountRequest request) { + return request.kind() != null ? request.kind() : AccountKind.ASSET; + } + + private AccountKind resolveKind(AccountUpdateRequest request, Account account) { + return request.kind() != null ? request.kind() : account.getKind(); + } + + private LoanTerms resolveLoanTerms(AccountRequest request, AccountKind kind) { + if (kind != AccountKind.LOAN) { + if (request.loanLimit() != null || request.loanAlreadyDisbursed() != null) { + throw new CustomException(AssetErrorCode.LOAN_FIELDS_NOT_ALLOWED); + } + return new LoanTerms(null, null); + } + + BigDecimal loanLimit = request.loanLimit() != null + ? request.loanLimit() + : request.initialBalance().abs(); + boolean alreadyDisbursed = request.loanAlreadyDisbursed() == null + || request.loanAlreadyDisbursed(); + if (!alreadyDisbursed && request.initialBalance().compareTo(BigDecimal.ZERO) != 0) { + throw new CustomException(AssetErrorCode.INVALID_UNDISBURSED_LOAN_BALANCE); + } + return new LoanTerms(loanLimit, alreadyDisbursed ? loanLimit : BigDecimal.ZERO); + } + + private record LoanTerms(BigDecimal loanLimit, BigDecimal disbursedAmount) { + } + + @Transactional + public void deleteAccount(Long userId, Long accountId) { + + Account account = validateAndGet(userId, accountId); + + fixedTransactionRepository.softDeleteByAccountId(accountId); + + List targetFixedTransactions = fixedTransactionRepository.findAllByTargetAccountId(accountId); + targetFixedTransactions.forEach(fixedTransactionRepository::delete); + + transactionRepository.backfillSourceAccountSnapshot(accountId, account.getAccountName(), account.getRole()); + transactionRepository.backfillTargetAccountSnapshot(accountId, account.getAccountName(), account.getRole()); + transactionRepository.markSourceAccountArchived(accountId); + transactionRepository.markTargetAccountArchived(accountId); + + accountRepository.delete(account); + } + + // 공통 검증 로직 + private Account validateAndGet(Long userId, Long accountId) { + + Account account = accountRepository.findById(accountId) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + if (!account.getUser().getId().equals(userId)) { + throw new CustomException(UserErrorCode.ACCESS_DENIED); + } + + return account; + } + + private Account validateAndGetWithLock(Long userId, Long accountId) { + Account account = accountRepository.findByIdWithLock(accountId) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + if (!account.getUser().getId().equals(userId)) { + throw new CustomException(UserErrorCode.ACCESS_DENIED); + } + + return account; + } + + private void resetGoalAchievementStateIfNeeded(Account account) { + if (account.getGoalAmount() != null && !account.isGoalAchieved() && account.isGoalAchievedNotified()) { + account.resetGoalAchievedNotified(); + } + } + + private void evictDashboardCache(Long userId, LocalDate date) { + Cache dashboardCache = cacheManager.getCache("dashboard"); + if (dashboardCache != null) { + dashboardCache.evict(userId + ":" + date.format(YEAR_MONTH_FORMATTER)); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryService.java new file mode 100644 index 0000000..c945997 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryService.java @@ -0,0 +1,120 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.CategoryRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.CategoryResponse; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryService { + + private final TransactionCategoryRepository categoryRepository; + private final TransactionRepository transactionRepository; + private final FixedTransactionRepository fixedTransactionRepository; + private final BudgetRepository budgetRepository; + + public List getCategories(Long userId) { + return categoryRepository.findAllByUserOrSystem(userId).stream() + .map(CategoryResponse::from) + .toList(); + } + + @Transactional + public CategoryResponse createCustomCategory(User user, CategoryRequest request) { + + String name = request.name().trim(); + Long userId = user.getId(); + + Optional deletedCategory = categoryRepository + .findByUserIdAndNameAndTypeIncludingDeleted(userId, name, request.type().name()); + + if (deletedCategory.isPresent() && deletedCategory.get().getDeletedAt() != null) { + TransactionCategory category = deletedCategory.get(); + category.restore(); + category.update(name, request.type()); + return CategoryResponse.from(categoryRepository.save(category)); + } + + validateCategoryNameUnique(userId, name, request.type(), null); + + TransactionCategory savedCategory = categoryRepository.save( + TransactionCategory.builder() + .user(user) + .name(name) + .type(request.type()) + .build() + ); + return CategoryResponse.from(savedCategory); + } + + @Transactional + public void updateCategory(Long categoryId, Long userId, CategoryRequest request) { + + TransactionCategory category = validateAndGetCategory(categoryId, userId); + String name = request.name().trim(); + + validateCategoryNameUnique(userId, name, request.type(), categoryId); + category.update(name, request.type()); + } + + @Transactional + public void deleteCategory(Long categoryId, Long userId) { + + TransactionCategory category = validateAndGetCategory(categoryId, userId); + String categoryName = category.getName(); + fixedTransactionRepository.softDeleteByTransactionCategoryId(categoryId); + + transactionRepository.backfillCategorySnapshot(categoryId, categoryName); + transactionRepository.markCategoryArchived(categoryId); + budgetRepository.backfillCategorySnapshot(categoryId, categoryName); + budgetRepository.markCategoryArchived(categoryId); + categoryRepository.delete(category); + } + + private void validateCategoryNameUnique(Long userId, String name, TransactionType type, Long excludeId) { + if (categoryRepository.existsByUserIsNullAndNameAndType(name, type)) { + throw new CustomException(AssetErrorCode.DUPLICATE_CATEGORY_NAME); + } + + boolean duplicate = excludeId == null + ? categoryRepository.existsByUserIdAndNameAndType(userId, name, type) + : categoryRepository.existsByUserIdAndNameAndTypeAndIdNot(userId, name, type, excludeId); + + if (duplicate) { + throw new CustomException(AssetErrorCode.DUPLICATE_CATEGORY_NAME); + } + } + + // 공통 검증 로직 + private TransactionCategory validateAndGetCategory(Long categoryId, Long userId) { + + TransactionCategory category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + if (category.getUser() == null) { + throw new CustomException(AssetErrorCode.CATEGORY_IMMUTABLE); + } + + if (!category.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.CATEGORY_FORBIDDEN); + } + + return category; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutor.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutor.java new file mode 100644 index 0000000..c9526e2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutor.java @@ -0,0 +1,116 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.asset.event.FixedTransactionExecutionFailedEvent; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FixedTransactionExecutor { + + private final TransactionService transactionService; + private final ApplicationEventPublisher eventPublisher; + + public boolean executeIfDue(FixedTransaction fixedTransaction, LocalDate today) { + if (fixedTransaction.hasExecutionFailure()) { + return false; + } + + LocalDate executionDate = resolveExecutionDate(fixedTransaction, today); + if (executionDate == null) { + return false; + } + + return executeOccurrence(fixedTransaction, executionDate, true); + } + + public void retryFailedOccurrence(FixedTransaction fixedTransaction) { + if (!fixedTransaction.hasExecutionFailure()) { + throw new CustomException(AssetErrorCode.FIXED_TRANSACTION_NOT_FAILED); + } + executeOccurrence(fixedTransaction, fixedTransaction.getFailedExecutionDate(), false); + } + + private boolean executeOccurrence(FixedTransaction fixedTransaction, LocalDate executionDate, boolean notifyOnBalanceFailure) { + TransactionRequest request = new TransactionRequest( + fixedTransaction.getAccount().getId(), + fixedTransaction.getTargetAccount() != null ? fixedTransaction.getTargetAccount().getId() : null, + fixedTransaction.getTransactionCategory().getId(), + fixedTransaction.getType(), + fixedTransaction.getAmount(), + executionDate, + fixedTransaction.getDescription() + ); + + try { + transactionService.createTransactionFromFixed(fixedTransaction.getUser().getId(), request); + fixedTransaction.updateExecutionStatus(executionDate); + log.info("고정 거래 생성 완료: ID={}", fixedTransaction.getId()); + return true; + } catch (CustomException e) { + FixedTransactionExecutionFailure failure = resolveBalanceFailure(fixedTransaction.getType(), e); + if (failure == null) { + throw e; + } + + boolean alreadyFailed = fixedTransaction.hasExecutionFailure(); + fixedTransaction.markExecutionFailed(executionDate, failure); + log.warn("고정 거래 잔액/한도 부족: ID={}, reason={}", fixedTransaction.getId(), failure); + + if (notifyOnBalanceFailure && !alreadyFailed) { + eventPublisher.publishEvent(new FixedTransactionExecutionFailedEvent( + fixedTransaction.getUser().getId(), + fixedTransaction.getId(), + fixedTransaction.getAccount().getAccountName(), + fixedTransaction.getDescription(), + failure + )); + } + + if (!notifyOnBalanceFailure) { + throw e; + } + return false; + } + } + + private FixedTransactionExecutionFailure resolveBalanceFailure(TransactionType type, CustomException e) { + if (type != TransactionType.EXPENSE && type != TransactionType.TRANSFER) { + return null; + } + if (e.getErrorCode() == AssetErrorCode.INSUFFICIENT_BALANCE) { + return FixedTransactionExecutionFailure.INSUFFICIENT_BALANCE; + } + if (e.getErrorCode() == AssetErrorCode.CREDIT_LIMIT_EXCEEDED) { + return FixedTransactionExecutionFailure.CREDIT_LIMIT_EXCEEDED; + } + return null; + } + + private LocalDate resolveExecutionDate(FixedTransaction fixedTransaction, LocalDate today) { + if (fixedTransaction.isExecutionDay(today) && !today.equals(fixedTransaction.getLastExecutedDate())) { + return today; + } + + LocalDate nextExecutionDate = fixedTransaction.getNextExecutionDate(); + if (nextExecutionDate != null + && !nextExecutionDate.isAfter(today) + && !nextExecutionDate.equals(fixedTransaction.getLastExecutedDate()) + && fixedTransaction.isExecutionDay(nextExecutionDate)) { + return nextExecutionDate; + } + + return null; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionImmediateExecutionService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionImmediateExecutionService.java new file mode 100644 index 0000000..8c02455 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionImmediateExecutionService.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FixedTransactionImmediateExecutionService { + + private final FixedTransactionRepository fixedTransactionRepository; + private final FixedTransactionExecutor fixedTransactionExecutor; + + @Transactional + public void executeIfDue(Long fixedTransactionId) { + fixedTransactionRepository.findById(fixedTransactionId).ifPresent(fixedTransaction -> { + try { + fixedTransactionExecutor.executeIfDue(fixedTransaction, LocalDate.now()); + } catch (Exception e) { + log.warn("고정 거래 즉시 실행 실패: ID={}, error={}", fixedTransactionId, e.getMessage()); + } + }); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionService.java new file mode 100644 index 0000000..c0d086f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionService.java @@ -0,0 +1,168 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.FixedTransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.FixedTransactionResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FixedTransactionService { + + private final FixedTransactionRepository fixedTransactionRepository; + private final AccountRepository accountRepository; + private final TransactionCategoryRepository categoryRepository; + private final FixedTransactionImmediateExecutionService fixedTransactionImmediateExecutionService; + private final FixedTransactionExecutor fixedTransactionExecutor; + + @Transactional + public Long createFixedTransaction(Long userId, User user, FixedTransactionRequest request) { + + Account account = accountRepository.findByIdAndUserId(request.accountId(), userId) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + Account targetAccount = resolveTargetAccount(userId, request); + TransactionCategory category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + FixedTransaction fixedTransaction = FixedTransaction.builder() + .user(user) + .account(account) + .targetAccount(targetAccount) + .transactionCategory(category) + .type(request.type()) + .amount(request.amount()) + .frequency(request.frequency()) + .repeatDay(request.repeatDay()) + .repeatMonth(request.repeatMonth()) + .startDate(request.startDate()) + .endDate(request.endDate()) + .description(request.description()) + .build(); + + FixedTransaction saved = fixedTransactionRepository.save(fixedTransaction); + scheduleImmediateExecution(saved.getId()); + return saved.getId(); + } + + private Account resolveTargetAccount(Long userId, FixedTransactionRequest request) { + if (request.type() != TransactionType.TRANSFER) { + return null; + } + if (request.targetAccountId() == null) { + throw new CustomException(AssetErrorCode.TARGET_ACCOUNT_REQUIRED); + } + if (request.accountId().equals(request.targetAccountId())) { + throw new CustomException(AssetErrorCode.TRANSFER_TO_SELF_FORBIDDEN); + } + return accountRepository.findByIdAndUserId(request.targetAccountId(), userId) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + } + + private void scheduleImmediateExecution(Long fixedTransactionId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + fixedTransactionImmediateExecutionService.executeIfDue(fixedTransactionId); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + fixedTransactionImmediateExecutionService.executeIfDue(fixedTransactionId); + } + }); + } + + @Transactional + public List getFixedTransactions(Long userId) { + List responses = new java.util.ArrayList<>(); + + for (FixedTransaction fixedTransaction : fixedTransactionRepository.findAllByUserId(userId)) { + if (!hasActiveAccounts(fixedTransaction)) { + fixedTransactionRepository.delete(fixedTransaction); + continue; + } + responses.add(FixedTransactionResponse.from(fixedTransaction)); + } + + return responses; + } + + private boolean hasActiveAccounts(FixedTransaction fixedTransaction) { + Account account = fixedTransaction.getAccount(); + if (account == null || accountRepository.findById(account.getId()).isEmpty()) { + return false; + } + if (fixedTransaction.getType() == TransactionType.TRANSFER) { + Account target = fixedTransaction.getTargetAccount(); + return target != null && accountRepository.findById(target.getId()).isPresent(); + } + return true; + } + + @Transactional + public void updateFixedTransaction(Long userId, Long id, FixedTransactionRequest request) { + + FixedTransaction fixedTransaction = validateAndGet(userId, id); + + Account account = accountRepository.findByIdAndUserId(request.accountId(), userId) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + Account targetAccount = resolveTargetAccount(userId, request); + TransactionCategory category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + fixedTransaction.update(account, targetAccount, category, request); + } + + @Transactional + public void toggleActiveStatus(Long userId, Long id) { + FixedTransaction fixedTransaction = validateAndGet(userId, id); + fixedTransaction.toggleActive(); + } + + @Transactional + public void deleteFixedTransaction(Long userId, Long id) { + FixedTransaction fixedTransaction = validateAndGet(userId, id); + fixedTransactionRepository.delete(fixedTransaction); + } + + @Transactional + public void retryFailedExecution(Long userId, Long id) { + FixedTransaction fixedTransaction = validateAndGet(userId, id); + fixedTransactionExecutor.retryFailedOccurrence(fixedTransaction); + } + + @Transactional + public void skipFailedExecution(Long userId, Long id) { + FixedTransaction fixedTransaction = validateAndGet(userId, id); + fixedTransaction.skipFailedOccurrence(); + } + + // 공통 검증 로직 + private FixedTransaction validateAndGet(Long userId, Long id) { + + FixedTransaction fixedTransaction = fixedTransactionRepository.findById(id) + .orElseThrow(() -> new CustomException(AssetErrorCode.FIXED_TRANSACTION_NOT_FOUND)); + + if (!fixedTransaction.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.FIXED_TRANSACTION_FORBIDDEN); + } + + return fixedTransaction; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationService.java new file mode 100644 index 0000000..0adad95 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationService.java @@ -0,0 +1,157 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.AllocationBucketResponse; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.GoalProgressResponse; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationResponse; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationSummaryResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.portfolio.application.PortfolioService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlyAllocationService { + + private final TransactionRepository transactionRepository; + private final AccountRepository accountRepository; + private final PortfolioService portfolioService; + + public MonthlyAllocationSummaryResponse getMonthlyAllocationSummary(Long userId, String yearMonth) { + + BigDecimal totalIncome = portfolioService.getMyPortfolio(userId, yearMonth).totalIncome(); + MonthlyAllocationResponse allocation = computeMonthlyAllocation(userId, yearMonth, totalIncome); + + return MonthlyAllocationSummaryResponse.from(yearMonth, totalIncome, allocation); + } + + public MonthlyAllocationResponse computeMonthlyAllocation(Long userId, String yearMonth, BigDecimal totalIncome) { + + YearMonth month = YearMonth.parse(yearMonth); + LocalDate startDate = month.atDay(1); + LocalDate endDate = month.atEndOfMonth(); + + BigDecimal savingsInflow = BigDecimal.ZERO; + BigDecimal savingsOutflow = BigDecimal.ZERO; + BigDecimal investmentInflow = BigDecimal.ZERO; + BigDecimal investmentOutflow = BigDecimal.ZERO; + + List transfers = transactionRepository.findByUserIdAndDateBetween(userId, startDate, endDate) + .stream() + .filter(tx -> tx.getType() == TransactionType.TRANSFER) + .toList(); + + for (Transaction tx : transfers) { + BigDecimal amount = tx.getAmount().abs(); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + + AccountRole sourceRole = resolveRole(tx.getSnapshotAccountRole(), tx.getAccount()); + AccountRole targetRole = resolveRole(tx.getSnapshotTargetAccountRole(), tx.getTargetAccount()); + + if (targetRole == AccountRole.SAVINGS) { + savingsInflow = savingsInflow.add(amount); + } else if (targetRole == AccountRole.INVESTMENT) { + investmentInflow = investmentInflow.add(amount); + } + + if (sourceRole == AccountRole.SAVINGS) { + savingsOutflow = savingsOutflow.add(amount); + } + if (sourceRole == AccountRole.INVESTMENT) { + investmentOutflow = investmentOutflow.add(amount); + } + } + + return new MonthlyAllocationResponse( + buildBucket(savingsInflow, savingsOutflow, totalIncome), + buildBucket(investmentInflow, investmentOutflow, totalIncome) + ); + } + + public List buildGoalProgress(Long userId) { + return accountRepository.findByUserId(userId).stream() + .filter(account -> account.getKind() == AccountKind.ASSET + || account.getKind() == AccountKind.LOAN) + .filter(account -> account.getGoalAmount() != null) + .map(this::toGoalProgress) + .sorted(Comparator.comparing(GoalProgressResponse::progressPercent).reversed()) + .toList(); + } + + public BigDecimal computeTotalAsset(Long userId) { + return accountRepository.findByUserId(userId).stream() + .map(Account::getCurrentBalance) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private GoalProgressResponse toGoalProgress(Account account) { + + BigDecimal goalAmount = account.getGoalAmount(); + BigDecimal balance = account.getCurrentBalance() != null ? account.getCurrentBalance() : BigDecimal.ZERO; + Integer progressPercent = account.calculateGoalProgressPercent(); + LocalDate goalDate = account.getGoalDate(); + + return new GoalProgressResponse( + account.getId(), + account.getAccountName(), + account.getKind(), + account.getRole(), + balance, + goalAmount, + progressPercent, + goalDate, + calculateDDay(goalDate) + ); + } + + private Integer calculateDDay(LocalDate goalDate) { + + if (goalDate == null) { + return null; + } + + return (int) ChronoUnit.DAYS.between(LocalDate.now(), goalDate); + } + + private AllocationBucketResponse buildBucket(BigDecimal inflow, BigDecimal outflow, BigDecimal totalIncome) { + + BigDecimal net = inflow.subtract(outflow); + BigDecimal rate = BigDecimal.ZERO; + + if (totalIncome.compareTo(BigDecimal.ZERO) > 0) { + rate = net.multiply(BigDecimal.valueOf(100)) + .divide(totalIncome, 1, RoundingMode.HALF_UP); + } + + return new AllocationBucketResponse(net, rate, inflow, outflow); + } + + private AccountRole resolveRole(AccountRole snapshotRole, Account account) { + if (snapshotRole != null) { + return snapshotRole; + } + if (account != null && account.getRole() != null) { + return account.getRole(); + } + return AccountRole.CHECKING; + } + +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportService.java new file mode 100644 index 0000000..014ac6a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportService.java @@ -0,0 +1,363 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.DataFormat; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TransactionExportService { + + static final int MAX_EXPORT_MONTHS = 12; + + private static final String SHEET_DETAILS = "거래 내역"; + private static final String SHEET_SUMMARY = "기간 요약"; + private static final String[] DETAIL_HEADERS = {"날짜", "유형", "계좌", "카테고리", "금액", "상대 계좌", "메모"}; + + private final TransactionRepository transactionRepository; + + public static String buildFileName(LocalDate startDate, LocalDate endDate) { + return String.format("MODI_거래내역_%s_%s.xlsx", startDate, endDate); + } + + public Resource exportToCsv(Long userId, LocalDate startDate, LocalDate endDate) { + validateExportPeriod(startDate, endDate); + + List transactions = transactionRepository.findByUserIdAndDateBetween(userId, startDate, endDate); + + try (Workbook workbook = new XSSFWorkbook()) { + ExportStyles styles = ExportStyles.create(workbook); + writeDetailSheet(workbook, styles, transactions, startDate, endDate); + writeSummarySheet(workbook, styles, transactions, startDate, endDate); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + workbook.write(bos); + return new ByteArrayResource(bos.toByteArray()); + } catch (Exception e) { + throw new RuntimeException("엑셀 생성 실패", e); + } + } + + private void writeDetailSheet( + Workbook workbook, + ExportStyles styles, + List transactions, + LocalDate startDate, + LocalDate endDate + ) { + Sheet sheet = workbook.createSheet(SHEET_DETAILS); + sheet.createFreezePane(0, 4); + + Row titleRow = sheet.createRow(0); + Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue("MODI 거래 내역"); + titleCell.setCellStyle(styles.titleStyle()); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, DETAIL_HEADERS.length - 1)); + + Row periodRow = sheet.createRow(1); + Cell periodCell = periodRow.createCell(0); + periodCell.setCellValue("기간: " + startDate + " ~ " + endDate); + periodCell.setCellStyle(styles.subtitleStyle()); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, DETAIL_HEADERS.length - 1)); + + Row headerRow = sheet.createRow(3); + for (int i = 0; i < DETAIL_HEADERS.length; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(DETAIL_HEADERS[i]); + cell.setCellStyle(styles.headerStyle()); + } + + int rowIdx = 4; + for (Transaction transaction : transactions) { + Row row = sheet.createRow(rowIdx++); + setDateCell(row, 0, transaction.getTransactionDate(), styles.textStyle()); + setTextCell(row, 1, formatType(transaction.getType()), styles.textStyle()); + setTextCell(row, 2, resolveAccountName(transaction), styles.textStyle()); + setTextCell(row, 3, resolveCategoryName(transaction), styles.textStyle()); + setAmountCell(row, 4, transaction.getAmount(), styles.amountStyle()); + setTextCell(row, 5, resolveTargetAccountName(transaction), styles.textStyle()); + setTextCell(row, 6, nullToEmpty(transaction.getDescription()), styles.textStyle()); + } + + autosizeColumns(sheet, DETAIL_HEADERS.length); + sheet.setColumnWidth(6, Math.max(sheet.getColumnWidth(6), 8000)); + } + + private void writeSummarySheet( + Workbook workbook, + ExportStyles styles, + List transactions, + LocalDate startDate, + LocalDate endDate + ) { + Sheet sheet = workbook.createSheet(SHEET_SUMMARY); + + Row titleRow = sheet.createRow(0); + Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue("MODI 기간 요약"); + titleCell.setCellStyle(styles.titleStyle()); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 1)); + + Row periodRow = sheet.createRow(1); + Cell periodCell = periodRow.createCell(0); + periodCell.setCellValue("기간: " + startDate + " ~ " + endDate); + periodCell.setCellStyle(styles.subtitleStyle()); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 1)); + + BigDecimal totalIncome = sumByType(transactions, TransactionType.INCOME); + BigDecimal totalExpense = sumByType(transactions, TransactionType.EXPENSE); + BigDecimal netIncome = totalIncome.subtract(totalExpense); + long transferCount = transactions.stream().filter(t -> t.getType() == TransactionType.TRANSFER).count(); + + Row summaryHeaderRow = sheet.createRow(3); + setTextCell(summaryHeaderRow, 0, "항목", styles.headerStyle()); + setTextCell(summaryHeaderRow, 1, "금액 / 건수", styles.headerStyle()); + + int rowIdx = 4; + rowIdx = writeSummaryRow(sheet, rowIdx, "총 수입", totalIncome, styles); + rowIdx = writeSummaryRow(sheet, rowIdx, "총 지출", totalExpense, styles); + rowIdx = writeSummaryRow(sheet, rowIdx, "순수입 (수입 - 지출)", netIncome, styles); + + Row transferRow = sheet.createRow(rowIdx++); + setTextCell(transferRow, 0, "이체 건수", styles.textStyle()); + Cell transferCountCell = transferRow.createCell(1); + transferCountCell.setCellValue(transferCount); + transferCountCell.setCellStyle(styles.textStyle()); + + rowIdx++; + + rowIdx = writeCategoryBreakdownSection( + sheet, rowIdx, styles, transactions, TransactionType.INCOME, "카테고리별 수입", "수입 합계", "수입 내역 없음" + ); + + rowIdx++; + + rowIdx = writeCategoryBreakdownSection( + sheet, rowIdx, styles, transactions, TransactionType.EXPENSE, "카테고리별 지출", "지출 합계", "지출 내역 없음" + ); + + autosizeColumns(sheet, 2); + } + + private int writeCategoryBreakdownSection( + Sheet sheet, + int rowIdx, + ExportStyles styles, + List transactions, + TransactionType type, + String sectionTitle, + String amountHeader, + String emptyMessage + ) { + Row sectionRow = sheet.createRow(rowIdx++); + setTextCell(sectionRow, 0, sectionTitle, styles.headerStyle()); + setTextCell(sectionRow, 1, amountHeader, styles.headerStyle()); + + Map amountByCategory = summarizeByCategory(transactions, type); + + if (amountByCategory.isEmpty()) { + Row emptyRow = sheet.createRow(rowIdx++); + setTextCell(emptyRow, 0, emptyMessage, styles.textStyle()); + return rowIdx; + } + + for (Map.Entry entry : amountByCategory.entrySet()) { + Row row = sheet.createRow(rowIdx++); + setTextCell(row, 0, entry.getKey(), styles.textStyle()); + setAmountCell(row, 1, entry.getValue(), styles.amountStyle()); + } + + return rowIdx; + } + + private Map summarizeByCategory(List transactions, TransactionType type) { + return transactions.stream() + .filter(t -> t.getType() == type) + .collect(Collectors.groupingBy( + this::resolveCategoryName, + Collectors.reducing(BigDecimal.ZERO, Transaction::getAmount, BigDecimal::add) + )) + .entrySet() + .stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + LinkedHashMap::new + )); + } + + private int writeSummaryRow(Sheet sheet, int rowIdx, String label, BigDecimal amount, ExportStyles styles) { + Row row = sheet.createRow(rowIdx); + setTextCell(row, 0, label, styles.textStyle()); + setAmountCell(row, 1, amount, styles.amountStyle()); + return rowIdx + 1; + } + + private BigDecimal sumByType(List transactions, TransactionType type) { + return transactions.stream() + .filter(t -> t.getType() == type) + .map(Transaction::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private String formatType(TransactionType type) { + return switch (type) { + case INCOME -> "수입"; + case EXPENSE -> "지출"; + case TRANSFER -> "이체"; + }; + } + + private String resolveAccountName(Transaction transaction) { + if (transaction.getSnapshotAccountName() != null && !transaction.getSnapshotAccountName().isBlank()) { + return transaction.getSnapshotAccountName(); + } + if (transaction.getAccount() != null) { + return transaction.getAccount().getAccountName(); + } + return "-"; + } + + private String resolveTargetAccountName(Transaction transaction) { + if (transaction.getType() != TransactionType.TRANSFER) { + return "-"; + } + if (transaction.getSnapshotTargetAccountName() != null && !transaction.getSnapshotTargetAccountName().isBlank()) { + return transaction.getSnapshotTargetAccountName(); + } + if (transaction.getTargetAccount() != null) { + return transaction.getTargetAccount().getAccountName(); + } + return "-"; + } + + private String resolveCategoryName(Transaction transaction) { + if (transaction.getTransactionCategory() != null) { + return transaction.getTransactionCategory().getName(); + } + return "기타"; + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private void setTextCell(Row row, int column, String value, CellStyle style) { + Cell cell = row.createCell(column); + cell.setCellValue(value); + cell.setCellStyle(style); + } + + private void setDateCell(Row row, int column, LocalDate date, CellStyle style) { + Cell cell = row.createCell(column); + cell.setCellValue(date.toString()); + cell.setCellStyle(style); + } + + private void setAmountCell(Row row, int column, BigDecimal amount, CellStyle style) { + Cell cell = row.createCell(column); + cell.setCellValue(amount.doubleValue()); + cell.setCellStyle(style); + } + + private void autosizeColumns(Sheet sheet, int columnCount) { + for (int i = 0; i < columnCount; i++) { + sheet.autoSizeColumn(i); + sheet.setColumnWidth(i, Math.min(sheet.getColumnWidth(i) + 512, 12000)); + } + } + + private void validateExportPeriod(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new CustomException(AssetErrorCode.INVALID_EXPORT_DATE_RANGE); + } + LocalDate maxEndDate = startDate.plusMonths(MAX_EXPORT_MONTHS).minusDays(1); + if (endDate.isAfter(maxEndDate)) { + throw new CustomException(AssetErrorCode.EXPORT_PERIOD_TOO_LONG); + } + } + + private record ExportStyles( + CellStyle titleStyle, + CellStyle subtitleStyle, + CellStyle headerStyle, + CellStyle amountStyle, + CellStyle textStyle + ) { + static ExportStyles create(Workbook workbook) { + DataFormat dataFormat = workbook.createDataFormat(); + + Font titleFont = workbook.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 14); + + Font headerFont = workbook.createFont(); + headerFont.setBold(true); + + CellStyle titleStyle = workbook.createCellStyle(); + titleStyle.setFont(titleFont); + titleStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + CellStyle subtitleStyle = workbook.createCellStyle(); + Font subtitleFont = workbook.createFont(); + subtitleFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex()); + subtitleStyle.setFont(subtitleFont); + + CellStyle headerStyle = bordered(workbook.createCellStyle()); + headerStyle.setFont(headerFont); + headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + headerStyle.setAlignment(HorizontalAlignment.CENTER); + + CellStyle amountStyle = bordered(workbook.createCellStyle()); + amountStyle.setDataFormat(dataFormat.getFormat("#,##0")); + amountStyle.setAlignment(HorizontalAlignment.RIGHT); + + CellStyle textStyle = bordered(workbook.createCellStyle()); + textStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + return new ExportStyles(titleStyle, subtitleStyle, headerStyle, amountStyle, textStyle); + } + + private static CellStyle bordered(CellStyle style) { + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionService.java new file mode 100644 index 0000000..e690a32 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionService.java @@ -0,0 +1,373 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.TransactionResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.budget.event.BudgetExceededCheckEvent; +import com.chaewookim.accountbookformoms.domain.asset.event.GoalAchievedCheckEvent; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TransactionService { + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final TransactionRepository transactionRepository; + private final AccountRepository accountRepository; + private final TransactionCategoryRepository categoryRepository; + private final ApplicationEventPublisher eventPublisher; + private final CacheManager cacheManager; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @CacheEvict(value = "dashboard", key = "#userId + ':' + #request.transactionDate.format(T(java.time.format.DateTimeFormatter).ofPattern('yyyy-MM'))") + public Long createTransaction(Long userId, TransactionRequest request) { + return createTransaction(userId, request, false); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @CacheEvict(value = "dashboard", key = "#userId + ':' + #request.transactionDate.format(T(java.time.format.DateTimeFormatter).ofPattern('yyyy-MM'))") + public void createTransactionFromFixed(Long userId, TransactionRequest request) { + createTransaction(userId, request, true); + } + + private Long createTransaction(Long userId, TransactionRequest request, boolean fromFixedTransaction) { + if (request.type() == TransactionType.TRANSFER) { + return createTransferTransaction(userId, request, fromFixedTransaction); + } + return createNormalTransaction(userId, request, fromFixedTransaction); + } + + // 이체 전용 로직 + private Long createTransferTransaction(Long userId, TransactionRequest request, boolean fromFixedTransaction) { + + if (request.targetAccountId() == null) { + throw new CustomException(AssetErrorCode.TARGET_ACCOUNT_REQUIRED); + } + + if (request.accountId().equals(request.targetAccountId())) { + throw new CustomException(AssetErrorCode.TRANSFER_TO_SELF_FORBIDDEN); + } + + Account first = accountRepository.findByIdWithLock(Math.min(request.accountId(), request.targetAccountId())) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + Account second = accountRepository.findByIdWithLock(Math.max(request.accountId(), request.targetAccountId())) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + Account source = (request.accountId().equals(first.getId())) ? first : second; + Account target = (request.targetAccountId().equals(first.getId())) ? first : second; + + if (!source.getUser().getId().equals(userId) + || !target.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.TRANSACTION_FORBIDDEN); + } + + applyTransferBalances(source, target, request.amount()); + + resetGoalAchievementStateIfNeeded(source); + resetGoalAchievementStateIfNeeded(target); + + publishGoalAchievedCheck(source.getUser().getId(), source.getId()); + publishGoalAchievedCheck(target.getUser().getId(), target.getId()); + + return saveTransferTransaction(source, target, request, fromFixedTransaction); + } + + // 일반 거래 전용 로직 + private Long createNormalTransaction(Long userId, TransactionRequest request, boolean fromFixedTransaction) { + + Account account = accountRepository.findByIdWithLock(request.accountId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + if (!account.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.TRANSACTION_FORBIDDEN); + } + + validateNormalTransactionType(account, request.type()); + BigDecimal amount = (request.type() == TransactionType.EXPENSE) ? request.amount().negate() : request.amount(); + account.changeBalance(amount); + + resetGoalAchievementStateIfNeeded(account); + + publishGoalAchievedCheck(userId, account.getId()); + + return saveTransaction(account, account, request, fromFixedTransaction); + } + + // 거래 내역 저장 로직 + private Long saveTransaction(Account userAccount, Account transactionAccount, TransactionRequest request, + boolean fromFixedTransaction) { + + TransactionCategory category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + Transaction transaction = Transaction.builder() + .user(userAccount.getUser()) + .account(transactionAccount) + .targetAccount(null) + .transactionCategory(category) + .type(request.type()) + .amount(request.amount()) + .transactionDate(request.transactionDate()) + .description(request.description()) + .build(); + + if (fromFixedTransaction) { + transaction.markAsFixedTransactionGenerated(); + } + + Long savedId = transactionRepository.save(transaction).getId(); + + if (request.type() == TransactionType.EXPENSE) { + String yearMonth = request.transactionDate().format(DateTimeFormatter.ofPattern("yyyy-MM")); + eventPublisher.publishEvent(new BudgetExceededCheckEvent(userAccount.getUser().getId(), yearMonth, request.categoryId())); + } + + return savedId; + } + + private Long saveTransferTransaction(Account source, Account target, TransactionRequest request, + boolean fromFixedTransaction) { + + TransactionCategory category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + Transaction transaction = Transaction.builder() + .user(source.getUser()) + .account(source) + .targetAccount(target) + .transactionCategory(category) + .type(request.type()) + .amount(request.amount()) + .transactionDate(request.transactionDate()) + .description(request.description()) + .build(); + + if (fromFixedTransaction) { + transaction.markAsFixedTransactionGenerated(); + } + + return transactionRepository.save(transaction).getId(); + } + + public Page getTransactions(Long userId, Long accountId, LocalDate startDate, LocalDate endDate, Pageable pageable) { + return transactionRepository.findAllByUserIdAndAccountIdAndTransactionDateBetween(userId, accountId, startDate, endDate, pageable) + .map(TransactionResponse::from); + } + + public Page getAllUserTransactions(Long userId, LocalDate startDate, LocalDate endDate, Pageable pageable) { + return transactionRepository.findAllByUserIdAndTransactionDateBetween(userId, startDate, endDate, pageable) + .map(TransactionResponse::from); + } + + public TransactionResponse getTransaction(Long transactionId) { + Transaction transaction = transactionRepository.findById(transactionId) + .orElseThrow(() -> new CustomException(AssetErrorCode.TRANSACTION_NOT_FOUND)); + return TransactionResponse.from(transaction); + } + + @Transactional + public void updateTransaction(Long userId, Long transactionId, TransactionRequest request) { + + Transaction transaction = validateAndGet(userId, transactionId); + LocalDate previousDate = transaction.getTransactionDate(); + + reverseTransactionBalances(transaction); + + TransactionCategory category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.CATEGORY_NOT_FOUND)); + + if (request.type() == TransactionType.TRANSFER) { + if (request.targetAccountId() == null || request.accountId().equals(request.targetAccountId())) { + throw new CustomException(AssetErrorCode.TRANSFER_TO_SELF_FORBIDDEN); + } + + Account first = accountRepository.findByIdWithLock(Math.min(request.accountId(), request.targetAccountId())) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + Account second = accountRepository.findByIdWithLock(Math.max(request.accountId(), request.targetAccountId())) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + Account source = request.accountId().equals(first.getId()) ? first : second; + Account target = request.targetAccountId().equals(first.getId()) ? first : second; + + if (!source.getUser().getId().equals(userId) + || !target.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.TRANSACTION_FORBIDDEN); + } + + transaction.update(request, category, source, target); + applyTransferBalances(source, target, request.amount()); + resetGoalAchievementStateIfNeeded(source); + resetGoalAchievementStateIfNeeded(target); + publishGoalAchievedCheck(userId, source.getId()); + publishGoalAchievedCheck(target.getUser().getId(), target.getId()); + } else { + Account account = accountRepository.findByIdWithLock(request.accountId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + if (!account.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.TRANSACTION_FORBIDDEN); + } + + validateNormalTransactionType(account, request.type()); + transaction.update(request, category, account, null); + BigDecimal amount = (request.type() == TransactionType.EXPENSE) ? request.amount().negate() : request.amount(); + account.changeBalance(amount); + resetGoalAchievementStateIfNeeded(account); + publishGoalAchievedCheck(userId, account.getId()); + } + + evictDashboardCache(userId, previousDate); + evictDashboardCache(userId, request.transactionDate()); + } + + @Transactional + public void deleteTransaction(Long userId, Long transactionId) { + + Transaction transaction = validateAndGet(userId, transactionId); + + reverseTransactionBalances(transaction); + publishGoalChecksAfterReverse(transaction); + transactionRepository.delete(transaction); + + evictDashboardCache(userId, transaction.getTransactionDate()); + } + + private void reverseTransactionBalances(Transaction transaction) { + Account source = accountRepository.findByIdWithLock(transaction.getAccount().getId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + if (transaction.getType() == TransactionType.TRANSFER && transaction.getTargetAccount() != null) { + Account target = accountRepository.findByIdWithLock(transaction.getTargetAccount().getId()) + .orElseThrow(() -> new CustomException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + reverseTransferBalances(source, target, transaction.getAmount()); + resetGoalAchievementStateIfNeeded(source); + resetGoalAchievementStateIfNeeded(target); + return; + } + + source.changeBalance(transaction.getBalanceChangeAmount().negate()); + resetGoalAchievementStateIfNeeded(source); + } + + private void resetGoalAchievementStateIfNeeded(Account account) { + if (account.getGoalAmount() == null) { + return; + } + if (!account.isGoalAchieved() && account.isGoalAchievedNotified()) { + account.resetGoalAchievedNotified(); + } + } + + private void validateNormalTransactionType(Account account, TransactionType type) { + if (account.getKind() == AccountKind.LOAN) { + throw new CustomException(AssetErrorCode.NORMAL_TRANSACTION_NOT_ALLOWED_FOR_LOAN); + } + if (type == TransactionType.INCOME && account.getKind() != AccountKind.ASSET) { + throw new CustomException(AssetErrorCode.INCOME_NOT_ALLOWED_FOR_LIABILITY); + } + } + + private void applyTransferBalances(Account source, Account target, BigDecimal amount) { + if (source.getKind() == AccountKind.LOAN) { + if (target.getKind() != AccountKind.ASSET) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_TRANSFER); + } + source.disburse(amount); + target.changeBalance(amount); + return; + } + if (target.getKind() == AccountKind.LOAN) { + if (source.getKind() != AccountKind.ASSET) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_TRANSFER); + } + source.changeBalance(amount.negate()); + target.changeBalance(amount); + return; + } + source.changeBalance(amount.negate()); + target.changeBalance(amount); + } + + private void reverseTransferBalances(Account source, Account target, BigDecimal amount) { + if (source.getKind() == AccountKind.LOAN) { + if (target.getKind() != AccountKind.ASSET) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_TRANSFER); + } + source.reverseDisbursement(amount); + target.changeBalance(amount.negate()); + return; + } + if (target.getKind() == AccountKind.LOAN) { + if (source.getKind() != AccountKind.ASSET) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_TRANSFER); + } + source.changeBalance(amount); + target.changeBalance(amount.negate()); + return; + } + source.changeBalance(amount); + target.changeBalance(amount.negate()); + } + + // 공통 검증 로직 + private Transaction validateAndGet(Long userId, Long transactionId) { + + Transaction transaction = transactionRepository.findById(transactionId) + .orElseThrow(() -> new CustomException(AssetErrorCode.TRANSACTION_NOT_FOUND)); + + if (!transaction.getUser().getId().equals(userId)) { + throw new CustomException(AssetErrorCode.TRANSACTION_FORBIDDEN); + } + + return transaction; + } + + private void publishGoalAchievedCheck(Long userId, Long accountId) { + eventPublisher.publishEvent(new GoalAchievedCheckEvent(userId, accountId)); + } + + private void publishGoalChecksAfterReverse(Transaction transaction) { + Account source = transaction.getAccount(); + publishGoalAchievedCheck(transaction.getUser().getId(), source.getId()); + + if (transaction.getType() == TransactionType.TRANSFER && transaction.getTargetAccount() != null) { + Account target = transaction.getTargetAccount(); + publishGoalAchievedCheck(target.getUser().getId(), target.getId()); + } + } + + // 대시보드 캐시 삭제 + private void evictDashboardCache(Long userId, LocalDate date) { + + if (cacheManager.getCache("dashboard") == null) { + return; + } + + Objects.requireNonNull(cacheManager.getCache("dashboard")) + .evict(userId + ":" + date.format(YEAR_MONTH_FORMATTER)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/AccountRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/AccountRepository.java new file mode 100644 index 0000000..c24504c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/AccountRepository.java @@ -0,0 +1,50 @@ +package com.chaewookim.accountbookformoms.domain.asset.dao; + +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface AccountRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByUserIdAndAccountName(Long userId, String accountName); + + boolean existsByUserIdAndAccountNameAndIdNot(Long userId, String accountName, Long id); + + @Query(value = """ + SELECT * + FROM account + WHERE user_id = :userId + AND account_name = :accountName + LIMIT 1 + """, nativeQuery = true) + Optional findByUserIdAndAccountNameIncludingDeleted(@Param("userId") Long userId, + @Param("accountName") String accountName); + + Optional findByIdAndUserId(Long id, Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select a from Account a where a.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE account + SET goal_achieved_notified = 1, + updated_at = NOW() + WHERE id = :accountId + AND user_id = :userId + AND goal_achieved_notified = 0 + AND deleted_at IS NULL + """, nativeQuery = true) + int claimGoalAchievedNotification(@Param("accountId") Long accountId, + @Param("userId") Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/FixedTransactionRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/FixedTransactionRepository.java new file mode 100644 index 0000000..03e0e09 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/FixedTransactionRepository.java @@ -0,0 +1,189 @@ +package com.chaewookim.accountbookformoms.domain.asset.dao; + +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; + +public interface FixedTransactionRepository extends JpaRepository { + + List findAllByUserId(Long userId); + + @Query("SELECT f FROM FixedTransaction f WHERE f.targetAccount.id = :accountId") + List findAllByTargetAccountId(@Param("accountId") Long accountId); + + @Modifying + @Query("UPDATE FixedTransaction f SET f.deletedAt = CURRENT_TIMESTAMP " + + "WHERE f.account.id = :accountId AND f.deletedAt IS NULL") + int softDeleteByAccountId(@Param("accountId") Long accountId); + + @Modifying + @Query("UPDATE FixedTransaction f SET f.deletedAt = CURRENT_TIMESTAMP " + + "WHERE f.transactionCategory.id = :categoryId AND f.deletedAt IS NULL") + int softDeleteByTransactionCategoryId(@Param("categoryId") Long categoryId); + + List findAllByIsActiveTrue(); + + List findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(LocalDate today); + + /** + * 사용자의 해당 월에 적용되는 고정(FixedTransaction) 거래 카테고리별 합계. + * 반환 컬럼: [categoryId, categoryName, sumAmount] + */ + @Query(""" + SELECT f.transactionCategory.id, f.transactionCategory.name, SUM(f.amount) + FROM FixedTransaction f + WHERE f.user.id = :userId + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + GROUP BY f.transactionCategory.id, f.transactionCategory.name + """) + List sumByUserCategory(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(SUM(f.amount), 0) + FROM FixedTransaction f + WHERE f.user.id = :userId + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + """) + BigDecimal sumByUserAndType(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(SUM(f.amount), 0) + FROM FixedTransaction f + WHERE f.user.id = :userId + AND f.type = :type + AND f.transactionCategory.id = :categoryId + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + """) + BigDecimal sumByUserAndTypeAndCategory(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 공개 설정 사용자들의 (user)별 월 합계. + * 반환 컬럼: [userId, username, sumAmount] + */ + @Query(""" + SELECT f.user.id, f.user.username, SUM(f.amount) + FROM FixedTransaction f + JOIN f.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + GROUP BY f.user.id, f.user.username + """) + List sumPublicMonthly(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT f.user.id, SUM(f.amount) + FROM FixedTransaction f + JOIN f.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + AND u.birthDate IS NOT NULL + AND u.birthDate BETWEEN :birthFrom AND :birthTo + GROUP BY f.user.id + """) + List sumPublicByAgeRange(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("birthFrom") LocalDate birthFrom, + @Param("birthTo") LocalDate birthTo, + @Param("excludeUserId") Long excludeUserId); + + @Query(""" + SELECT f.user.id, SUM(f.amount) + FROM FixedTransaction f + JOIN f.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + GROUP BY f.user.id + HAVING (:minAmount IS NULL OR SUM(f.amount) >= :minAmount) + AND (:maxAmount IS NULL OR SUM(f.amount) <= :maxAmount) + """) + List sumPublicByAmountRange(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("minAmount") BigDecimal minAmount, + @Param("maxAmount") BigDecimal maxAmount, + @Param("excludeUserId") Long excludeUserId); + + @Query(""" + SELECT f.user.id, SUM(f.amount) + FROM FixedTransaction f + JOIN f.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND f.type = :type + AND f.transactionCategory.id = :categoryId + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + GROUP BY f.user.id + """) + List sumPublicCategoryByUser(@Param("type") TransactionType type, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeUserId") Long excludeUserId); + + /** + * 지정 userId 집합(공개 사용자만) 의 월 고정 거래 합계(사용자 단위). 위치 기반 비교용. + */ + @Query(""" + SELECT f.user.id, SUM(f.amount) + FROM FixedTransaction f + JOIN f.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id IN :userIds + AND f.type = :type + AND f.isActive = true + AND f.startDate <= :endDate + AND (f.endDate IS NULL OR f.endDate >= :startDate) + GROUP BY f.user.id + """) + List sumPublicByUserIds(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("userIds") Collection userIds); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionCategoryRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionCategoryRepository.java new file mode 100644 index 0000000..cbbdea1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionCategoryRepository.java @@ -0,0 +1,39 @@ +package com.chaewookim.accountbookformoms.domain.asset.dao; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface TransactionCategoryRepository extends JpaRepository { + + @Query("SELECT c FROM TransactionCategory c WHERE c.user.id = :userId OR c.user IS NULL") + List findAllByUserOrSystem(@Param("userId") Long userId); + + @Query("SELECT c.id FROM TransactionCategory c WHERE c.user IS NULL") + List findSystemCategoryIds(); + + boolean existsByUserIsNullAndNameAndType(String name, TransactionType type); + + Optional findByUserIsNullAndNameAndType(String name, TransactionType type); + + boolean existsByUserIdAndNameAndType(Long userId, String name, TransactionType type); + + boolean existsByUserIdAndNameAndTypeAndIdNot(Long userId, String name, TransactionType type, Long id); + + @Query(value = """ + SELECT * + FROM transaction_category + WHERE user_id = :userId + AND name = :name + AND type = :type + LIMIT 1 + """, nativeQuery = true) + Optional findByUserIdAndNameAndTypeIncludingDeleted(@Param("userId") Long userId, + @Param("name") String name, + @Param("type") String type); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionRepository.java new file mode 100644 index 0000000..68ae648 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dao/TransactionRepository.java @@ -0,0 +1,315 @@ +package com.chaewookim.accountbookformoms.domain.asset.dao; + +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +public interface TransactionRepository extends JpaRepository { + + boolean existsByTransactionCategoryId(Long categoryId); + + @Query(""" + SELECT t FROM Transaction t + LEFT JOIN FETCH t.transactionCategory + LEFT JOIN FETCH t.account + LEFT JOIN FETCH t.targetAccount + WHERE t.user.id = :userId + AND t.transactionDate BETWEEN :startDate AND :endDate + ORDER BY t.transactionDate DESC + """) + List findByUserIdAndDateBetween(@Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + + @Query(""" + SELECT t.transactionCategory.name, SUM(ABS(t.amount)) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = 'EXPENSE' + AND FUNCTION('DATE_FORMAT', t.transactionDate, '%Y-%m') = :yearMonth + GROUP BY t.transactionCategory.name + """) + List sumCategoryExpense(@Param("userId") Long userId, @Param("yearMonth") String yearMonth); + + @Query(""" + SELECT FUNCTION('DATE_FORMAT', t.transactionDate, '%Y-%m') as ym, + SUM(CASE WHEN t.type = 'INCOME' THEN t.amount ELSE 0 END) as totalIncome, + SUM(CASE WHEN t.type = 'EXPENSE' THEN ABS(t.amount) ELSE 0 END) as totalExpense + FROM Transaction t + WHERE t.user.id = :userId + AND t.transactionDate >= :sixMonthsAgo + GROUP BY FUNCTION('DATE_FORMAT', t.transactionDate, '%Y-%m') + ORDER BY ym ASC + """) + List sumMonthlyTrends(@Param("userId") Long userId, @Param("sixMonthsAgo") LocalDate sixMonthsAgo); + + @Query(""" + SELECT COALESCE(SUM(t.amount), 0) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = TransactionType.EXPENSE + AND t.transactionCategory.id = :categoryId + AND t.transactionDate BETWEEN :startDate AND :endDate + """) + BigDecimal sumAmountByUserIdAndCategoryId(@Param("userId") Long userId, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(t.snapshotCategoryId, t.transactionCategory.id), SUM(ABS(t.amount)) + FROM Transaction t + WHERE t.user.id = :userId + AND FUNCTION('DATE_FORMAT', t.transactionDate, '%Y-%m') = :yearMonth + GROUP BY COALESCE(t.snapshotCategoryId, t.transactionCategory.id) + """) + List sumAmountByUserIdGroupByCategoryId(@Param("userId") Long userId, + @Param("yearMonth") String yearMonth); + + /** + * 사용자의 월별 변동(Transaction) 거래 카테고리별 합계. + * 반환 컬럼: [categoryId, categoryName, sumAmount] + */ + @Query(""" + SELECT t.transactionCategory.id, t.transactionCategory.name, SUM(t.amount) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.transactionCategory.id, t.transactionCategory.name + """) + List sumByUserCategory(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(SUM(t.amount), 0) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + """) + BigDecimal sumByUserAndType(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(SUM(t.amount), 0) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = :type + AND t.transactionCategory.id = :categoryId + AND t.transactionDate BETWEEN :startDate AND :endDate + """) + BigDecimal sumByUserAndTypeAndCategory(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 실제 거래만 고정 실행 여부({@code generated})로 나눠 카테고리별 합계. + * 아직 실행되지 않은 FixedTransaction 템플릿은 포함하지 않는다. + */ + @Query(""" + SELECT t.transactionCategory.id, t.transactionCategory.name, SUM(t.amount) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = :type + AND t.fixedTransactionGenerated = :generated + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.transactionCategory.id, t.transactionCategory.name + """) + List sumByUserCategoryAndGenerated(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("generated") boolean generated, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + SELECT COALESCE(SUM(t.amount), 0) + FROM Transaction t + WHERE t.user.id = :userId + AND t.type = :type + AND t.fixedTransactionGenerated = true + AND t.transactionCategory.id = :categoryId + AND t.transactionDate BETWEEN :startDate AND :endDate + """) + BigDecimal sumGeneratedByUserAndTypeAndCategory(@Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 공개 설정 사용자들의 (user)별 월 합계. + * 반환 컬럼: [userId, username, sumAmount] + */ + @Query(""" + SELECT t.user.id, t.user.username, SUM(t.amount) + FROM Transaction t + JOIN t.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.user.id, t.user.username + """) + List sumPublicMonthly(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 같은 나이대 공개 사용자들의 월 합계. + * 반환 컬럼: [userId, sumAmount] + */ + @Query(""" + SELECT t.user.id, SUM(t.amount) + FROM Transaction t + JOIN t.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + AND u.birthDate IS NOT NULL + AND u.birthDate BETWEEN :birthFrom AND :birthTo + GROUP BY t.user.id + """) + List sumPublicByAgeRange(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("birthFrom") LocalDate birthFrom, + @Param("birthTo") LocalDate birthTo, + @Param("excludeUserId") Long excludeUserId); + + /** + * 지정 금액 구간 안에 드는 공개 사용자들의 월 합계. + */ + @Query(""" + SELECT t.user.id, SUM(t.amount) + FROM Transaction t + JOIN t.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.user.id + HAVING (:minAmount IS NULL OR SUM(t.amount) >= :minAmount) + AND (:maxAmount IS NULL OR SUM(t.amount) <= :maxAmount) + """) + List sumPublicByAmountRange(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("minAmount") BigDecimal minAmount, + @Param("maxAmount") BigDecimal maxAmount, + @Param("excludeUserId") Long excludeUserId); + + /** + * 공개 사용자들의 특정 카테고리 월 합계(사용자 단위). + * 반환: [userId, sumAmount] + */ + @Query(""" + SELECT t.user.id, SUM(t.amount) + FROM Transaction t + JOIN t.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND t.type = :type + AND t.transactionCategory.id = :categoryId + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.user.id + """) + List sumPublicCategoryByUser(@Param("type") TransactionType type, + @Param("categoryId") Long categoryId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeUserId") Long excludeUserId); + + Page findAllByUserIdAndAccountIdAndTransactionDateBetween(Long userId, Long accountId, LocalDate startDate, LocalDate endDate, Pageable pageable); + + Page findAllByUserIdAndTransactionDateBetween(Long userId, LocalDate startDate, LocalDate endDate, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Transaction t + SET t.snapshotAccountId = :accountId, + t.snapshotAccountName = :accountName, + t.snapshotAccountRole = :accountRole + WHERE t.account.id = :accountId + AND (t.snapshotAccountName IS NULL OR t.snapshotAccountRole IS NULL) + """) + void backfillSourceAccountSnapshot(@Param("accountId") Long accountId, + @Param("accountName") String accountName, + @Param("accountRole") com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole accountRole); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Transaction t + SET t.snapshotTargetAccountId = :accountId, + t.snapshotTargetAccountName = :accountName, + t.snapshotTargetAccountRole = :accountRole + WHERE t.targetAccount.id = :accountId + AND (t.snapshotTargetAccountName IS NULL OR t.snapshotTargetAccountRole IS NULL) + """) + void backfillTargetAccountSnapshot(@Param("accountId") Long accountId, + @Param("accountName") String accountName, + @Param("accountRole") com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole accountRole); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Transaction t SET t.accountArchived = true WHERE t.snapshotAccountId = :accountId") + void markSourceAccountArchived(@Param("accountId") Long accountId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Transaction t SET t.targetAccountArchived = true WHERE t.snapshotTargetAccountId = :accountId") + void markTargetAccountArchived(@Param("accountId") Long accountId); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Transaction t + SET t.snapshotCategoryId = :categoryId, + t.snapshotCategoryName = :categoryName + WHERE t.transactionCategory.id = :categoryId + AND t.snapshotCategoryName IS NULL + """) + void backfillCategorySnapshot(@Param("categoryId") Long categoryId, @Param("categoryName") String categoryName); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Transaction t SET t.categoryArchived = true WHERE t.snapshotCategoryId = :categoryId") + void markCategoryArchived(@Param("categoryId") Long categoryId); + + /** + * 지정 userId 집합(공개 사용자만) 의 월 합계(사용자 단위). 위치 기반 비교용. + */ + @Query(""" + SELECT t.user.id, SUM(t.amount) + FROM Transaction t + JOIN t.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id IN :userIds + AND t.type = :type + AND t.transactionDate BETWEEN :startDate AND :endDate + GROUP BY t.user.id + """) + List sumPublicByUserIds(@Param("type") TransactionType type, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("userIds") Collection userIds); + List findByAccountIdAndDescriptionAndType(Long accountId, String description, TransactionType type); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountGoalRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountGoalRequest.java new file mode 100644 index 0000000..4af110f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountGoalRequest.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record AccountGoalRequest( + + @NotNull(message = "목표 금액은 필수입니다.") + @DecimalMin(value = "0", message = "목표 금액은 0원 이상이어야 합니다.") + BigDecimal goalAmount, + + LocalDate goalDate +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountRequest.java new file mode 100644 index 0000000..b6b4e5f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountRequest.java @@ -0,0 +1,32 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record AccountRequest( + + @NotBlank(message = "계좌 이름은 필수 입력 값입니다.") + String accountName, + + @NotNull(message = "초기 잔고는 필수 입력 값입니다.") + BigDecimal initialBalance, + + AccountRole role, + + AccountKind kind, + + BigDecimal creditLimit, + + BigDecimal loanLimit, + + Boolean loanAlreadyDisbursed +) { + public AccountRequest(String accountName, BigDecimal initialBalance, AccountRole role, + AccountKind kind, BigDecimal creditLimit) { + this(accountName, initialBalance, role, kind, creditLimit, null, null); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountUpdateRequest.java new file mode 100644 index 0000000..f572c9f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/AccountUpdateRequest.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record AccountUpdateRequest( + + @NotBlank(message = "계좌 이름은 필수 입력 값입니다.") + String accountName, + + @NotNull(message = "초기 잔고는 필수 입력 값입니다.") + BigDecimal initialBalance, + + @NotNull(message = "현재 잔고는 필수 입력 값입니다.") + BigDecimal currentBalance, + + AccountRole role, + + AccountKind kind, + + BigDecimal creditLimit, + + BigDecimal loanLimit +) { + public AccountUpdateRequest(String accountName, BigDecimal initialBalance, BigDecimal currentBalance, + AccountRole role, AccountKind kind, BigDecimal creditLimit) { + this(accountName, initialBalance, currentBalance, role, kind, creditLimit, null); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/CategoryRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/CategoryRequest.java new file mode 100644 index 0000000..7825276 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/CategoryRequest.java @@ -0,0 +1,24 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CategoryRequest( + + @NotBlank(message = "카테고리 이름은 필수입니다.") + String name, + + @NotNull(message = "카테고리 타입은 필수입니다.") + TransactionType type +) { + public TransactionCategory toEntity(User user) { + return TransactionCategory.builder() + .user(user) + .name(this.name) + .type(this.type) + .build(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/FixedTransactionRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/FixedTransactionRequest.java new file mode 100644 index 0000000..f1ec4a7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/FixedTransactionRequest.java @@ -0,0 +1,43 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import jakarta.validation.constraints.*; + +public record FixedTransactionRequest( + + @NotNull(message = "계좌 ID는 필수입니다.") + Long accountId, + + Long targetAccountId, + + @NotNull(message = "카테고리 ID는 필수입니다.") + Long categoryId, + + @NotNull(message = "거래 유형은 필수입니다.") + TransactionType type, + + @NotNull(message = "금액은 필수입니다.") + @Positive(message = "금액은 0보다 커야 합니다.") + BigDecimal amount, + + @NotNull(message = "반복 주기는 필수입니다.") + TransactionFrequency frequency, + + @NotNull(message = "반복일은 필수입니다.") + Integer repeatDay, + + Integer repeatMonth, + + @NotNull(message = "시작일은 필수입니다.") + LocalDate startDate, + + LocalDate endDate, + + String description +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/TransactionRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/TransactionRequest.java new file mode 100644 index 0000000..05d9af7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/request/TransactionRequest.java @@ -0,0 +1,31 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.request; + +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record TransactionRequest( + + @NotNull(message = "계좌 ID는 필수입니다.") + Long accountId, + + Long targetAccountId, // 이체 시 사용할 대상 계좌 ID + + @NotNull(message = "카테고리 ID는 필수입니다.") + Long categoryId, + + @NotNull(message = "거래 유형은 필수입니다.") + TransactionType type, + + @NotNull(message = "금액은 필수입니다.") + @Positive(message = "금액은 0보다 커야 합니다.") + BigDecimal amount, + + @NotNull(message = "거래일은 필수입니다.") + LocalDate transactionDate, + + String description +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AccountResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AccountResponse.java new file mode 100644 index 0000000..4521e3a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AccountResponse.java @@ -0,0 +1,41 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record AccountResponse( + + Long id, + String accountName, + BigDecimal initialBalance, + BigDecimal currentBalance, + AccountRole role, + AccountKind kind, + BigDecimal creditLimit, + BigDecimal loanLimit, + BigDecimal disbursedAmount, + BigDecimal goalAmount, + LocalDate goalDate, + Integer progressPercent +) { + public AccountResponse(Account account) { + this( + account.getId(), + account.getAccountName(), + account.getInitialBalance(), + account.getCurrentBalance(), + account.getRole(), + account.getKind(), + account.getCreditLimit(), + account.getLoanLimit(), + account.getDisbursedAmount(), + account.getGoalAmount(), + account.getGoalDate(), + account.calculateGoalProgressPercent() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AllocationBucketResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AllocationBucketResponse.java new file mode 100644 index 0000000..17358c9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/AllocationBucketResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import java.io.Serializable; +import java.math.BigDecimal; + +public record AllocationBucketResponse( + + BigDecimal net, + BigDecimal rate, + BigDecimal inflow, + BigDecimal outflow +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/CategoryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/CategoryResponse.java new file mode 100644 index 0000000..9008f9b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/CategoryResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; + +public record CategoryResponse( + + Long id, + String name, + TransactionType type, + boolean isCustom +) { + public static CategoryResponse from(TransactionCategory category) { + return new CategoryResponse( + category.getId(), + category.getName(), + category.getType(), + category.getUser() != null + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/FixedTransactionResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/FixedTransactionResponse.java new file mode 100644 index 0000000..6f68a04 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/FixedTransactionResponse.java @@ -0,0 +1 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; import java.math.BigDecimal; import java.time.LocalDate; public record FixedTransactionResponse( Long id, Long accountId, String accountName, Long targetAccountId, String targetAccountName, Long categoryId, String categoryName, TransactionType type, BigDecimal amount, TransactionFrequency frequency, Integer repeatDay, Integer repeatMonth, LocalDate startDate, LocalDate endDate, String description, Boolean isActive, LocalDate nextExecutionDate, LocalDate lastExecutedDate, FixedTransactionExecutionFailure failureReason, LocalDate failedExecutionDate ) { public static FixedTransactionResponse from(FixedTransaction entity) { Long accountId = entity.getAccount() != null ? entity.getAccount().getId() : null; String accountName = entity.getAccount() != null ? entity.getAccount().getAccountName() : "삭제된 계좌"; Long targetAccountId = entity.getTargetAccount() != null ? entity.getTargetAccount().getId() : null; String targetAccountName = entity.getTargetAccount() != null ? entity.getTargetAccount().getAccountName() : null; return new FixedTransactionResponse( entity.getId(), accountId, accountName, targetAccountId, targetAccountName, entity.getTransactionCategory().getId(), entity.getTransactionCategory().getName(), entity.getType(), entity.getAmount(), entity.getFrequency(), entity.getRepeatDay(), entity.getRepeatMonth(), entity.getStartDate(), entity.getEndDate(), entity.getDescription(), entity.getIsActive(), entity.getNextExecutionDate(), entity.getLastExecutedDate(), entity.getFailureReason(), entity.getFailedExecutionDate() ); } } \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/GoalProgressResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/GoalProgressResponse.java new file mode 100644 index 0000000..ceb3d0f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/GoalProgressResponse.java @@ -0,0 +1,22 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record GoalProgressResponse( + + Long accountId, + String accountName, + AccountKind kind, + AccountRole role, + BigDecimal currentBalance, + BigDecimal goalAmount, + Integer progressPercent, + LocalDate goalDate, + Integer dDay +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationResponse.java new file mode 100644 index 0000000..da1d17f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationResponse.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import java.io.Serializable; + +public record MonthlyAllocationResponse( + + AllocationBucketResponse savings, + AllocationBucketResponse investment +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationSummaryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationSummaryResponse.java new file mode 100644 index 0000000..3fcb926 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/MonthlyAllocationSummaryResponse.java @@ -0,0 +1,26 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import java.io.Serializable; +import java.math.BigDecimal; + +public record MonthlyAllocationSummaryResponse( + + String yearMonth, + BigDecimal totalIncome, + AllocationBucketResponse savings, + AllocationBucketResponse investment +) implements Serializable { + + public static MonthlyAllocationSummaryResponse from( + String yearMonth, + BigDecimal totalIncome, + MonthlyAllocationResponse allocation + ) { + return new MonthlyAllocationSummaryResponse( + yearMonth, + totalIncome, + allocation.savings(), + allocation.investment() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/TransactionResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/TransactionResponse.java new file mode 100644 index 0000000..7e7b936 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/dto/response/TransactionResponse.java @@ -0,0 +1,66 @@ +package com.chaewookim.accountbookformoms.domain.asset.dto.response; + +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record TransactionResponse( + + Long id, + Long accountId, + String accountName, + boolean accountArchived, + Long targetAccountId, + String targetAccountName, + boolean targetAccountArchived, + Long categoryId, + String categoryName, + boolean categoryArchived, + TransactionType type, + BigDecimal amount, + LocalDate transactionDate, + String description, + boolean fixedTransactionGenerated +) { + public static TransactionResponse from(Transaction transaction) { + Long accountId = transaction.getSnapshotAccountId() != null + ? transaction.getSnapshotAccountId() + : (transaction.getAccount() != null ? transaction.getAccount().getId() : null); + String accountName = transaction.getSnapshotAccountName() != null + ? transaction.getSnapshotAccountName() + : (transaction.getAccount() != null ? transaction.getAccount().getAccountName() : "삭제된 계좌"); + + Long targetAccountId = transaction.getSnapshotTargetAccountId() != null + ? transaction.getSnapshotTargetAccountId() + : (transaction.getTargetAccount() != null ? transaction.getTargetAccount().getId() : null); + String targetAccountName = transaction.getSnapshotTargetAccountName() != null + ? transaction.getSnapshotTargetAccountName() + : (transaction.getTargetAccount() != null ? transaction.getTargetAccount().getAccountName() : null); + + Long categoryId = transaction.getSnapshotCategoryId() != null + ? transaction.getSnapshotCategoryId() + : (transaction.getTransactionCategory() != null ? transaction.getTransactionCategory().getId() : null); + String categoryName = transaction.getSnapshotCategoryName() != null + ? transaction.getSnapshotCategoryName() + : (transaction.getTransactionCategory() != null ? transaction.getTransactionCategory().getName() : "삭제된 카테고리"); + + return new TransactionResponse( + transaction.getId(), + accountId, + accountName, + transaction.isAccountArchived(), + targetAccountId, + targetAccountName, + transaction.isTargetAccountArchived(), + categoryId, + categoryName, + transaction.isCategoryArchived(), + transaction.getType(), + transaction.getAmount(), + transaction.getTransactionDate(), + transaction.getDescription(), + transaction.isFixedTransactionGenerated() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Account.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Account.java new file mode 100644 index 0000000..59b06c2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Account.java @@ -0,0 +1,344 @@ +package com.chaewookim.accountbookformoms.domain.asset.entity; + +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "account", + indexes = { + @Index(name = "idx_account_user", columnList = "user_id"), + @Index(name = "idx_account_user_name", columnList = "user_id, account_name") + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE account SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Account extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private String accountName; + + @Column(nullable = false) + private BigDecimal initialBalance; + + @Column(nullable = false) + private BigDecimal currentBalance; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AccountRole role = AccountRole.CHECKING; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AccountKind kind = AccountKind.ASSET; + + @Column + private BigDecimal creditLimit; + + @Column + private BigDecimal loanLimit; + + @Column + private BigDecimal disbursedAmount; + + @Column + private BigDecimal goalAmount; + + @Column + private LocalDate goalDate; + + @Column(nullable = false) + private boolean goalAchievedNotified; + + @Builder + public Account(User user, String accountName, BigDecimal initialBalance, AccountRole role, + AccountKind kind, BigDecimal creditLimit, BigDecimal loanLimit, + BigDecimal disbursedAmount) { + this.user = user; + this.accountName = accountName; + this.initialBalance = initialBalance; + this.currentBalance = initialBalance; + this.kind = kind != null ? kind : AccountKind.ASSET; + this.role = this.kind == AccountKind.ASSET && role != null ? role : AccountRole.CHECKING; + this.creditLimit = creditLimit; + this.loanLimit = this.kind == AccountKind.LOAN + ? (loanLimit != null ? loanLimit : absoluteOrZero(initialBalance)) + : loanLimit; + this.disbursedAmount = this.kind == AccountKind.LOAN + ? (disbursedAmount != null ? disbursedAmount : this.loanLimit) + : disbursedAmount; + validateAccountPolicy(initialBalance, initialBalance, this.kind, creditLimit, + this.loanLimit, this.disbursedAmount); + } + + public void updateAccountName(String accountName) { + this.accountName = accountName; + } + + public void updateInitialBalance(BigDecimal newInitialBalance) { + BigDecimal delta = newInitialBalance.subtract(this.initialBalance); + this.initialBalance = newInitialBalance; + this.currentBalance = this.currentBalance.add(delta); + } + + public void updateInitialBalanceOnly(BigDecimal newInitialBalance) { + this.initialBalance = newInitialBalance; + } + + public void resetBalance(BigDecimal initialBalance) { + validateAccountPolicy(initialBalance, initialBalance, this.kind, this.creditLimit, + this.loanLimit, this.disbursedAmount); + this.initialBalance = initialBalance; + this.currentBalance = initialBalance; + } + + public void changeBalance(BigDecimal amount) { + BigDecimal nextBalance = this.currentBalance.add(amount); + if (this.kind == AccountKind.ASSET && nextBalance.compareTo(BigDecimal.ZERO) < 0) { + throw new CustomException(AssetErrorCode.INSUFFICIENT_BALANCE); + } + if (this.kind == AccountKind.CREDIT_CARD + && this.creditLimit != null + && nextBalance.compareTo(this.creditLimit.negate()) < 0) { + throw new CustomException(AssetErrorCode.CREDIT_LIMIT_EXCEEDED); + } + if (this.kind != AccountKind.ASSET && nextBalance.compareTo(BigDecimal.ZERO) > 0) { + throw new CustomException(AssetErrorCode.INVALID_LIABILITY_BALANCE); + } + if (this.kind == AccountKind.LOAN + && nextBalance.abs().compareTo(this.disbursedAmount) > 0) { + throw new CustomException(AssetErrorCode.LOAN_DEBT_EXCEEDS_DISBURSED_AMOUNT); + } + this.currentBalance = nextBalance; + } + + public void disburse(BigDecimal amount) { + BigDecimal nextDisbursedAmount = this.disbursedAmount.add(amount); + if (nextDisbursedAmount.compareTo(this.loanLimit) > 0) { + throw new CustomException(AssetErrorCode.LOAN_LIMIT_EXCEEDED); + } + this.currentBalance = this.currentBalance.subtract(amount); + this.disbursedAmount = nextDisbursedAmount; + } + + public void reverseDisbursement(BigDecimal amount) { + BigDecimal nextBalance = this.currentBalance.add(amount); + BigDecimal nextDisbursedAmount = this.disbursedAmount.subtract(amount); + if (nextDisbursedAmount.compareTo(BigDecimal.ZERO) < 0 + || nextBalance.compareTo(BigDecimal.ZERO) > 0 + || nextBalance.abs().compareTo(nextDisbursedAmount) > 0) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_REVERSAL); + } + this.currentBalance = nextBalance; + this.disbursedAmount = nextDisbursedAmount; + } + + public void updateRole(AccountRole role) { + this.role = this.kind == AccountKind.ASSET && role != null ? role : AccountRole.CHECKING; + } + + public void reinitialize(AccountKind kind, BigDecimal creditLimit, BigDecimal loanLimit, + BigDecimal disbursedAmount, BigDecimal initialBalance, AccountRole role) { + AccountKind resolvedKind = kind != null ? kind : AccountKind.ASSET; + validateAccountPolicy(initialBalance, initialBalance, resolvedKind, creditLimit, + loanLimit, disbursedAmount); + if (this.kind != resolvedKind) { + clearGoal(); + } + this.kind = resolvedKind; + this.creditLimit = creditLimit; + this.loanLimit = loanLimit; + this.disbursedAmount = disbursedAmount; + this.initialBalance = initialBalance; + this.currentBalance = initialBalance; + this.role = resolvedKind == AccountKind.ASSET && role != null ? role : AccountRole.CHECKING; + } + + public void updateKindAndLimits(AccountKind kind, BigDecimal creditLimit, BigDecimal loanLimit, + BigDecimal initialBalance, BigDecimal currentBalance) { + AccountKind resolvedKind = kind != null ? kind : AccountKind.ASSET; + AccountKind previousKind = this.kind; + BigDecimal resolvedLoanLimit = null; + BigDecimal resolvedDisbursedAmount = null; + if (resolvedKind == AccountKind.LOAN) { + resolvedLoanLimit = loanLimit != null + ? loanLimit + : (previousKind == AccountKind.LOAN ? this.loanLimit : absoluteOrZero(initialBalance)); + resolvedDisbursedAmount = previousKind == AccountKind.LOAN + ? this.disbursedAmount + : absoluteOrZero(currentBalance); + } + validateAccountPolicy(initialBalance, currentBalance, resolvedKind, creditLimit, + resolvedLoanLimit, resolvedDisbursedAmount); + this.kind = resolvedKind; + this.creditLimit = creditLimit; + this.loanLimit = resolvedLoanLimit; + this.disbursedAmount = resolvedDisbursedAmount; + if (previousKind != null && previousKind != resolvedKind) { + clearGoal(); + } + if (resolvedKind != AccountKind.ASSET) { + this.role = AccountRole.CHECKING; + } + } + + private void validateAccountPolicy(BigDecimal initialBalance, BigDecimal currentBalance, + AccountKind kind, BigDecimal creditLimit, + BigDecimal loanLimit, BigDecimal disbursedAmount) { + if (creditLimit != null && creditLimit.compareTo(BigDecimal.ZERO) < 0) { + throw new CustomException(AssetErrorCode.INVALID_CREDIT_LIMIT); + } + if (kind != AccountKind.CREDIT_CARD && creditLimit != null) { + throw new CustomException(AssetErrorCode.CREDIT_LIMIT_NOT_ALLOWED); + } + if (kind == AccountKind.ASSET + && ((initialBalance != null && initialBalance.compareTo(BigDecimal.ZERO) < 0) + || (currentBalance != null && currentBalance.compareTo(BigDecimal.ZERO) < 0))) { + throw new CustomException(AssetErrorCode.INVALID_ACCOUNT_BALANCE); + } + if (kind != AccountKind.ASSET + && ((initialBalance != null && initialBalance.compareTo(BigDecimal.ZERO) > 0) + || (currentBalance != null && currentBalance.compareTo(BigDecimal.ZERO) > 0))) { + throw new CustomException(AssetErrorCode.INVALID_LIABILITY_BALANCE); + } + if (kind == AccountKind.CREDIT_CARD && creditLimit != null + && ((initialBalance != null && initialBalance.compareTo(creditLimit.negate()) < 0) + || (currentBalance != null && currentBalance.compareTo(creditLimit.negate()) < 0))) { + throw new CustomException(AssetErrorCode.CREDIT_LIMIT_EXCEEDED); + } + if (kind != AccountKind.LOAN && (loanLimit != null || disbursedAmount != null)) { + throw new CustomException(AssetErrorCode.LOAN_FIELDS_NOT_ALLOWED); + } + if (kind == AccountKind.LOAN) { + if (loanLimit == null || loanLimit.compareTo(BigDecimal.ZERO) < 0) { + throw new CustomException(AssetErrorCode.INVALID_LOAN_LIMIT); + } + if (disbursedAmount == null || disbursedAmount.compareTo(BigDecimal.ZERO) < 0 + || disbursedAmount.compareTo(loanLimit) > 0) { + throw new CustomException(AssetErrorCode.INVALID_DISBURSED_AMOUNT); + } + if (currentBalance != null && currentBalance.abs().compareTo(disbursedAmount) > 0) { + throw new CustomException(AssetErrorCode.LOAN_DEBT_EXCEEDS_DISBURSED_AMOUNT); + } + } + } + + private static BigDecimal absoluteOrZero(BigDecimal amount) { + return amount != null ? amount.abs() : BigDecimal.ZERO; + } + + public void updateGoal(BigDecimal goalAmount, LocalDate goalDate) { + if (this.kind == AccountKind.CREDIT_CARD) { + throw new CustomException(AssetErrorCode.GOAL_NOT_SUPPORTED); + } + if (this.kind == AccountKind.LOAN + && (goalAmount == null || goalAmount.compareTo(BigDecimal.ZERO) < 0)) { + throw new CustomException(AssetErrorCode.INVALID_DEBT_GOAL_AMOUNT); + } + if (this.kind == AccountKind.ASSET + && (goalAmount == null || goalAmount.compareTo(BigDecimal.ZERO) <= 0)) { + throw new CustomException(AssetErrorCode.INVALID_GOAL_AMOUNT); + } + this.goalAmount = goalAmount; + this.goalDate = goalDate; + this.goalAchievedNotified = false; + } + + public void clearGoal() { + this.goalAmount = null; + this.goalDate = null; + this.goalAchievedNotified = false; + } + + public void resetGoalAchievedNotified() { + this.goalAchievedNotified = false; + } + + public boolean isReadyForNotification() { + return goalAmount != null && isGoalAchieved() && !isGoalAchievedNotified(); + } + + public boolean isGoalAchieved() { + if (goalAmount == null || this.kind == AccountKind.CREDIT_CARD) { + return false; + } + if (this.kind == AccountKind.LOAN) { + BigDecimal remainingDebt = currentBalance != null ? currentBalance.abs() : BigDecimal.ZERO; + return remainingDebt.compareTo(goalAmount) <= 0; + } + if (goalAmount.compareTo(BigDecimal.ZERO) <= 0) { + return false; + } + BigDecimal balance = currentBalance != null ? currentBalance : BigDecimal.ZERO; + if (balance.compareTo(goalAmount) >= 0) { + return true; + } + int percent = balance + .multiply(BigDecimal.valueOf(100)) + .divide(goalAmount, 0, RoundingMode.HALF_UP) + .intValue(); + return percent >= 100; + } + + public Integer calculateGoalProgressPercent() { + if (goalAmount == null || this.kind == AccountKind.CREDIT_CARD) { + return null; + } + if (this.kind == AccountKind.LOAN) { + BigDecimal initialDebt = disbursedAmount != null ? disbursedAmount : BigDecimal.ZERO; + BigDecimal currentDebt = currentBalance != null ? currentBalance.abs() : BigDecimal.ZERO; + BigDecimal amountToRepay = initialDebt.subtract(goalAmount); + if (amountToRepay.compareTo(BigDecimal.ZERO) <= 0) { + return currentDebt.compareTo(goalAmount) <= 0 ? 100 : 0; + } + int percent = initialDebt.subtract(currentDebt) + .multiply(BigDecimal.valueOf(100)) + .divide(amountToRepay, 0, RoundingMode.HALF_UP) + .intValue(); + return Math.min(100, Math.max(0, percent)); + } + if (goalAmount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + BigDecimal balance = currentBalance != null ? currentBalance : BigDecimal.ZERO; + int percent = balance + .multiply(BigDecimal.valueOf(100)) + .divide(goalAmount, 0, RoundingMode.HALF_UP) + .intValue(); + return Math.min(100, Math.max(0, percent)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/FixedTransaction.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/FixedTransaction.java new file mode 100644 index 0000000..a5ff9b6 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/FixedTransaction.java @@ -0,0 +1,278 @@ +package com.chaewookim.accountbookformoms.domain.asset.entity; + +import com.chaewookim.accountbookformoms.domain.asset.dto.request.FixedTransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE fixed_transaction SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "fixed_transaction", + indexes = { + @Index(name = "idx_fixed_transaction_user", columnList = "user_id"), + @Index(name = "idx_fixed_transaction_account", columnList = "account_id"), + @Index(name = "idx_fixed_transaction_category", columnList = "category_id") + }) +public class FixedTransaction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + private Account account; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target_account_id") + private Account targetAccount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private TransactionCategory transactionCategory; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; + + @Column(nullable = false) + private BigDecimal amount; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionFrequency frequency; + + /** WEEKLY: 1(월)~7(일), MONTHLY/YEARLY: 1~31일 */ + @Column(nullable = false) + private Integer repeatDay; + + /** YEARLY 전용: 1~12월 */ + private Integer repeatMonth; + + @Column(nullable = false) + private LocalDate startDate; + + private LocalDate endDate; + + private LocalDate lastExecutedDate; + + private LocalDate nextExecutionDate; + + @Enumerated(EnumType.STRING) + @Column(length = 40) + private FixedTransactionExecutionFailure failureReason; + + private LocalDate failedExecutionDate; + + private String description; + + @Column(nullable = false) + private Boolean isActive; + + @Builder + public FixedTransaction(User user, Account account, Account targetAccount, TransactionCategory transactionCategory, + TransactionType type, BigDecimal amount, + TransactionFrequency frequency, Integer repeatDay, Integer repeatMonth, + LocalDate startDate, LocalDate endDate, String description) { + this.user = user; + this.account = account; + this.targetAccount = targetAccount; + this.transactionCategory = transactionCategory; + this.type = type; + this.amount = amount; + this.frequency = frequency; + this.repeatDay = repeatDay; + this.repeatMonth = repeatMonth; + this.startDate = startDate; + this.endDate = endDate; + this.description = description; + this.nextExecutionDate = calculateInitialNextDate(startDate, frequency, repeatDay, repeatMonth); + this.isActive = true; + } + + public static void validateSchedule(TransactionFrequency frequency, Integer repeatDay, Integer repeatMonth) { + switch (frequency) { + case WEEKLY -> { + if (repeatDay == null || repeatDay < 1 || repeatDay > 7) { + throw new CustomException(AssetErrorCode.INVALID_REPEAT_DAY); + } + } + case MONTHLY -> { + if (repeatDay == null || repeatDay < 1 || repeatDay > 31) { + throw new CustomException(AssetErrorCode.INVALID_REPEAT_DAY); + } + } + case YEARLY -> { + if (repeatMonth == null || repeatMonth < 1 || repeatMonth > 12) { + throw new CustomException(AssetErrorCode.INVALID_REPEAT_MONTH); + } + if (repeatDay == null || repeatDay < 1 || repeatDay > 31) { + throw new CustomException(AssetErrorCode.INVALID_REPEAT_DAY); + } + } + } + } + + private static int effectiveDayOfMonth(int repeatDay, int year, int month) { + return Math.min(repeatDay, YearMonth.of(year, month).lengthOfMonth()); + } + + private static LocalDate calculateInitialNextDate(LocalDate startDate, TransactionFrequency frequency, + Integer repeatDay, Integer repeatMonth) { + validateSchedule(frequency, repeatDay, repeatMonth); + + return switch (frequency) { + case WEEKLY -> { + LocalDate date = startDate; + while (date.getDayOfWeek().getValue() != repeatDay) { + date = date.plusDays(1); + } + yield date; + } + case MONTHLY -> { + int year = startDate.getYear(); + int month = startDate.getMonthValue(); + int day = effectiveDayOfMonth(repeatDay, year, month); + LocalDate target = LocalDate.of(year, month, day); + if (startDate.isAfter(target)) { + LocalDate nextMonth = startDate.plusMonths(1); + day = effectiveDayOfMonth(repeatDay, nextMonth.getYear(), nextMonth.getMonthValue()); + target = LocalDate.of(nextMonth.getYear(), nextMonth.getMonthValue(), day); + } + yield target; + } + case YEARLY -> { + int year = startDate.getYear(); + int day = effectiveDayOfMonth(repeatDay, year, repeatMonth); + LocalDate target = LocalDate.of(year, repeatMonth, day); + if (startDate.isAfter(target)) { + year++; + day = effectiveDayOfMonth(repeatDay, year, repeatMonth); + target = LocalDate.of(year, repeatMonth, day); + } + yield target; + } + }; + } + + public boolean isExecutionDay(LocalDate today) { + if (endDate != null && today.isAfter(endDate)) { + return false; + } + if (today.isBefore(startDate)) { + return false; + } + + return switch (frequency) { + case WEEKLY -> today.getDayOfWeek().getValue() == repeatDay; + case MONTHLY -> today.getDayOfMonth() == effectiveDayOfMonth(repeatDay, today.getYear(), today.getMonthValue()); + case YEARLY -> today.getMonthValue() == repeatMonth + && today.getDayOfMonth() == effectiveDayOfMonth(repeatDay, today.getYear(), repeatMonth); + }; + } + + public void update(Account account, Account targetAccount, TransactionCategory category, FixedTransactionRequest request) { + this.account = account; + this.targetAccount = targetAccount; + this.transactionCategory = category; + this.type = request.type(); + this.amount = request.amount(); + this.frequency = request.frequency(); + this.repeatDay = request.repeatDay(); + this.repeatMonth = request.repeatMonth(); + this.startDate = request.startDate(); + this.endDate = request.endDate(); + this.description = request.description(); + + LocalDate basis = LocalDate.now().isBefore(startDate) ? startDate : LocalDate.now(); + this.nextExecutionDate = calculateInitialNextDate(basis, frequency, repeatDay, repeatMonth); + clearExecutionFailure(); + } + + public void toggleActive() { + this.isActive = !this.isActive; + } + + public boolean hasExecutionFailure() { + return failureReason != null && failedExecutionDate != null; + } + + public void markExecutionFailed(LocalDate executionDate, FixedTransactionExecutionFailure reason) { + this.failedExecutionDate = executionDate; + this.failureReason = reason; + } + + public void clearExecutionFailure() { + this.failureReason = null; + this.failedExecutionDate = null; + } + + public void skipFailedOccurrence() { + if (!hasExecutionFailure()) { + throw new CustomException(AssetErrorCode.FIXED_TRANSACTION_NOT_FAILED); + } + LocalDate skippedDate = this.failedExecutionDate; + updateExecutionStatus(skippedDate); + } + + private LocalDate calculateNextExecutionDate(LocalDate executedDate) { + return switch (frequency) { + case WEEKLY -> executedDate.plusWeeks(1); + case MONTHLY -> { + LocalDate next = executedDate.plusMonths(1); + int day = effectiveDayOfMonth(repeatDay, next.getYear(), next.getMonthValue()); + yield LocalDate.of(next.getYear(), next.getMonthValue(), day); + } + case YEARLY -> { + int year = executedDate.getYear() + 1; + int day = effectiveDayOfMonth(repeatDay, year, repeatMonth); + yield LocalDate.of(year, repeatMonth, day); + } + }; + } + + public void updateExecutionStatus(LocalDate executedDate) { + this.lastExecutedDate = executedDate; + this.nextExecutionDate = calculateNextExecutionDate(executedDate); + clearExecutionFailure(); + } + + public void alignNextExecutionDateIfStale(LocalDate today) { + if (isExecutionDay(today) && nextExecutionDate != null && nextExecutionDate.isAfter(today)) { + this.nextExecutionDate = today; + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Transaction.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Transaction.java new file mode 100644 index 0000000..50b5820 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/Transaction.java @@ -0,0 +1,175 @@ +package com.chaewookim.accountbookformoms.domain.asset.entity; + +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE transaction SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "transaction", indexes = { + @Index(name = "idx_transaction_user_account_date", + columnList = "user_id, account_id, transaction_date") +}) +public class Transaction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + private Account account; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target_account_id") + private Account targetAccount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private TransactionCategory transactionCategory; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; + + @Column(nullable = false) + private BigDecimal amount; + + @Column(nullable = false) + private LocalDate transactionDate; + + private String description; + + @Column(nullable = false) + private boolean fixedTransactionGenerated = false; + + @Column(name = "snapshot_account_id") + private Long snapshotAccountId; + + @Column(name = "snapshot_account_name") + private String snapshotAccountName; + + @Enumerated(EnumType.STRING) + @Column(name = "snapshot_account_role") + private AccountRole snapshotAccountRole; + + @Column(name = "snapshot_target_account_id") + private Long snapshotTargetAccountId; + + @Column(name = "snapshot_target_account_name") + private String snapshotTargetAccountName; + + @Enumerated(EnumType.STRING) + @Column(name = "snapshot_target_account_role") + private AccountRole snapshotTargetAccountRole; + + @Column(nullable = false) + private boolean accountArchived = false; + + @Column(nullable = false) + private boolean targetAccountArchived = false; + + @Column(name = "snapshot_category_id") + private Long snapshotCategoryId; + + @Column(name = "snapshot_category_name") + private String snapshotCategoryName; + + @Column(nullable = false) + private boolean categoryArchived = false; + + @Builder + public Transaction(User user, Account account, Account targetAccount, TransactionCategory transactionCategory, + TransactionType type, BigDecimal amount, LocalDate transactionDate, String description) { + this.user = user; + this.account = account; + this.targetAccount = targetAccount; + this.transactionCategory = transactionCategory; + this.type = type; + this.amount = amount; + this.transactionDate = transactionDate; + this.description = description; + syncAccountSnapshots(); + syncCategorySnapshot(); + } + + public void markAsFixedTransactionGenerated() { + this.fixedTransactionGenerated = true; + } + + public void update(TransactionRequest request, TransactionCategory category, Account account, Account targetAccount) { + this.account = account; + this.transactionCategory = category; + this.type = request.type(); + this.amount = request.amount(); + this.transactionDate = request.transactionDate(); + this.description = request.description(); + this.targetAccount = request.type() == TransactionType.TRANSFER ? targetAccount : null; + syncAccountSnapshots(); + syncCategorySnapshot(); + } + + public void syncAccountSnapshots() { + if (this.account != null) { + this.snapshotAccountId = this.account.getId(); + this.snapshotAccountName = this.account.getAccountName(); + this.snapshotAccountRole = this.account.getRole(); + this.accountArchived = false; + } + if (this.targetAccount != null) { + this.snapshotTargetAccountId = this.targetAccount.getId(); + this.snapshotTargetAccountName = this.targetAccount.getAccountName(); + this.snapshotTargetAccountRole = this.targetAccount.getRole(); + this.targetAccountArchived = false; + } else { + this.snapshotTargetAccountId = null; + this.snapshotTargetAccountName = null; + this.snapshotTargetAccountRole = null; + this.targetAccountArchived = false; + } + } + + public void syncCategorySnapshot() { + if (this.transactionCategory != null) { + this.snapshotCategoryId = this.transactionCategory.getId(); + this.snapshotCategoryName = this.transactionCategory.getName(); + this.categoryArchived = false; + } + } + + public BigDecimal getBalanceChangeAmount() { + return (this.type == TransactionType.EXPENSE || this.type == TransactionType.TRANSFER) + ? this.amount.negate() + : this.amount; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/TransactionCategory.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/TransactionCategory.java new file mode 100644 index 0000000..48ec0d4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/entity/TransactionCategory.java @@ -0,0 +1,66 @@ +package com.chaewookim.accountbookformoms.domain.asset.entity; + +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "transaction_category", + indexes = @Index( + name = "idx_txcategory_user_name_type", + columnList = "user_id, name, type")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE transaction_category SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class TransactionCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; + + @Builder + public TransactionCategory( + User user, + String name, + TransactionType type + ) { + this.user = user; + this.name = name; + this.type = type; + } + + public void update(String name, TransactionType transactionType) { + this.name = name; + this.type = transactionType; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountKind.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountKind.java new file mode 100644 index 0000000..139ac02 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountKind.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.asset.enums; + +public enum AccountKind { + ASSET, + CREDIT_CARD, + LOAN +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountRole.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountRole.java new file mode 100644 index 0000000..eb7ef47 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/AccountRole.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.asset.enums; + +public enum AccountRole { + + CHECKING, // 생활 · 입출금 + SAVINGS, // 저축 + INVESTMENT // 투자 +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/FixedTransactionExecutionFailure.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/FixedTransactionExecutionFailure.java new file mode 100644 index 0000000..e5649dd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/FixedTransactionExecutionFailure.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.asset.enums; + +public enum FixedTransactionExecutionFailure { + + INSUFFICIENT_BALANCE, + CREDIT_LIMIT_EXCEEDED +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionFrequency.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionFrequency.java new file mode 100644 index 0000000..64c7dfb --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionFrequency.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.asset.enums; + +public enum TransactionFrequency { + + WEEKLY, + MONTHLY, + YEARLY +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionType.java new file mode 100644 index 0000000..a01d90c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/enums/TransactionType.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.asset.enums; + +public enum TransactionType { + + INCOME, // 수입 + EXPENSE, // 지출 + TRANSFER // 이체 +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/error/AssetErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/error/AssetErrorCode.java new file mode 100644 index 0000000..1307f2b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/error/AssetErrorCode.java @@ -0,0 +1,59 @@ +package com.chaewookim.accountbookformoms.domain.asset.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AssetErrorCode implements BaseErrorCode { + + // Account + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없습니다."), + DUPLICATE_ACCOUNT_NAME(HttpStatus.CONFLICT, "이미 존재하는 계좌 이름입니다."), + INSUFFICIENT_BALANCE(HttpStatus.BAD_REQUEST, "잔액이 부족합니다."), + INVALID_ACCOUNT_BALANCE(HttpStatus.BAD_REQUEST, "자산 계좌의 잔액은 0원 이상이어야 합니다."), + INVALID_LIABILITY_BALANCE(HttpStatus.BAD_REQUEST, "신용카드와 대출 잔액은 0원을 초과할 수 없습니다."), + INVALID_CREDIT_LIMIT(HttpStatus.BAD_REQUEST, "신용 한도는 0원 이상이어야 합니다."), + CREDIT_LIMIT_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "신용 한도는 신용카드 계좌에만 설정할 수 있습니다."), + CREDIT_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "신용카드 한도를 초과했습니다."), + INVALID_LOAN_LIMIT(HttpStatus.BAD_REQUEST, "대출 약정 금액은 0원 이상이어야 합니다."), + INVALID_DISBURSED_AMOUNT(HttpStatus.BAD_REQUEST, "누적 대출 실행액은 0원 이상이며 약정 금액을 초과할 수 없습니다."), + INVALID_UNDISBURSED_LOAN_BALANCE(HttpStatus.BAD_REQUEST, "미실행 대출의 초기 잔액은 0원이어야 합니다."), + LOAN_FIELDS_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "대출 약정 정보는 대출 계좌에만 설정할 수 있습니다."), + LOAN_DEBT_EXCEEDS_DISBURSED_AMOUNT(HttpStatus.BAD_REQUEST, "현재 대출 원금은 누적 실행액을 초과할 수 없습니다."), + LOAN_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "대출 누적 실행액이 약정 금액을 초과합니다."), + INVALID_LOAN_REVERSAL(HttpStatus.BAD_REQUEST, "후속 상환 내역 때문에 대출 실행 거래를 되돌릴 수 없습니다."), + INVALID_GOAL_AMOUNT(HttpStatus.BAD_REQUEST, "목표 금액은 0보다 커야 합니다."), + INVALID_DEBT_GOAL_AMOUNT(HttpStatus.BAD_REQUEST, "대출 목표 잔액은 0원 이상이어야 합니다."), + GOAL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "신용카드는 목표를 설정할 수 없습니다."), + + // Transaction + TRANSACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "거래 내역을 찾을 수 없습니다."), + TRANSACTION_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 거래 내역만 접근할 수 있습니다."), + TRANSFER_TO_SELF_FORBIDDEN(HttpStatus.BAD_REQUEST, "자기 자신에게는 이체할 수 없습니다."), + TARGET_ACCOUNT_REQUIRED(HttpStatus.BAD_REQUEST, "이체 시 입금 대상 계좌는 필수입니다."), + INCOME_NOT_ALLOWED_FOR_LIABILITY(HttpStatus.BAD_REQUEST, "신용카드와 대출에는 수입을 직접 등록할 수 없습니다. 자산 계좌에서 이체해 주세요."), + NORMAL_TRANSACTION_NOT_ALLOWED_FOR_LOAN(HttpStatus.BAD_REQUEST, "대출 계좌에는 일반 수입·지출을 등록할 수 없습니다."), + INVALID_LOAN_TRANSFER(HttpStatus.BAD_REQUEST, "대출 실행은 대출 계좌에서 자산 계좌로, 원금 상환은 자산 계좌에서 대출 계좌로만 이체할 수 있습니다."), + INVALID_EXPORT_DATE_RANGE(HttpStatus.BAD_REQUEST, "내보내기 종료일은 시작일 이후여야 합니다."), + EXPORT_PERIOD_TOO_LONG(HttpStatus.BAD_REQUEST, "거래 내역 내보내기는 최대 12개월까지 가능합니다."), + + // FixedTransaction + INVALID_REPEAT_DAY(HttpStatus.BAD_REQUEST, "반복일 값이 올바르지 않습니다. (매주: 1~7, 매월/매년: 1~31)"), + INVALID_REPEAT_MONTH(HttpStatus.BAD_REQUEST, "반복 월은 1월부터 12월 사이여야 합니다."), + FIXED_TRANSACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 고정 내역을 찾을 수 없습니다."), + FIXED_TRANSACTION_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 고정 내역만 접근할 수 있습니다."), + FIXED_TRANSACTION_NOT_FAILED(HttpStatus.BAD_REQUEST, "실행 실패한 고정 거래만 처리할 수 있습니다."), + + // Category + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다."), + CATEGORY_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 카테고리만 수정/삭제 가능합니다."), + CATEGORY_IMMUTABLE(HttpStatus.BAD_REQUEST, "기본 카테고리는 수정하거나 삭제할 수 없습니다."), + CATEGORY_IN_USE(HttpStatus.BAD_REQUEST, "이 카테고리를 사용 중인 거래·고정내역이 있어 삭제할 수 없습니다."), + DUPLICATE_CATEGORY_NAME(HttpStatus.CONFLICT, "이미 존재하는 카테고리 이름입니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/FixedTransactionExecutionFailedEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/FixedTransactionExecutionFailedEvent.java new file mode 100644 index 0000000..ab01656 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/FixedTransactionExecutionFailedEvent.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.asset.event; + +import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; + +public record FixedTransactionExecutionFailedEvent( + Long userId, + Long fixedTransactionId, + String accountName, + String description, + FixedTransactionExecutionFailure failureReason +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/GoalAchievedCheckEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/GoalAchievedCheckEvent.java new file mode 100644 index 0000000..02d985d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/event/GoalAchievedCheckEvent.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.asset.event; + +public record GoalAchievedCheckEvent( + + Long userId, + Long accountId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionScheduler.java new file mode 100644 index 0000000..3c993d5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionScheduler.java @@ -0,0 +1,60 @@ +package com.chaewookim.accountbookformoms.domain.asset.scheduler; + +import com.chaewookim.accountbookformoms.domain.asset.application.FixedTransactionExecutor; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FixedTransactionScheduler { + + private final FixedTransactionRepository fixedTransactionRepository; + private final FixedTransactionExecutor fixedTransactionExecutor; + + @Scheduled(cron = "5 0 0 * * *") + @SchedulerLock(name = "FixedTransactionScheduler_processFixedTransactions", + lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") + @Transactional + public void processFixedTransactions() { + + LocalDate today = LocalDate.now(); + log.info("고정 거래 스케줄러 시작: {}", today); + List targetTransactions = new ArrayList<>( + fixedTransactionRepository.findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(today) + ); + Set seenIds = new HashSet<>(); + targetTransactions.forEach(ft -> seenIds.add(ft.getId())); + + for (FixedTransaction candidate : fixedTransactionRepository.findAllByIsActiveTrue()) { + if (seenIds.contains(candidate.getId())) { + continue; + } + if (candidate.isExecutionDay(today) && !today.equals(candidate.getLastExecutedDate())) { + candidate.alignNextExecutionDateIfStale(today); + targetTransactions.add(candidate); + seenIds.add(candidate.getId()); + } + } + + for (FixedTransaction fixedTransaction : targetTransactions) { + try { + fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + } catch (Exception e) { + log.error("고정 거래 생성 실패: ID={}, error={}", fixedTransaction.getId(), e.getMessage()); + } + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/asset/seed/TransactionCategorySeeder.java b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/seed/TransactionCategorySeeder.java new file mode 100644 index 0000000..04e6e27 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/asset/seed/TransactionCategorySeeder.java @@ -0,0 +1,69 @@ +package com.chaewookim.accountbookformoms.domain.asset.seed; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class TransactionCategorySeeder { + + private static final List EXPENSE_CATEGORIES = List.of( + "음식", "의류", "주거", "생활", "건강", "교통", "통신", "교육", "잔고 조정" + ); + + private static final List INCOME_CATEGORIES = List.of( + "급여", "사업", "투자", "용돈", "잔고 조정" + ); + + private static final List TRANSFER_CATEGORIES = List.of( + "적금", "비상금" + ); + + private static final List LEGACY_CATEGORY_NAMES = List.of("기타"); + + @Bean + public ApplicationRunner seedTransactionCategories(TransactionCategoryRepository repository) { + return args -> { + removeLegacyCategories(repository); + seed(repository, TransactionType.EXPENSE, EXPENSE_CATEGORIES); + seed(repository, TransactionType.INCOME, INCOME_CATEGORIES); + seed(repository, TransactionType.TRANSFER, TRANSFER_CATEGORIES); + }; + } + + private void removeLegacyCategories(TransactionCategoryRepository repository) { + for (String name : LEGACY_CATEGORY_NAMES) { + for (TransactionType type : TransactionType.values()) { + repository.findByUserIsNullAndNameAndType(name, type) + .ifPresent(category -> { + repository.delete(category); + log.info("Removed legacy TransactionCategory type={}, name={}", type, name); + }); + } + } + } + + private void seed(TransactionCategoryRepository repository, TransactionType type, List names) { + for (String name : names) { + if (repository.existsByUserIsNullAndNameAndType(name, type)) { + continue; + } + repository.save(TransactionCategory.builder() + .user(null) + .name(name) + .type(type) + .build()); + log.info("Seeded TransactionCategory type={}, name={}", type, name); + } + } + +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/AdminBoardController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/AdminBoardController.java new file mode 100644 index 0000000..215881d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/AdminBoardController.java @@ -0,0 +1,72 @@ +package com.chaewookim.accountbookformoms.domain.board.api; + +import com.chaewookim.accountbookformoms.domain.board.application.AdminBoardService; +import com.chaewookim.accountbookformoms.domain.board.application.BoardReindexService; +import com.chaewookim.accountbookformoms.domain.board.dto.response.AdminBoardResponse; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Board API") +@RestController +@RequestMapping("/api/v1/admin/boards") +@RequiredArgsConstructor +public class AdminBoardController { + + private final AdminBoardService adminBoardService; + private final BoardReindexService boardReindexService; + + @Operation(summary = "관리자 게시물 목록", + description = "게시물을 admin_deleted 포함 원문 그대로 반환합니다. " + + "includeDeleted=true 이면 유저가 소프트 삭제한 게시물도 포함.") + @PageableAsQueryParam + @GetMapping + public ResponseEntity>> list( + @RequestParam(required = false) BOARD_TYPE type, + @RequestParam(defaultValue = "false") boolean includeDeleted, + @Parameter(hidden = true) + @PageableDefault(size = 20) Pageable pageable + ) { + return ResponseEntity.ok(ApiResponse.success( + adminBoardService.list(type, includeDeleted, pageable))); + } + + @Operation(summary = "관리자 게시물 삭제", description = "관리자 권한으로 게시물을 삭제 표시합니다.") + @DeleteMapping("/{post-id}") + public ResponseEntity> delete( + @PathVariable("post-id") Long postId + ) { + return ResponseEntity.ok(ApiResponse.success(adminBoardService.deleteByAdmin(postId))); + } + + @Operation(summary = "관리자 게시물 일괄 삭제", description = "관리자 권한으로 여러 게시물을 일괄 삭제 표시합니다.") + @DeleteMapping("/bulk") + public ResponseEntity>> deleteBulk( + @org.springframework.web.bind.annotation.RequestBody java.util.List postIds + ) { + return ResponseEntity.ok(ApiResponse.success(adminBoardService.deleteBulkByAdmin(postIds))); + } + + @Operation(summary = "게시물 ES 전체 재색인", + description = "모든 게시물을 태그와 함께 Elasticsearch 에 다시 색인합니다. " + + "BoardDocument 스키마 변경(예: tags 추가) 이후 최초 1회 실행하세요.") + @PostMapping("/reindex") + public ResponseEntity> reindexAll() { + return ResponseEntity.ok(ApiResponse.success(boardReindexService.reindexAll())); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/BoardController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/BoardController.java new file mode 100644 index 0000000..c53ee78 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/api/BoardController.java @@ -0,0 +1,145 @@ +package com.chaewookim.accountbookformoms.domain.board.api; + +import com.chaewookim.accountbookformoms.domain.board.application.BoardService; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardCreateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardStatusRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardUpdateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardCreateResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardSearchResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardUpdateResponse; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +@Slf4j +@Tag(name = "Board API") +@RestController +@RequestMapping("/api/v1/boards") +@RequiredArgsConstructor +public class BoardController { + + private final BoardService boardService; + + @Operation(summary = "게시물 목록 조회", description = "QNA/KNOWHOW 게시판 게시물 목록 조회. type 파라미터 생략 시 전체 반환.") + @PageableAsQueryParam + @GetMapping + public ResponseEntity>> list( + @RequestParam(required = false) BOARD_TYPE type, + @RequestParam(required = false) String tag, + @RequestParam(required = false, defaultValue = "false") boolean mine, + @Parameter(hidden = true) + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long viewerId = principal == null ? null : principal.getUserId(); + Long authorId = (mine && principal != null) ? principal.getUserId() : null; + return ResponseEntity.ok(ApiResponse.success(boardService.list(type, tag, authorId, pageable, viewerId))); + } + + @Operation(summary = "게시물 추가", description = "일반 사용자의 게시물 생성") + @PostMapping + public ResponseEntity> create( + @RequestBody @Valid BoardCreateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(boardService.create(request, userId))); + } + + @Operation(summary = "게시물 조회", description = "게시물 단건 조회") + @GetMapping("/{post-id}") + public ResponseEntity> get( + @PathVariable("post-id") Long postId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long viewerId = principal == null ? null : principal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(boardService.get(postId, viewerId))); + } + + @Operation(summary = "게시물 수정", description = "일반 사용자의 게시물 수정") + @PatchMapping("/{post-id}") + public ResponseEntity> update( + @PathVariable("post-id") Long postId, + @RequestBody @Valid BoardUpdateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(boardService.update(postId, request, userId))); + } + + @Operation(summary = "Q&A 해결 상태 토글", description = "작성자가 Q&A 게시물의 해결 여부를 설정합니다.") + @PatchMapping("/{post-id}/resolved") + public ResponseEntity> setResolved( + @PathVariable("post-id") Long postId, + @RequestBody @Valid BoardStatusRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(ApiResponse.success( + boardService.setResolved(postId, request.value(), userPrincipal.getUserId()))); + } + + @Operation(summary = "Q&A 급함 표시 토글", description = "작성자가 Q&A 게시물의 급함 여부를 설정합니다.") + @PatchMapping("/{post-id}/urgent") + public ResponseEntity> setUrgent( + @PathVariable("post-id") Long postId, + @RequestBody @Valid BoardStatusRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(ApiResponse.success( + boardService.setUrgent(postId, request.value(), userPrincipal.getUserId()))); + } + + @Operation(summary = "게시물 삭제", description = "일반 사용자의 게시물 삭제 (Soft Delete)") + @DeleteMapping("/{post-id}") + public ResponseEntity> delete( + @PathVariable("post-id") Long postId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(boardService.delete(postId, userId))); + } + + @Operation(summary = "HOT 게시물", description = "지정 기간(최근 days일) 이내 게시물을 조회수+좋아요 기반으로 랭킹") + @GetMapping("/hot") + public ResponseEntity>> hot( + @RequestParam BOARD_TYPE type, + @RequestParam(defaultValue = "7") int days, + @RequestParam(defaultValue = "3") int limit + ) { + return ResponseEntity.ok(ApiResponse.success(boardService.hot(type, days, limit))); + } + + @Operation(summary = "게시물 검색", description = "Elasticsearch nori 기반 게시물 검색") + @PageableAsQueryParam + @GetMapping("/search") + public ResponseEntity>> search( + @RequestParam String keyword, + @Parameter(hidden = true) + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok(ApiResponse.success(boardService.search(keyword, pageable))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardService.java new file mode 100644 index 0000000..9bba930 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardService.java @@ -0,0 +1,77 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.AdminBoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dto.response.AdminBoardResponse; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminBoardService { + + private final BoardRepository boardRepository; + private final AdminBoardRepository adminBoardRepository; + private final ApplicationEventPublisher eventPublisher; + + public Long deleteByAdmin(Long postId) { + Board board = boardRepository.findById(postId) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + board.markAsAdminDeleted(); + eventPublisher.publishEvent(BoardChangedEvent.delete(board.getId())); + return board.getId(); + } + + public List deleteBulkByAdmin(List postIds) { + List boards = boardRepository.findAllById(postIds); + for (Board board : boards) { + board.markAsAdminDeleted(); + eventPublisher.publishEvent(BoardChangedEvent.delete(board.getId())); + } + return boards.stream().map(Board::getId).toList(); + } + + /** + * 관리자 게시물 목록 조회. + * + * @param type null 이면 전체 + * @param includeDeleted true 면 유저가 소프트 삭제한 게시물까지 포함 (native query) + * @param pageable Pageable (Sort 는 native 경로에서 무시되며 created_at DESC 고정) + */ + @Transactional(readOnly = true) + public Page list(BOARD_TYPE type, boolean includeDeleted, Pageable pageable) { + int size = pageable.getPageSize(); + int offset = (int) pageable.getOffset(); + Page page; + if (includeDeleted) { + if (type == null) { + List rows = adminBoardRepository.findAllIncludingDeleted(size, offset); + long total = adminBoardRepository.countIncludingDeleted(); + page = new PageImpl<>(rows, pageable, total); + } else { + List rows = adminBoardRepository + .findAllIncludingDeletedByType(type.name(), size, offset); + long total = adminBoardRepository.countIncludingDeletedByType(type.name()); + page = new PageImpl<>(rows, pageable, total); + } + } else { + page = (type == null) + ? boardRepository.findAll(pageable) + : boardRepository.findByType(type, pageable); + } + return page.map(AdminBoardResponse::from); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListener.java new file mode 100644 index 0000000..386e805 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListener.java @@ -0,0 +1,40 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchRepository; +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BoardIndexEventListener { + + private final BoardRepository boardRepository; + private final BoardSearchRepository boardSearchRepository; + private final TagService tagService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(BoardChangedEvent event) { + try { + switch (event.type()) { + case UPSERT -> boardRepository.findById(event.boardId()) + .ifPresent(board -> { + var tags = tagService.tagsOfBoard(board.getId()); + boardSearchRepository.save(BoardDocument.from(board, tags)); + }); + case DELETE -> boardSearchRepository.deleteById(event.boardId()); + } + } catch (Exception e) { + log.error("Board ES indexing failed: boardId={}, type={}", event.boardId(), event.type(), e); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexRunner.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexRunner.java new file mode 100644 index 0000000..21cc0b9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexRunner.java @@ -0,0 +1,28 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 앱 부팅 시 자동 재색인. + *

{@code app.board.reindex-on-startup=true} 일 때만 실행. 로컬/배포 직후 스키마 변경 대응용.

+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.board.reindex-on-startup", havingValue = "true") +public class BoardReindexRunner { + + @Bean + public ApplicationRunner boardReindexOnStartup(BoardReindexService reindexService) { + return args -> { + log.info("app.board.reindex-on-startup=true → Board ES 전체 재색인 시작"); + long count = reindexService.reindexAll(); + log.info("Board ES 부팅 시 재색인 완료: {} 문서", count); + }; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexService.java new file mode 100644 index 0000000..157b368 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardReindexService.java @@ -0,0 +1,64 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchRepository; +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 게시물 → Elasticsearch 초기(또는 태그 스키마 변경 후) 재색인 배치. + *

페이지 단위로 Board 를 로딩해 태그를 함께 벌크 색인. 관리자 API 및 부팅 옵션에서 호출.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BoardReindexService { + + private static final int PAGE_SIZE = 200; + + private final BoardRepository boardRepository; + private final BoardSearchRepository boardSearchRepository; + private final TagService tagService; + + @Transactional(readOnly = true) + public long reindexAll() { + long start = System.currentTimeMillis(); + long indexed = 0; + int pageNumber = 0; + while (true) { + Page page = boardRepository.findAll( + PageRequest.of(pageNumber, PAGE_SIZE, Sort.by(Sort.Direction.ASC, "id"))); + List boards = page.getContent(); + if (boards.isEmpty()) break; + + List ids = boards.stream().map(Board::getId).toList(); + Map> tagsByBoard = tagService.tagsByBoards(ids); + + List docs = new ArrayList<>(boards.size()); + for (Board b : boards) { + docs.add(BoardDocument.from(b, tagsByBoard.getOrDefault(b.getId(), List.of()))); + } + boardSearchRepository.saveAll(docs); + indexed += docs.size(); + + if (!page.hasNext()) break; + pageNumber++; + } + + log.info("Board ES 초기 재색인 완료: {} 문서 / {}ms", + indexed, System.currentTimeMillis() - start); + return indexed; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardService.java new file mode 100644 index 0000000..7699cd2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardService.java @@ -0,0 +1,244 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchQueryRepository; +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardCreateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardUpdateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardCreateResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardHotResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardSearchResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardUpdateResponse; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.bookmark.application.BookmarkService; +import com.chaewookim.accountbookformoms.domain.image.application.ImageService; +import com.chaewookim.accountbookformoms.domain.like.application.PostLikeService; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BoardService { + + private final BoardRepository boardRepository; + private final BoardSearchQueryRepository boardSearchQueryRepository; + private final BoardViewCountService viewCountService; + private final UserRepository userRepository; + private final PostLikeService likeService; + private final BookmarkService bookmarkService; + private final TagService tagService; + private final ImageService imageService; + private final ApplicationEventPublisher eventPublisher; + + public Page list(BOARD_TYPE type, String tag, Pageable pageable, Long viewerId) { + return list(type, tag, null, pageable, viewerId); + } + + public Page list(BOARD_TYPE type, String tag, Long authorId, Pageable pageable, Long viewerId) { + Page page; + if (tag != null && !tag.isBlank()) { + List ids = tagService.boardIdsWithTag(tag); + if (ids.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + List boards = boardRepository.findAllById(ids); + if (type != null) { + boards = boards.stream().filter(b -> b.getType() == type).toList(); + } + if (authorId != null) { + boards = boards.stream().filter(b -> authorId.equals(b.getUserId())).toList(); + } + page = new PageImpl<>(boards, pageable, boards.size()); + } else if (authorId != null) { + page = (type == null) + ? boardRepository.findByUserId(authorId, pageable) + : boardRepository.findByUserIdAndType(authorId, type, pageable); + } else { + page = (type == null) + ? boardRepository.findAll(pageable) + : boardRepository.findByType(type, pageable); + } + return enrichPage(page, viewerId); + } + + public List listByIds(List ids, Long viewerId) { + if (ids == null || ids.isEmpty()) return List.of(); + List boards = boardRepository.findAllById(ids); + Map byId = boards.stream() + .collect(Collectors.toMap(Board::getId, b -> b, (a, b) -> a)); + List orderedIds = ids.stream().filter(byId::containsKey).toList(); + List ordered = orderedIds.stream().map(byId::get).toList(); + Map pending = viewCountService.getPendingDeltas(orderedIds); + Map nicknames = loadNicknames(ordered.stream().map(Board::getUserId).toList()); + Map likeCounts = likeService.countByTargets(LikeTargetType.BOARD, orderedIds); + Set liked = likeService.likedTargets(LikeTargetType.BOARD, orderedIds, viewerId); + Set bookmarked = bookmarkService.bookmarkedBoards(orderedIds, viewerId); + Map> tagsByBoard = tagService.tagsByBoards(orderedIds); + Map> imagesByBoard = imageService.urlsByBoards(orderedIds); + return ordered.stream() + .map(b -> BoardResponse.from( + b, + pending.getOrDefault(b.getId(), 0L), + nicknames.get(b.getUserId()), + likeCounts.getOrDefault(b.getId(), 0L), + liked.contains(b.getId()), + bookmarked.contains(b.getId()), + tagsByBoard.getOrDefault(b.getId(), List.of()), + imagesByBoard.getOrDefault(b.getId(), List.of()))) + .toList(); + } + + private Page enrichPage(Page page, Long viewerId) { + List boards = page.getContent(); + List ids = boards.stream().map(Board::getId).toList(); + Map pending = viewCountService.getPendingDeltas(ids); + Map nicknames = loadNicknames(boards.stream().map(Board::getUserId).toList()); + Map likeCounts = likeService.countByTargets(LikeTargetType.BOARD, ids); + Set liked = likeService.likedTargets(LikeTargetType.BOARD, ids, viewerId); + Set bookmarked = bookmarkService.bookmarkedBoards(ids, viewerId); + Map> tagsByBoard = tagService.tagsByBoards(ids); + Map> imagesByBoard = imageService.urlsByBoards(ids); + return page.map(b -> BoardResponse.from( + b, + pending.getOrDefault(b.getId(), 0L), + nicknames.get(b.getUserId()), + likeCounts.getOrDefault(b.getId(), 0L), + liked.contains(b.getId()), + bookmarked.contains(b.getId()), + tagsByBoard.getOrDefault(b.getId(), List.of()), + imagesByBoard.getOrDefault(b.getId(), List.of()))); + } + + @Transactional + public BoardCreateResponse create(BoardCreateRequest request, Long userId) { + Board board = Board.builder() + .userId(userId) + .categoryId(request.categoryId()) + .title(request.title()) + .content(request.content()) + .type(request.type()) + .build(); + + Board saved = boardRepository.save(board); + tagService.setTagsForBoard(saved.getId(), request.tags()); + eventPublisher.publishEvent(BoardChangedEvent.upsert(saved.getId())); + return new BoardCreateResponse(saved.getId(), saved.getTitle()); + } + + public BoardResponse get(Long postId, Long viewerId) { + Board board = findBoardOrThrow(postId); + long pending = viewCountService.increment(postId); + String nickname = userRepository.findById(board.getUserId()) + .map(User::getUsername) + .orElse(null); + long likeCount = likeService.count(LikeTargetType.BOARD, postId); + boolean liked = likeService.isLiked(LikeTargetType.BOARD, postId, viewerId); + boolean bookmarked = bookmarkService.isBookmarked(postId, viewerId); + List tags = tagService.tagsOfBoard(postId); + List imageUrls = imageService.urlsOfBoard(postId); + return BoardResponse.from(board, pending, nickname, likeCount, liked, bookmarked, tags, imageUrls); + } + + @Transactional + public BoardUpdateResponse update(Long postId, BoardUpdateRequest request, Long userId) { + Board board = findBoardOrThrow(postId); + validateOwner(board, userId); + + board.update(request.title(), request.content(), request.type()); + if (request.tags() != null) { + tagService.setTagsForBoard(postId, request.tags()); + } + eventPublisher.publishEvent(BoardChangedEvent.upsert(board.getId())); + return new BoardUpdateResponse(board.getId(), board.getTitle()); + } + + @Transactional + public Long delete(Long postId, Long userId) { + Board board = findBoardOrThrow(postId); + validateOwner(board, userId); + + boardRepository.delete(board); + likeService.evictCount(LikeTargetType.BOARD, board.getId()); + viewCountService.evict(board.getId()); + eventPublisher.publishEvent(BoardChangedEvent.delete(board.getId())); + return board.getId(); + } + + @Transactional + public boolean setResolved(Long postId, boolean value, Long userId) { + Board board = findBoardOrThrow(postId); + validateOwner(board, userId); + board.setResolved(value); + return board.isResolved(); + } + + @Transactional + public boolean setUrgent(Long postId, boolean value, Long userId) { + Board board = findBoardOrThrow(postId); + validateOwner(board, userId); + board.setUrgent(value); + return board.isUrgent(); + } + + // TTL 5분(RedisConfig.CACHE_BOARD_HOT). 좋아요/조회수 변화는 5분 내에서 stale 허용. + // 캐시 미스 시에만 전체 최근 게시물을 로드 + like count 조회 + in-memory 정렬 수행. + @Cacheable(cacheNames = RedisConfig.CACHE_BOARD_HOT, + key = "T(java.util.Objects).toString(#type) + ':' + #days + ':' + #limit") + public List hot(BOARD_TYPE type, int days, int limit) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + List recent = boardRepository.findRecentByType(type, since); + List ids = recent.stream().map(Board::getId).toList(); + Map likeCounts = likeService.countByTargets(LikeTargetType.BOARD, ids); + return new ArrayList<>(recent.stream() + .map(b -> BoardHotResponse.from(b, likeCounts.getOrDefault(b.getId(), 0L))) + .sorted((a, b) -> Long.compare(b.score(), a.score())) + .limit(limit) + .toList()); + } + + public Page search(String keyword, Pageable pageable) { + Page hits = boardSearchQueryRepository.search(keyword, pageable); + return hits.map(BoardSearchResponse::from); + } + + private Map loadNicknames(List userIds) { + if (userIds.isEmpty()) return Map.of(); + return userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getUsername, (a, b) -> a)); + } + + private Board findBoardOrThrow(Long postId) { + return boardRepository.findById(postId) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + } + + private void validateOwner(Board board, Long userId) { + if (!board.getUserId().equals(userId)) { + throw new CustomException(BoardErrorCode.BOARD_ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountService.java new file mode 100644 index 0000000..de2b8f2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountService.java @@ -0,0 +1,67 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BoardViewCountService { + + private static final String VIEW_KEY_PREFIX = "board:views:"; + private static final String DIRTY_SET_KEY = "board:views:dirty"; + + private final RedisTemplate redisTemplate; + + public long increment(Long boardId) { + Long delta = redisTemplate.opsForValue().increment(viewKey(boardId)); + redisTemplate.opsForSet().add(DIRTY_SET_KEY, String.valueOf(boardId)); + return delta == null ? 0L : delta; + } + + public Map getPendingDeltas(Collection boardIds) { + if (boardIds == null || boardIds.isEmpty()) return Map.of(); + List keys = boardIds.stream().map(this::viewKey).toList(); + List values = redisTemplate.opsForValue().multiGet(keys); + Map result = new HashMap<>(); + int i = 0; + for (Long id : boardIds) { + String v = (values != null && i < values.size()) ? values.get(i) : null; + result.put(id, v == null ? 0L : Long.parseLong(v)); + i++; + } + return result; + } + + public Set snapshotDirtyBoardIds() { + Set raw = redisTemplate.opsForSet().members(DIRTY_SET_KEY); + if (raw == null || raw.isEmpty()) return Collections.emptySet(); + return raw.stream().map(Long::parseLong).collect(Collectors.toSet()); + } + + // SREM 먼저 → GETSET 0 순서로 race 방지: SREM 이후 들어온 INCR 은 set 에 자기 자신을 + // 재등록하므로 다음 사이클에서 처리됨. delta 누락 없음. + public long readAndReset(Long boardId) { + redisTemplate.opsForSet().remove(DIRTY_SET_KEY, String.valueOf(boardId)); + String previous = redisTemplate.opsForValue().getAndSet(viewKey(boardId), "0"); + return previous == null ? 0L : Long.parseLong(previous); + } + + /** 게시물 삭제 시 dirty set 과 카운터 키를 함께 정리. */ + public void evict(Long boardId) { + redisTemplate.opsForSet().remove(DIRTY_SET_KEY, String.valueOf(boardId)); + redisTemplate.delete(viewKey(boardId)); + } + + private String viewKey(Long boardId) { + return VIEW_KEY_PREFIX + boardId; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/AdminBoardRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/AdminBoardRepository.java new file mode 100644 index 0000000..5f05f9e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/AdminBoardRepository.java @@ -0,0 +1,35 @@ +package com.chaewookim.accountbookformoms.domain.board.dao; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 관리자 조회 전용 — {@link Board} 의 {@code @SQLRestriction("deleted_at IS NULL")} 를 우회하기 위해 + * 네이티브 쿼리로 소프트 삭제(user-deleted) 행까지 함께 반환한다. + *

Pageable 의 Sort 는 네이티브 쿼리와 정합성 이슈가 있어 정렬은 하드코딩(created_at DESC)한다.

+ */ +@Repository +public interface AdminBoardRepository extends JpaRepository { + + @Query(value = "SELECT * FROM board ORDER BY created_at DESC LIMIT :size OFFSET :offset", + nativeQuery = true) + List findAllIncludingDeleted(@Param("size") int size, @Param("offset") int offset); + + @Query(value = "SELECT COUNT(*) FROM board", nativeQuery = true) + long countIncludingDeleted(); + + @Query(value = "SELECT * FROM board WHERE type = :type " + + "ORDER BY created_at DESC LIMIT :size OFFSET :offset", + nativeQuery = true) + List findAllIncludingDeletedByType(@Param("type") String type, + @Param("size") int size, + @Param("offset") int offset); + + @Query(value = "SELECT COUNT(*) FROM board WHERE type = :type", nativeQuery = true) + long countIncludingDeletedByType(@Param("type") String type); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardRepository.java new file mode 100644 index 0000000..6d219b3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardRepository.java @@ -0,0 +1,32 @@ +package com.chaewookim.accountbookformoms.domain.board.dao; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BoardRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Board b SET b.views = b.views + :delta WHERE b.id = :id") + int increaseViews(@Param("id") Long id, @Param("delta") long delta); + + Page findByType(BOARD_TYPE type, Pageable pageable); + + Page findByUserId(Long userId, Pageable pageable); + + Page findByUserIdAndType(Long userId, BOARD_TYPE type, Pageable pageable); + + long countByUserId(Long userId); + + @Query("SELECT b FROM Board b WHERE b.type = :type AND b.createdAt >= :since") + List findRecentByType(@Param("type") BOARD_TYPE type, @Param("since") java.time.LocalDateTime since); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepository.java new file mode 100644 index 0000000..4d83166 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepository.java @@ -0,0 +1,39 @@ +package com.chaewookim.accountbookformoms.domain.board.dao; + +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class BoardSearchQueryRepository { + + private final ElasticsearchOperations elasticsearchOperations; + + public Page search(String keyword, Pageable pageable) { + NativeQuery query = NativeQuery.builder() + .withQuery(q -> q.bool(b -> b + .should(s -> s.multiMatch(mm -> mm + .query(keyword) + .fields("title^2", "content"))) + .should(s -> s.term(t -> t + .field("tags") + .value(keyword))) + .minimumShouldMatch("1"))) + .withPageable(pageable) + .build(); + + SearchHits hits = elasticsearchOperations.search(query, BoardDocument.class); + List content = hits.stream().map(SearchHit::getContent).toList(); + return new PageImpl<>(content, pageable, hits.getTotalHits()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchRepository.java new file mode 100644 index 0000000..5921a67 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchRepository.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.board.dao; + +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +public interface BoardSearchRepository extends ElasticsearchRepository { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocument.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocument.java new file mode 100644 index 0000000..67f3369 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocument.java @@ -0,0 +1,65 @@ +package com.chaewookim.accountbookformoms.domain.board.document; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.DateFormat; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Document(indexName = "board") +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardDocument { + + @Id + private Long id; + + @Field(type = FieldType.Text, analyzer = "nori") + private String title; + + @Field(type = FieldType.Text, analyzer = "nori") + private String content; + + @Field(type = FieldType.Keyword) + private String type; + + @Field(type = FieldType.Long) + private Long userId; + + @Field(type = FieldType.Long) + private Long categoryId; + + @Field(type = FieldType.Keyword) + private List tags; + + @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis) + private LocalDateTime createdAt; + + public static BoardDocument from(Board board) { + return from(board, List.of()); + } + + public static BoardDocument from(Board board, List tags) { + return BoardDocument.builder() + .id(board.getId()) + .title(board.getTitle()) + .content(board.getContent()) + .type(board.getType().name()) + .userId(board.getUserId()) + .categoryId(board.getCategoryId()) + .tags(tags != null ? tags : List.of()) + .createdAt(board.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardCreateRequest.java new file mode 100644 index 0000000..dbb30d0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardCreateRequest.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.request; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record BoardCreateRequest( + @NotNull Long categoryId, + @NotBlank @Size(max = 255) String title, + @NotBlank String content, + @NotNull BOARD_TYPE type, + List tags +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardStatusRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardStatusRequest.java new file mode 100644 index 0000000..9157f9f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardStatusRequest.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record BoardStatusRequest( + @NotNull Boolean value +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardUpdateRequest.java new file mode 100644 index 0000000..28d3590 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/request/BoardUpdateRequest.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.request; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record BoardUpdateRequest( + @NotBlank @Size(max = 255) String title, + @NotBlank String content, + @NotNull BOARD_TYPE type, + List tags +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/AdminBoardResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/AdminBoardResponse.java new file mode 100644 index 0000000..bf4c517 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/AdminBoardResponse.java @@ -0,0 +1,40 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; + +import java.time.LocalDateTime; + +/** + * 관리자 조회용 게시물 응답. 원문(제목/내용)을 마스킹 없이 그대로 반환하고 + * 관리자 삭제/유저 소프트 삭제 여부를 별도 플래그로 노출. + */ +public record AdminBoardResponse( + Long id, + Long userId, + Long categoryId, + String title, + String content, + BOARD_TYPE type, + int views, + boolean adminDeleted, + boolean userDeleted, + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + public static AdminBoardResponse from(Board board) { + return new AdminBoardResponse( + board.getId(), + board.getUserId(), + board.getCategoryId(), + board.getTitle(), + board.getContent(), + board.getType(), + board.getViews(), + board.isAdminDeleted(), + board.getDeletedAt() != null, + board.getCreatedAt(), + board.getDeletedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardCreateResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardCreateResponse.java new file mode 100644 index 0000000..50127d9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardCreateResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +public record BoardCreateResponse( + Long id, + String title +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardHotResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardHotResponse.java new file mode 100644 index 0000000..c4bdf65 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardHotResponse.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; + +import java.time.LocalDateTime; + +public record BoardHotResponse( + Long id, + String title, + BOARD_TYPE type, + int views, + long likeCount, + long score, + LocalDateTime createdAt +) { + public static BoardHotResponse from(Board b, long likeCount) { + return new BoardHotResponse( + b.getId(), b.getTitle(), b.getType(), b.getViews(), + likeCount, (long) b.getViews() + likeCount * 3L, b.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardResponse.java new file mode 100644 index 0000000..d94c9f2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardResponse.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; + +import java.time.LocalDateTime; +import java.util.List; + +public record BoardResponse( + Long id, + Long userId, + String authorNickname, + Long categoryId, + String title, + String content, + BOARD_TYPE type, + int views, + boolean resolved, + boolean urgent, + long likeCount, + boolean liked, + boolean bookmarked, + List tags, + List imageUrls, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + private static final String ADMIN_DELETED_MESSAGE = "관리자가 삭제한 게시물입니다."; + private static final String UNKNOWN_AUTHOR = "탈퇴한 사용자"; + + public static BoardResponse from(Board board) { + return from(board, 0L, null, 0L, false, false, List.of(), List.of()); + } + + public static BoardResponse from(Board board, long pendingViews) { + return from(board, pendingViews, null, 0L, false, false, List.of(), List.of()); + } + + public static BoardResponse from(Board board, long pendingViews, String authorNickname) { + return from(board, pendingViews, authorNickname, 0L, false, false, List.of(), List.of()); + } + + public static BoardResponse from( + Board board, + long pendingViews, + String authorNickname, + long likeCount, + boolean liked, + boolean bookmarked + ) { + return from(board, pendingViews, authorNickname, likeCount, liked, bookmarked, List.of(), List.of()); + } + + public static BoardResponse from( + Board board, + long pendingViews, + String authorNickname, + long likeCount, + boolean liked, + boolean bookmarked, + List tags, + List imageUrls + ) { + boolean adminDeleted = board.isAdminDeleted(); + return new BoardResponse( + board.getId(), + board.getUserId(), + authorNickname != null ? authorNickname : UNKNOWN_AUTHOR, + board.getCategoryId(), + adminDeleted ? ADMIN_DELETED_MESSAGE : board.getTitle(), + adminDeleted ? ADMIN_DELETED_MESSAGE : board.getContent(), + board.getType(), + board.getViews() + (int) pendingViews, + board.isResolved(), + board.isUrgent(), + likeCount, + liked, + bookmarked, + tags, + imageUrls, + board.getCreatedAt(), + board.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardSearchResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardSearchResponse.java new file mode 100644 index 0000000..5e232ca --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardSearchResponse.java @@ -0,0 +1,30 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; + +import java.time.LocalDateTime; +import java.util.List; + +public record BoardSearchResponse( + Long id, + Long userId, + Long categoryId, + String title, + String content, + String type, + List tags, + LocalDateTime createdAt +) { + public static BoardSearchResponse from(BoardDocument doc) { + return new BoardSearchResponse( + doc.getId(), + doc.getUserId(), + doc.getCategoryId(), + doc.getTitle(), + doc.getContent(), + doc.getType(), + doc.getTags() != null ? doc.getTags() : List.of(), + doc.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardUpdateResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardUpdateResponse.java new file mode 100644 index 0000000..39b2549 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/dto/response/BoardUpdateResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.board.dto.response; + +public record BoardUpdateResponse( + Long id, + String title +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/entity/Board.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/entity/Board.java new file mode 100644 index 0000000..a4394ee --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/entity/Board.java @@ -0,0 +1,100 @@ +package com.chaewookim.accountbookformoms.domain.board.entity; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "board", + indexes = { + @Index(name = "idx_board_created_at", columnList = "created_at"), + @Index(name = "idx_board_type", columnList = "type"), + @Index(name = "idx_board_user_type", columnList = "user_id, type") + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE board SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Board extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(nullable = false, length = 255) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private BOARD_TYPE type; + + @Column(nullable = false) + private int views; + + @Column(name = "admin_deleted", nullable = false) + private boolean adminDeleted; + + @Column(name = "is_resolved", nullable = false) + private boolean resolved; + + @Column(name = "is_urgent", nullable = false) + private boolean urgent; + + @Builder + public Board(Long userId, Long categoryId, String title, String content, BOARD_TYPE type) { + this.userId = userId; + this.categoryId = categoryId; + this.title = title; + this.content = content; + this.type = type; + this.views = 0; + this.adminDeleted = false; + this.resolved = false; + this.urgent = false; + } + + public void update(String title, String content, BOARD_TYPE type) { + this.title = title; + this.content = content; + this.type = type; + } + + public void increaseViews(int delta) { + this.views += delta; + } + + public void markAsAdminDeleted() { + this.adminDeleted = true; + } + + public void setResolved(boolean resolved) { + this.resolved = resolved; + } + + public void setUrgent(boolean urgent) { + this.urgent = urgent; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/enums/BOARD_TYPE.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/enums/BOARD_TYPE.java new file mode 100644 index 0000000..d7a8654 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/enums/BOARD_TYPE.java @@ -0,0 +1,6 @@ +package com.chaewookim.accountbookformoms.domain.board.enums; + +public enum BOARD_TYPE { + QNA, + KNOWHOW +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/error/BoardErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/error/BoardErrorCode.java new file mode 100644 index 0000000..92f21d4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/error/BoardErrorCode.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.board.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BoardErrorCode implements BaseErrorCode { + + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시물을 찾을 수 없습니다."), + BOARD_ACCESS_DENIED(HttpStatus.FORBIDDEN, "본인의 게시물만 수정/삭제할 수 있습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardHotWarmupScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardHotWarmupScheduler.java new file mode 100644 index 0000000..18a7da2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardHotWarmupScheduler.java @@ -0,0 +1,47 @@ +package com.chaewookim.accountbookformoms.domain.board.scheduler; + +import com.chaewookim.accountbookformoms.domain.board.application.BoardService; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Board HOT 랭킹 캐시 warm-up. + *

프론트가 가장 자주 호출하는 (type, days, limit) 조합을 미리 계산해 Redis 에 넣어둔다. + * TTL 5분(RedisConfig.CACHE_BOARD_HOT)과 정합되도록 4분 주기 실행.

+ *

ShedLock 으로 다중 인스턴스 중복 실행 차단.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.cache.warmup.board-hot.enabled", + havingValue = "true", matchIfMissing = true) +public class BoardHotWarmupScheduler { + + private static final int WARMUP_DAYS = 7; + private static final int WARMUP_LIMIT = 3; + + private final BoardService boardService; + + @Scheduled(cron = "0 */4 * * * *") + @SchedulerLock(name = "BoardHotWarmupScheduler_warmup", + lockAtMostFor = "PT3M", lockAtLeastFor = "PT30S") + public void warmup() { + long start = System.currentTimeMillis(); + int warmed = 0; + for (BOARD_TYPE type : BOARD_TYPE.values()) { + try { + boardService.hot(type, WARMUP_DAYS, WARMUP_LIMIT); + warmed++; + } catch (Exception e) { + log.warn("board:hot warm-up 실패: type={}, error={}", type, e.getMessage()); + } + } + log.info("board:hot warm-up 완료: {} 항목 / {}ms", + warmed, System.currentTimeMillis() - start); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncScheduler.java new file mode 100644 index 0000000..4592d8f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncScheduler.java @@ -0,0 +1,49 @@ +package com.chaewookim.accountbookformoms.domain.board.scheduler; + +import com.chaewookim.accountbookformoms.domain.board.application.BoardViewCountService; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BoardViewSyncScheduler { + + private final BoardViewCountService viewCountService; + private final BoardRepository boardRepository; + + @Scheduled(cron = "0 */5 * * * *") + @SchedulerLock(name = "BoardViewSyncScheduler_sync", + lockAtMostFor = "PT5M", lockAtLeastFor = "PT30S") + @Transactional + public void sync() { + Set ids = viewCountService.snapshotDirtyBoardIds(); + if (ids.isEmpty()) return; + + long start = System.currentTimeMillis(); + int applied = 0; + long totalDelta = 0; + + for (Long id : ids) { + long delta = viewCountService.readAndReset(id); + if (delta <= 0) continue; + int updated = boardRepository.increaseViews(id, delta); + if (updated == 0) { + log.warn("board views sync: board {} not found (delta {} lost)", id, delta); + } else { + applied++; + totalDelta += delta; + } + } + + log.info("board views sync 완료: {} 보드 / {} views / {}ms", + applied, totalDelta, System.currentTimeMillis() - start); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/api/BoardCategoryController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/api/BoardCategoryController.java new file mode 100644 index 0000000..8240986 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/api/BoardCategoryController.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.api; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.application.BoardCategoryService; +import com.chaewookim.accountbookformoms.domain.boardcategory.dto.response.BoardCategoryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Board Category API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/board-categories") +public class BoardCategoryController { + + private final BoardCategoryService service; + + @Operation(summary = "게시판 카테고리 목록 조회") + @GetMapping + public ResponseEntity>> list( + @RequestParam(required = false) BOARD_TYPE type + ) { + return ResponseEntity.ok(ApiResponse.success(service.list(type))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryService.java new file mode 100644 index 0000000..8c20397 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryService.java @@ -0,0 +1,25 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.application; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.dao.BoardCategoryRepository; +import com.chaewookim.accountbookformoms.domain.boardcategory.dto.response.BoardCategoryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BoardCategoryService { + + private final BoardCategoryRepository repository; + + public List list(BOARD_TYPE boardType) { + List found = boardType == null + ? repository.findAll() + : repository.findByBoardTypeOrderByDisplayOrderAsc(boardType); + return found.stream().map(BoardCategoryResponse::from).toList(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dao/BoardCategoryRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dao/BoardCategoryRepository.java new file mode 100644 index 0000000..4feff6e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dao/BoardCategoryRepository.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.dao; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.entity.BoardCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BoardCategoryRepository extends JpaRepository { + + List findByBoardTypeOrderByDisplayOrderAsc(BOARD_TYPE boardType); + + boolean existsByBoardTypeAndName(BOARD_TYPE boardType, String name); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dto/response/BoardCategoryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dto/response/BoardCategoryResponse.java new file mode 100644 index 0000000..f2a66e4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/dto/response/BoardCategoryResponse.java @@ -0,0 +1,20 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.dto.response; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.entity.BoardCategory; + +public record BoardCategoryResponse( + Long id, + String name, + BOARD_TYPE boardType, + int displayOrder +) { + public static BoardCategoryResponse from(BoardCategory entity) { + return new BoardCategoryResponse( + entity.getId(), + entity.getName(), + entity.getBoardType(), + entity.getDisplayOrder() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/entity/BoardCategory.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/entity/BoardCategory.java new file mode 100644 index 0000000..c150ffb --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/entity/BoardCategory.java @@ -0,0 +1,48 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.entity; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "board_category", + indexes = @Index(name = "idx_board_category_board_type", columnList = "board_type") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "board_type", nullable = false, length = 20) + private BOARD_TYPE boardType; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + public BoardCategory(String name, BOARD_TYPE boardType, int displayOrder) { + this.name = name; + this.boardType = boardType; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/seed/BoardCategorySeeder.java b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/seed/BoardCategorySeeder.java new file mode 100644 index 0000000..f1215f4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/boardcategory/seed/BoardCategorySeeder.java @@ -0,0 +1,47 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.seed; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.dao.BoardCategoryRepository; +import com.chaewookim.accountbookformoms.domain.boardcategory.entity.BoardCategory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class BoardCategorySeeder { + + private static final List QNA_CATEGORIES = List.of( + "재테크", "가계부", "육아 비용", "절약", "공동구매", "살림" + ); + + private static final List KNOWHOW_CATEGORIES = List.of( + "절약 노하우", "재테크", "가계부", "육아 꿀팁", "공동구매", "살림 팁" + ); + + @Bean + public ApplicationRunner seedBoardCategories(BoardCategoryRepository repository) { + return args -> { + seed(repository, BOARD_TYPE.QNA, QNA_CATEGORIES); + seed(repository, BOARD_TYPE.KNOWHOW, KNOWHOW_CATEGORIES); + }; + } + + private void seed(BoardCategoryRepository repo, BOARD_TYPE type, List names) { + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + if (repo.existsByBoardTypeAndName(type, name)) continue; + repo.save(BoardCategory.builder() + .name(name) + .boardType(type) + .displayOrder(i) + .build()); + log.info("Seeded BoardCategory type={}, name={}", type, name); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/api/BookmarkController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/api/BookmarkController.java new file mode 100644 index 0000000..a3f502f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/api/BookmarkController.java @@ -0,0 +1,50 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.api; + +import com.chaewookim.accountbookformoms.domain.board.application.BoardService; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardResponse; +import com.chaewookim.accountbookformoms.domain.bookmark.application.BookmarkService; +import com.chaewookim.accountbookformoms.domain.bookmark.dto.response.BookmarkToggleResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 java.util.List; + +@Tag(name = "Bookmark API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class BookmarkController { + + private final BookmarkService bookmarkService; + private final BoardService boardService; + + @Operation(summary = "게시물 북마크 토글") + @PostMapping("/boards/{postId}/bookmark") + public ResponseEntity> toggle( + @PathVariable Long postId, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + bookmarkService.toggle(postId, principal.getUserId()))); + } + + @Operation(summary = "내가 북마크한 게시물 목록") + @GetMapping("/users/me/bookmarks") + public ResponseEntity>> myBookmarks( + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = principal.getUserId(); + List boardIds = bookmarkService.listBoardIdsByUser(userId); + return ResponseEntity.ok(ApiResponse.success(boardService.listByIds(boardIds, userId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkService.java new file mode 100644 index 0000000..854f672 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkService.java @@ -0,0 +1,59 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.bookmark.dao.BookmarkRepository; +import com.chaewookim.accountbookformoms.domain.bookmark.dto.response.BookmarkToggleResponse; +import com.chaewookim.accountbookformoms.domain.bookmark.entity.Bookmark; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final BoardRepository boardRepository; + + @Transactional + public BookmarkToggleResponse toggle(Long boardId, Long userId) { + if (!boardRepository.existsById(boardId)) { + throw new CustomException(BoardErrorCode.BOARD_NOT_FOUND); + } + Optional existing = bookmarkRepository.findByUserIdAndBoardId(userId, boardId); + if (existing.isPresent()) { + bookmarkRepository.delete(existing.get()); + return new BookmarkToggleResponse(false); + } + bookmarkRepository.save(Bookmark.builder().userId(userId).boardId(boardId).build()); + return new BookmarkToggleResponse(true); + } + + public boolean isBookmarked(Long boardId, Long userId) { + if (userId == null) return false; + return bookmarkRepository.findByUserIdAndBoardId(userId, boardId).isPresent(); + } + + public Set bookmarkedBoards(Collection boardIds, Long userId) { + if (userId == null || boardIds.isEmpty()) return Collections.emptySet(); + return bookmarkRepository.findByUserIdAndBoardIdIn(userId, boardIds).stream() + .map(Bookmark::getBoardId) + .collect(Collectors.toSet()); + } + + public List listBoardIdsByUser(Long userId) { + return bookmarkRepository.findByUserId(userId).stream() + .map(Bookmark::getBoardId) + .toList(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dao/BookmarkRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dao/BookmarkRepository.java new file mode 100644 index 0000000..5147c4d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dao/BookmarkRepository.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.dao; + +import com.chaewookim.accountbookformoms.domain.bookmark.entity.Bookmark; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface BookmarkRepository extends JpaRepository { + + Optional findByUserIdAndBoardId(Long userId, Long boardId); + + List findByUserId(Long userId); + + List findByUserIdAndBoardIdIn(Long userId, Collection boardIds); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dto/response/BookmarkToggleResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dto/response/BookmarkToggleResponse.java new file mode 100644 index 0000000..8bda8c2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/dto/response/BookmarkToggleResponse.java @@ -0,0 +1,6 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.dto.response; + +public record BookmarkToggleResponse( + boolean bookmarked +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/entity/Bookmark.java b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/entity/Bookmark.java new file mode 100644 index 0000000..cec4c11 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/bookmark/entity/Bookmark.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.entity; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "bookmark", + uniqueConstraints = @UniqueConstraint( + name = "uq_bookmark_user_board", + columnNames = {"user_id", "board_id"} + ), + indexes = @Index(name = "idx_bookmark_user", columnList = "user_id") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Bookmark extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "board_id", nullable = false) + private Long boardId; + + @Builder + public Bookmark(Long userId, Long boardId) { + this.userId = userId; + this.boardId = boardId; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetCompareController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetCompareController.java new file mode 100644 index 0000000..943122e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetCompareController.java @@ -0,0 +1,90 @@ +package com.chaewookim.accountbookformoms.domain.budget.api; + +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetCompareService; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetCompareRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.PublicBudgetFilterRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PairBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PublicMonthlyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "예산 비교(Budget Compare)", description = "내 예산 조회 및 공개 사용자 예산 비교 API") +@RestController +@RequestMapping("/api/v1/budgets") +@RequiredArgsConstructor +public class BudgetCompareController { + + private final BudgetCompareService budgetCompareService; + + @Operation(summary = "내 월별 예산 조회", description = "월 총 예산과 카테고리별 예산 목록") + @GetMapping("/me") + public ResponseEntity> getMyMonthlyBudget( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success(budgetCompareService.getMyMonthlyBudget(principal.getUserId(), yearMonth))); + } + + @Operation(summary = "공개 사용자 월별 총 예산 목록", description = "포트폴리오 공개 사용자들의 월별 총 예산. 연/월/금액 구간 필터 지원") + @GetMapping("/compare/users") + public ResponseEntity>> getPublicMonthlyBudgets( + @ParameterObject @Valid @ModelAttribute PublicBudgetFilterRequest filter + ) { + return ResponseEntity.ok(ApiResponse.success(budgetCompareService.getPublicMonthlyBudgets(filter))); + } + + @Operation(summary = "그룹 평균과 내 예산 비교", + description = "type: AGE(본인 나이대) / AMOUNT(금액 구간) / CATEGORY(특정 카테고리)") + @GetMapping("/compare") + public ResponseEntity> compare( + @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject @Valid @ModelAttribute BudgetCompareRequest request + ) { + return ResponseEntity.ok(ApiResponse.success(budgetCompareService.compareWithGroup(principal.getUserId(), request))); + } + + @Operation(summary = "선택 사용자와 본인 예산 세부 조회", + description = "본인과 선택한 공개 사용자의 월 총 예산 및 카테고리별 예산을 함께 반환") + @GetMapping("/compare/users/{targetUserId}/details") + public ResponseEntity> getPairBudgetDetails( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + budgetCompareService.getPairBudgetDetails(principal.getUserId(), targetUserId, yearMonth))); + } + + @Operation(summary = "선택 사용자와 본인 예산 비교", + description = "type: AGE(나이대 라벨) / AMOUNT(월 총 예산) / CATEGORY(특정 카테고리). CATEGORY는 categoryId 필수") + @GetMapping("/compare/users/{targetUserId}") + public ResponseEntity> compareWithUser( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam BudgetCompareType type, + @RequestParam String yearMonth, + @RequestParam(required = false) Long categoryId + ) { + return ResponseEntity.ok(ApiResponse.success( + budgetCompareService.compareWithUser(principal.getUserId(), targetUserId, type, yearMonth, categoryId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetController.java new file mode 100644 index 0000000..58938e8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/api/BudgetController.java @@ -0,0 +1,97 @@ +package com.chaewookim.accountbookformoms.domain.budget.api; + +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetService; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCopyResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetSummaryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "예산(Budget)", description = "사용자 예산 관리 API") +@RestController +@RequestMapping("/api/v1/budget") +@RequiredArgsConstructor +public class BudgetController { + + private final BudgetService budgetService; + + @Operation(summary = "예산 등록", description = "특정 월과 카테고리에 대한 예산 및 예상 지출을 등록") + @PostMapping + public ResponseEntity> createBudget( + @AuthenticationPrincipal UserPrincipal user, + @Valid @RequestBody BudgetRequest request + ) { + return ResponseEntity.ok(ApiResponse.success(budgetService.createBudget(user.getUserId(), request))); + } + + @Operation(summary = "월별 예산 현황 조회", description = "특정 월의 카테고리별 예산, 실제 지출, 잔액 및 진척도 조회") + @GetMapping("/{yearMonth}/status") + public ResponseEntity>> getMonthlyBudgetStatus( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success(budgetService.getMonthlyBudgetStatus(user.getUserId(), yearMonth))); + } + + @Operation(summary = "월별 예산 요약 조회", description = "특정 월의 전체 예산 합계 및 지출 요약 정보 조회") + @GetMapping("/{yearMonth}/summary") + public ResponseEntity> getMonthlyBudgetSummary( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success(budgetService.getMonthlyBudgetSummary(user.getUserId(), yearMonth))); + } + + @Operation(summary = "예산 수정", description = "등록된 예산의 총 예산 및 예상 지출 금액 수정") + @PatchMapping("/{budgetId}") + public ResponseEntity> updateBudget( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long budgetId, + @Valid @RequestBody BudgetRequest request + ) { + budgetService.updateBudget(user.getUserId(), budgetId, request); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "예산 삭제", description = "특정 예산 항목 삭제") + @DeleteMapping("/{budgetId}") + public ResponseEntity> deleteBudget( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable Long budgetId + ) { + budgetService.deleteBudget(user.getUserId(), budgetId); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "최근 예산 불러오기 미리보기", description = "가장 최근에 등록한 예산 월을 기준으로 복사 미리보기") + @GetMapping("/{targetYearMonth}/copy-from-latest/preview") + public ResponseEntity> previewCopyFromLatest( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable String targetYearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + budgetService.previewCopyFromLatest(user.getUserId(), targetYearMonth) + )); + } + + @Operation(summary = "최근 예산 불러오기", description = "가장 최근에 등록한 예산 월 설정을 복사") + @PostMapping("/{targetYearMonth}/copy-from-latest") + public ResponseEntity> copyFromLatest( + @AuthenticationPrincipal UserPrincipal user, + @PathVariable String targetYearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + budgetService.copyFromLatest(user.getUserId(), targetYearMonth) + )); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareService.java new file mode 100644 index 0000000..4df016f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareService.java @@ -0,0 +1,328 @@ +package com.chaewookim.accountbookformoms.domain.budget.application; + +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetCompareRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.PublicBudgetFilterRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.CategoryBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PairBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PublicMonthlyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import com.chaewookim.accountbookformoms.domain.budget.error.BudgetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.application.UserLocationService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BudgetCompareService { + + private static final double MIN_RADIUS_KM = 0.1; + private static final double MAX_RADIUS_KM = 50.0; + + private final BudgetRepository budgetRepository; + private final UserRepository userRepository; + private final UserLocationService userLocationService; + private final BudgetGroupCacheService groupCache; + + public MyBudgetResponse getMyMonthlyBudget(Long userId, String yearMonth) { + + validateYearMonth(yearMonth); + + List budgets = budgetRepository.findByUserIdAndYearMonth(userId, yearMonth); + List categoryBudgets = budgets.stream() + .map(CategoryBudgetResponse::from) + .toList(); + + BigDecimal total = budgets.stream() + .map(Budget::getTotalBudget) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + return new MyBudgetResponse(yearMonth, total, categoryBudgets); + } + + public List getPublicMonthlyBudgets(PublicBudgetFilterRequest filter) { + + validateAmountRange(filter.minAmount(), filter.maxAmount()); + + if (filter.year() == null || filter.month() == null) { + throw new CustomException(BudgetErrorCode.INVALID_YEAR_MONTH); + } + + String yearMonth = String.format("%04d-%02d", filter.year(), filter.month()); + YearMonth ym = YearMonth.parse(yearMonth); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + return budgetRepository.findPublicMonthlyTotals( + yearMonth, + startDate, + endDate, + filter.minAmount(), + filter.maxAmount()) + .stream() + .map(row -> new PublicMonthlyBudgetResponse( + ((Number) row[0]).longValue(), + (String) row[1], + (String) row[2], + toBigDecimal(row[3]))) + .toList(); + } + + public PairBudgetDetailResponse getPairBudgetDetails(Long myUserId, Long targetUserId, String yearMonth) { + + validateYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(BudgetErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return new PairBudgetDetailResponse( + toUserBudgetDetail(me, yearMonth), + toUserBudgetDetail(target, yearMonth)); + } + + public UserBudgetCompareResponse compareWithUser(Long myUserId, Long targetUserId, + BudgetCompareType type, String yearMonth, Long categoryId) { + + validateYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(BudgetErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return switch (type) { + case AGE -> compareUsersByAge(me, target, yearMonth); + case AMOUNT -> compareUsersByAmount(me, target, yearMonth); + case CATEGORY -> compareUsersByCategory(me, target, yearMonth, categoryId); + case LOCATION -> throw new CustomException(BudgetErrorCode.LOCATION_PAIR_NOT_SUPPORTED); + }; + } + + private UserBudgetCompareResponse compareUsersByAge(User me, User target, String yearMonth) { + if (me.getBirthDate() == null || target.getBirthDate() == null) { + throw new CustomException(BudgetErrorCode.BIRTH_DATE_REQUIRED); + } + BigDecimal myAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(me.getId(), yearMonth)); + BigDecimal targetAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(target.getId(), yearMonth)); + return UserBudgetCompareResponse.of( + BudgetCompareType.AGE, yearMonth, + me.getId(), decadeLabel(me.getBirthDate()), myAmount, + target.getId(), decadeLabel(target.getBirthDate()), targetAmount); + } + + private UserBudgetCompareResponse compareUsersByAmount(User me, User target, String yearMonth) { + BigDecimal myAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(me.getId(), yearMonth)); + BigDecimal targetAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(target.getId(), yearMonth)); + return UserBudgetCompareResponse.of( + BudgetCompareType.AMOUNT, yearMonth, + me.getId(), "월 총 예산", myAmount, + target.getId(), "월 총 예산", targetAmount); + } + + private UserBudgetCompareResponse compareUsersByCategory(User me, User target, String yearMonth, Long categoryId) { + if (categoryId == null) { + throw new CustomException(BudgetErrorCode.CATEGORY_ID_REQUIRED); + } + BigDecimal myAmount = nullToZero(budgetRepository.findMyCategoryBudget(me.getId(), yearMonth, categoryId)); + BigDecimal targetAmount = nullToZero(budgetRepository.findMyCategoryBudget(target.getId(), yearMonth, categoryId)); + String categoryLabel = "카테고리 #" + categoryId; + return UserBudgetCompareResponse.of( + BudgetCompareType.CATEGORY, yearMonth, + me.getId(), categoryLabel, myAmount, + target.getId(), categoryLabel, targetAmount); + } + + private UserBudgetDetailResponse toUserBudgetDetail(User user, String yearMonth) { + List budgets = budgetRepository.findByUserIdAndYearMonth(user.getId(), yearMonth); + BigDecimal total = budgets.stream() + .map(Budget::getTotalBudget) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return new UserBudgetDetailResponse( + user.getId(), user.getUsername(), yearMonth, total, + budgets.stream().map(CategoryBudgetResponse::from).toList()); + } + + private User loadPublicTarget(Long targetUserId) { + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (target.getUserSetting() == null || !Boolean.TRUE.equals(target.getUserSetting().getIsPortfolioPublic())) { + throw new CustomException(BudgetErrorCode.TARGET_NOT_PUBLIC); + } + return target; + } + + private String decadeLabel(LocalDate birthDate) { + int age = LocalDate.now().getYear() - birthDate.getYear(); + return ((age / 10) * 10) + "대"; + } + + private BigDecimal nullToZero(BigDecimal value) { + return value != null ? value : BigDecimal.ZERO; + } + + public BudgetCompareResponse compareWithGroup(Long userId, BudgetCompareRequest request) { + + validateYearMonth(request.yearMonth()); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + return switch (request.type()) { + case AGE -> compareByAge(user, request.yearMonth()); + case AMOUNT -> compareByAmount(user, request.yearMonth(), request.minAmount(), request.maxAmount()); + case CATEGORY -> compareByCategory(user, request.yearMonth(), request.categoryId()); + case LOCATION -> compareByLocation(user, request.yearMonth(), request.radiusKm()); + }; + } + + private BudgetCompareResponse compareByLocation(User user, String yearMonth, Double radiusKm) { + + if (radiusKm == null) { + throw new CustomException(BudgetErrorCode.RADIUS_REQUIRED); + } + if (radiusKm < MIN_RADIUS_KM || radiusKm > MAX_RADIUS_KM) { + throw new CustomException(BudgetErrorCode.INVALID_RADIUS); + } + + Set nearbyUserIds = userLocationService.findNearbyUserIds(user.getId(), radiusKm); + + List rows = nearbyUserIds.isEmpty() + ? List.of() + : budgetRepository.sumMonthlyTotalsByUserIds(yearMonth, nearbyUserIds); + + BigDecimal myAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(user.getId(), yearMonth)); + return buildCompare(rows, myAmount, yearMonth, BudgetCompareType.LOCATION); + } + + private BudgetCompareResponse compareByAge(User user, String yearMonth) { + + if (user.getBirthDate() == null) { + throw new CustomException(BudgetErrorCode.BIRTH_DATE_REQUIRED); + } + + int currentYear = LocalDate.now().getYear(); + int age = currentYear - user.getBirthDate().getYear(); + int decadeStart = (age / 10) * 10; + + LocalDate birthFrom = LocalDate.of(currentYear - (decadeStart + 9), 1, 1); + LocalDate birthTo = LocalDate.of(currentYear - decadeStart, 12, 31); + + Map groupSums = groupCache.getAgeGroupSums(yearMonth, birthFrom, birthTo); + BigDecimal myAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(user.getId(), yearMonth)); + return buildCompareExcludingSelf(groupSums, user.getId(), myAmount, yearMonth, BudgetCompareType.AGE); + } + + private BudgetCompareResponse compareByAmount(User user, String yearMonth, BigDecimal minAmount, BigDecimal maxAmount) { + + validateAmountRange(minAmount, maxAmount); + + Map groupSums = groupCache.getAmountGroupSums(yearMonth, minAmount, maxAmount); + BigDecimal myAmount = nullToZero(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(user.getId(), yearMonth)); + return buildCompareExcludingSelf(groupSums, user.getId(), myAmount, yearMonth, BudgetCompareType.AMOUNT); + } + + private BudgetCompareResponse compareByCategory(User user, String yearMonth, Long categoryId) { + + if (categoryId == null) { + throw new CustomException(BudgetErrorCode.CATEGORY_ID_REQUIRED); + } + + BudgetGroupCacheService.CategoryAggregation agg = groupCache.getCategoryAggregation(yearMonth, categoryId); + BigDecimal myContribution = budgetRepository.findMyCategoryBudget(user.getId(), yearMonth, categoryId); + + BigDecimal groupSum = agg.totalSum(); + long groupCount = agg.count(); + if (myContribution != null) { + groupSum = groupSum.subtract(myContribution); + groupCount = Math.max(0L, groupCount - 1); + } + + BigDecimal average = BigDecimal.ZERO; + if (groupCount > 0) { + average = groupSum.divide(BigDecimal.valueOf(groupCount), 2, RoundingMode.HALF_UP); + } + + BigDecimal myAmount = myContribution != null ? myContribution : BigDecimal.ZERO; + return BudgetCompareResponse.of(BudgetCompareType.CATEGORY, yearMonth, myAmount, average, groupCount); + } + + private BudgetCompareResponse buildCompareExcludingSelf(Map groupSums, Long myUserId, + BigDecimal myAmount, String yearMonth, + BudgetCompareType type) { + Map others = new HashMap<>(groupSums); + others.remove(myUserId); + + long sampleSize = others.size(); + BigDecimal average = BigDecimal.ZERO; + if (sampleSize > 0) { + BigDecimal sum = others.values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add); + average = sum.divide(BigDecimal.valueOf(sampleSize), 2, RoundingMode.HALF_UP); + } + return BudgetCompareResponse.of(type, yearMonth, myAmount, average, sampleSize); + } + + private BudgetCompareResponse buildCompare(List rows, BigDecimal myAmount, String yearMonth, + BudgetCompareType type) { + long sampleSize = rows.size(); + BigDecimal average = BigDecimal.ZERO; + if (sampleSize > 0) { + BigDecimal sum = rows.stream() + .map(r -> toBigDecimal(r[1])) + .reduce(BigDecimal.ZERO, BigDecimal::add); + average = sum.divide(BigDecimal.valueOf(sampleSize), 2, RoundingMode.HALF_UP); + } + return BudgetCompareResponse.of(type, yearMonth, myAmount, average, sampleSize); + } + + private void validateYearMonth(String yearMonth) { + try { + YearMonth.parse(yearMonth); + } catch (DateTimeParseException | NullPointerException e) { + throw new CustomException(BudgetErrorCode.INVALID_YEAR_MONTH); + } + } + + private void validateAmountRange(BigDecimal minAmount, BigDecimal maxAmount) { + if (minAmount != null && maxAmount != null && minAmount.compareTo(maxAmount) > 0) { + throw new CustomException(BudgetErrorCode.INVALID_AMOUNT_RANGE); + } + } + + private BigDecimal toBigDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal bd) { + return bd; + } + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetGroupCacheService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetGroupCacheService.java new file mode 100644 index 0000000..3da50bd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetGroupCacheService.java @@ -0,0 +1,74 @@ +package com.chaewookim.accountbookformoms.domain.budget.application; + +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 그룹 평균 비교를 위한 캐싱 wrapper. + *

userId 를 키에서 제외해 같은 demographic 사용자들이 공유. self exclusion 은 호출자(CompareService) 가 in-memory 로 처리.

+ *

repo 쿼리의 excludeUserId 자리에 {@link #NO_EXCLUDE} 를 넘겨 "모든 공개 사용자 포함" 결과를 캐시.

+ */ +@Service +@RequiredArgsConstructor +public class BudgetGroupCacheService { + + private static final Long NO_EXCLUDE = -1L; + + private final BudgetRepository budgetRepository; + + public record CategoryAggregation(BigDecimal totalSum, long count) {} + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_BUDGET_AGE, + key = "T(java.util.Objects).hash(#yearMonth, #birthFrom.toString(), #birthTo.toString())") + public Map getAgeGroupSums(String yearMonth, LocalDate birthFrom, LocalDate birthTo) { + return toUserSumMap(budgetRepository.sumMonthlyTotalsByAgeRange(yearMonth, birthFrom, birthTo, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_BUDGET_AMOUNT, + key = "T(java.util.Objects).hash(#yearMonth, #minAmount, #maxAmount)") + public Map getAmountGroupSums(String yearMonth, BigDecimal minAmount, BigDecimal maxAmount) { + return toUserSumMap(budgetRepository.sumMonthlyTotalsByAmountRange(yearMonth, minAmount, maxAmount, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_BUDGET_CATEGORY, + key = "T(java.util.Objects).hash(#yearMonth, #categoryId)") + public CategoryAggregation getCategoryAggregation(String yearMonth, Long categoryId) { + Object[] row = budgetRepository.averageCategoryBudget(yearMonth, categoryId, NO_EXCLUDE); + if (row == null || row.length < 2 || row[1] == null) { + return new CategoryAggregation(BigDecimal.ZERO, 0L); + } + long count = ((Number) row[1]).longValue(); + if (count <= 0 || row[0] == null) { + return new CategoryAggregation(BigDecimal.ZERO, 0L); + } + BigDecimal avg = toBigDecimal(row[0]); + BigDecimal totalSum = avg.multiply(BigDecimal.valueOf(count)); + return new CategoryAggregation(totalSum, count); + } + + private static Map toUserSumMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(((Number) row[0]).longValue(), toBigDecimal(row[1])); + } + return map; + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetService.java new file mode 100644 index 0000000..b068f39 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetService.java @@ -0,0 +1,483 @@ +package com.chaewookim.accountbookformoms.domain.budget.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCopyResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetSummaryResponse; +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; +import com.chaewookim.accountbookformoms.domain.budget.error.BudgetErrorCode; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BudgetService { + + private final BudgetRepository budgetRepository; + private final TransactionRepository transactionRepository; + private final FixedTransactionRepository fixedTransactionRepository; + private final TransactionCategoryRepository categoryRepository; + private final UserRepository userRepository; + private final CacheManager cacheManager; + + @Transactional + public Long createBudget(Long userId, BudgetRequest request) { + + if (budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId(userId, request.yearMonth(), request.categoryId()).isPresent()) { + throw new CustomException(BudgetErrorCode.BUDGET_ALREADY_EXISTS); + } + + Optional deletedBudget = budgetRepository.findByUserIdAndYearMonthAndCategoryIdIncludingDeleted( + userId, request.yearMonth(), request.categoryId()); + if (deletedBudget.isPresent() && deletedBudget.get().getDeletedAt() != null) { + Budget budget = deletedBudget.get(); + budget.restore(); + budget.update(request.totalBudget(), request.expectedExpense()); + Long restoredId = budgetRepository.save(budget).getId(); + evictDashboardCache(userId, request.yearMonth()); + return restoredId; + } + + Budget budget = Budget.builder() + .user(userRepository.getReferenceById(userId)) + .transactionCategory(categoryRepository.getReferenceById(request.categoryId())) + .yearMonth(request.yearMonth()) + .totalBudget(request.totalBudget()) + .expectedExpense(request.expectedExpense()) + .build(); + Long savedId = budgetRepository.save(budget).getId(); + evictDashboardCache(userId, request.yearMonth()); + return savedId; + } + + public List getMonthlyBudgetStatus(Long userId, String yearMonth) { + return buildBudgetViewContexts(userId, yearMonth).stream() + .map(this::toBudgetResponse) + .toList(); + } + + public BudgetSummaryResponse getMonthlyBudgetSummary(Long userId, String yearMonth) { + + List contexts = buildBudgetViewContexts(userId, yearMonth); + + BigDecimal totalPlannedBudgetSum = contexts.stream() + .map(BudgetViewContext::totalPlannedBudget) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + LocalDate[] monthRange = monthDateRange(yearMonth); + BigDecimal totalActualExpenseSum = transactionRepository.sumByUserAndType( + userId, TransactionType.EXPENSE, monthRange[0], monthRange[1]); + if (totalActualExpenseSum == null) { + totalActualExpenseSum = BigDecimal.ZERO; + } + + BigDecimal totalRemainingBudget = totalPlannedBudgetSum.subtract(totalActualExpenseSum); + + return new BudgetSummaryResponse(yearMonth, totalPlannedBudgetSum, totalActualExpenseSum, totalRemainingBudget); + } + + private boolean isUserConfiguredBudget(Budget budget) { + return budget.getTotalBudget().compareTo(BigDecimal.ZERO) > 0 + || budget.getExpectedExpense().compareTo(BigDecimal.ZERO) > 0; + } + + private void removePlaceholderBudgets(Long userId, String yearMonth) { + budgetRepository.findByUserIdAndYearMonth(userId, yearMonth).stream() + .filter(budget -> !isUserConfiguredBudget(budget)) + .forEach(budgetRepository::delete); + } + + @Transactional + public void updateBudget(Long userId, Long budgetId, BudgetRequest request) { + Budget budget = validateAndGet(userId, budgetId); + budget.update(request.totalBudget(), request.expectedExpense()); + + User user = userRepository.findById(userId).orElseThrow(); + user.updateLastBudgetAlertMonth(null); + userRepository.save(user); + evictDashboardCache(userId, budget.getYearMonth()); + } + + @Transactional + public void deleteBudget(Long userId, Long budgetId) { + Budget budget = validateAndGet(userId, budgetId); + String yearMonth = budget.getYearMonth(); + budgetRepository.delete(budget); + + User user = userRepository.findById(userId).orElseThrow(); + user.updateLastBudgetAlertMonth(null); + userRepository.save(user); + evictDashboardCache(userId, yearMonth); + } + + public BudgetCopyResponse previewCopyFromLatest(Long userId, String targetYearMonth) { + + validateTargetMonthCanCopyFromLatest(userId, targetYearMonth); + CopyPlan plan = buildCopyPlan(userId, targetYearMonth); + + if (plan.sourceBudgets.isEmpty()) { + throw new CustomException(BudgetErrorCode.NO_SOURCE_BUDGET); + } + + return toCopyResponse(plan); + } + + @Transactional + public BudgetCopyResponse copyFromLatest(Long userId, String targetYearMonth) { + + validateTargetMonthCanCopyFromLatest(userId, targetYearMonth); + removePlaceholderBudgets(userId, targetYearMonth); + CopyPlan plan = buildCopyPlan(userId, targetYearMonth); + + if (plan.sourceBudgets.isEmpty()) { + throw new CustomException(BudgetErrorCode.NO_SOURCE_BUDGET); + } + + List items = new ArrayList<>(); + int copiedCount = 0; + + for (Budget source : plan.sourceBudgets) { + Long categoryId = resolveCategoryId(source); + String categoryName = resolveCategoryName(source); + String skipReason = resolveSkipReason(plan, categoryId); + + if (skipReason != null) { + items.add(new BudgetCopyResponse.Item( + categoryId, + categoryName, + source.getTotalBudget(), + source.getExpectedExpense(), + false, + skipReason + )); + continue; + } + + BudgetRequest request = new BudgetRequest( + categoryId, + targetYearMonth, + source.getTotalBudget(), + source.getExpectedExpense() + ); + createBudget(userId, request); + plan.existingTargetCategoryIds.add(categoryId); + copiedCount++; + + items.add(new BudgetCopyResponse.Item( + categoryId, + categoryName, + source.getTotalBudget(), + source.getExpectedExpense(), + true, + null + )); + } + + if (copiedCount > 0) { + User user = userRepository.findById(userId).orElseThrow(); + user.updateLastBudgetAlertMonth(null); + userRepository.save(user); + } + + return new BudgetCopyResponse(plan.sourceYearMonth, targetYearMonth, items, copiedCount); + } + + private void validateTargetMonthCanCopyFromLatest(Long userId, String targetYearMonth) { + boolean hasUserConfiguredBudget = budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth).stream() + .anyMatch(this::isUserConfiguredBudget); + if (hasUserConfiguredBudget) { + throw new CustomException(BudgetErrorCode.TARGET_MONTH_NOT_EMPTY); + } + } + + private CopyPlan buildCopyPlan(Long userId, String targetYearMonth) { + + String sourceYearMonth = budgetRepository.findLatestBudgetYearMonthBefore(userId, targetYearMonth) + .orElse(null); + List sourceBudgets = sourceYearMonth == null + ? List.of() + : budgetRepository.findByUserIdAndYearMonth(userId, sourceYearMonth); + Set activeExpenseCategoryIds = categoryRepository.findAllByUserOrSystem(userId).stream() + .filter(category -> category.getType() == TransactionType.EXPENSE) + .map(TransactionCategory::getId) + .collect(Collectors.toSet()); + Set existingTargetCategoryIds = budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth).stream() + .filter(this::isUserConfiguredBudget) + .map(this::resolveCategoryId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + + return new CopyPlan(sourceYearMonth, targetYearMonth, sourceBudgets, activeExpenseCategoryIds, existingTargetCategoryIds); + } + + private BudgetCopyResponse toCopyResponse(CopyPlan plan) { + + List items = new ArrayList<>(); + int copyCount = 0; + + for (Budget source : plan.sourceBudgets) { + Long categoryId = resolveCategoryId(source); + String categoryName = resolveCategoryName(source); + String skipReason = resolveSkipReason(plan, categoryId); + boolean selected = skipReason == null; + + if (selected) { + copyCount++; + } + + items.add(new BudgetCopyResponse.Item( + categoryId, + categoryName, + source.getTotalBudget(), + source.getExpectedExpense(), + selected, + skipReason + )); + } + + return new BudgetCopyResponse(plan.sourceYearMonth, plan.targetYearMonth, items, copyCount); + } + + private String resolveSkipReason(CopyPlan plan, Long categoryId) { + if (!plan.activeExpenseCategoryIds.contains(categoryId)) { + return "DELETED_CATEGORY"; + } + if (plan.existingTargetCategoryIds.contains(categoryId)) { + return "ALREADY_EXISTS"; + } + return null; + } + + private record CopyPlan( + String sourceYearMonth, + String targetYearMonth, + List sourceBudgets, + Set activeExpenseCategoryIds, + Set existingTargetCategoryIds + ) { + } + + // 공통 검증 로직 분리 + private Budget validateAndGet(Long userId, Long budgetId) { + + Budget budget = budgetRepository.findById(budgetId) + .orElseThrow(() -> new CustomException(BudgetErrorCode.BUDGET_NOT_FOUND)); + + if (!budget.getUser().getId().equals(userId)) { + throw new CustomException(BudgetErrorCode.BUDGET_FORBIDDEN); + } + return budget; + } + + private Long resolveCategoryId(Budget budget) { + if (budget.getTransactionCategory() != null) { + return budget.getTransactionCategory().getId(); + } + return budget.getSnapshotCategoryId(); + } + + private String resolveCategoryName(Budget budget) { + if (budget.getTransactionCategory() != null) { + return budget.getTransactionCategory().getName(); + } + if (budget.getSnapshotCategoryName() != null) { + return budget.getSnapshotCategoryName(); + } + return "삭제된 카테고리"; + } + + // 예산 대비 실제 지출의 사용률 계산 + private BigDecimal calculateProgress(BigDecimal total, BigDecimal actual) { + if (total.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; + return actual.divide(total, 2, RoundingMode.HALF_UP).multiply(new BigDecimal("100")); + } + + // 카테고리별 예산 알림 생성 시 필요 + public BigDecimal getCategoryProgress(Long userId, String yearMonth, Long categoryId) { + + LocalDate[] monthRange = monthDateRange(yearMonth); + BigDecimal actualExpense = transactionRepository.sumAmountByUserIdAndCategoryId( + userId, categoryId, monthRange[0], monthRange[1]); + if (actualExpense == null) { + actualExpense = BigDecimal.ZERO; + } + + Optional budget = budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId( + userId, yearMonth, categoryId); + BigDecimal fixedExpenseAmount = calculateFixedExpenseForCategory(userId, categoryId, yearMonth); + BigDecimal totalBudget = budget.map(Budget::getTotalBudget).orElse(BigDecimal.ZERO); + BigDecimal expectedExpense = budget.map(Budget::getExpectedExpense).orElse(BigDecimal.ZERO); + BigDecimal totalPlanned = totalBudget.add(fixedExpenseAmount).add(expectedExpense); + + if (totalPlanned.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + + return calculateProgress(totalPlanned, actualExpense); + } + + private List buildBudgetViewContexts(Long userId, String yearMonth) { + + List budgets = budgetRepository.findByUserIdAndYearMonth(userId, yearMonth); + LocalDate[] monthRange = monthDateRange(yearMonth); + FixedExpenseSnapshot fixedSnapshot = buildFixedExpenseSnapshot(userId, monthRange[0], monthRange[1]); + + Map expenseMap = transactionRepository.sumAmountByUserIdGroupByCategoryId(userId, yearMonth) + .stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (BigDecimal) row[1] + )); + + List contexts = budgets.stream() + .map(budget -> { + Long categoryId = resolveCategoryId(budget); + BigDecimal fixedExpenseAmount = fixedSnapshot.amountByCategory() + .getOrDefault(categoryId, BigDecimal.ZERO); + return new BudgetViewContext( + budget.getId(), + categoryId, + resolveCategoryName(budget), + budget.isCategoryArchived(), + budget.getTotalBudget(), + fixedExpenseAmount, + budget.getExpectedExpense(), + expenseMap.getOrDefault(categoryId, BigDecimal.ZERO) + ); + }) + .collect(Collectors.toCollection(ArrayList::new)); + + Set coveredCategoryIds = budgets.stream() + .map(this::resolveCategoryId) + .collect(Collectors.toSet()); + + fixedSnapshot.amountByCategory().forEach((categoryId, fixedAmount) -> { + if (coveredCategoryIds.contains(categoryId) || fixedAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + contexts.add(new BudgetViewContext( + null, + categoryId, + fixedSnapshot.nameByCategory().getOrDefault(categoryId, "카테고리"), + false, + BigDecimal.ZERO, + fixedAmount, + BigDecimal.ZERO, + expenseMap.getOrDefault(categoryId, BigDecimal.ZERO) + )); + }); + + return contexts; + } + + private BudgetResponse toBudgetResponse(BudgetViewContext context) { + BigDecimal totalPlannedBudget = context.totalPlannedBudget(); + BigDecimal remaining = totalPlannedBudget.subtract(context.actualExpense()); + BigDecimal progress = calculateProgress(totalPlannedBudget, context.actualExpense()); + + return new BudgetResponse( + context.id(), + context.categoryId(), + context.categoryName(), + context.categoryArchived(), + context.totalBudget(), + context.fixedExpenseAmount(), + context.expectedExpense(), + totalPlannedBudget, + context.actualExpense(), + remaining, + progress + ); + } + + private FixedExpenseSnapshot buildFixedExpenseSnapshot(Long userId, LocalDate startDate, LocalDate endDate) { + + Map amountByCategory = new HashMap<>(); + Map nameByCategory = new HashMap<>(); + + for (Object[] row : fixedTransactionRepository.sumByUserCategory( + userId, TransactionType.EXPENSE, startDate, endDate)) { + Long categoryId = (Long) row[0]; + String categoryName = (String) row[1]; + BigDecimal amount = (BigDecimal) row[2]; + amountByCategory.put(categoryId, amount); + nameByCategory.put(categoryId, categoryName); + } + + return new FixedExpenseSnapshot(amountByCategory, nameByCategory); + } + + private BigDecimal calculateFixedExpenseForCategory(Long userId, Long categoryId, String yearMonth) { + + LocalDate[] monthRange = monthDateRange(yearMonth); + BigDecimal fixedAmount = fixedTransactionRepository.sumByUserAndTypeAndCategory( + userId, + TransactionType.EXPENSE, + categoryId, + monthRange[0], + monthRange[1] + ); + return fixedAmount != null ? fixedAmount : BigDecimal.ZERO; + } + + private LocalDate[] monthDateRange(String yearMonth) { + LocalDate startDate = LocalDate.parse(yearMonth + "-01"); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + return new LocalDate[]{startDate, endDate}; + } + + private void evictDashboardCache(Long userId, String yearMonth) { + Cache dashboardCache = cacheManager.getCache(RedisConfig.CACHE_DASHBOARD); + if (dashboardCache == null || yearMonth == null) { + return; + } + dashboardCache.evict(userId + ":" + yearMonth); + } + + private record FixedExpenseSnapshot( + Map amountByCategory, + Map nameByCategory + ) { + } + + private record BudgetViewContext( + Long id, + Long categoryId, + String categoryName, + boolean categoryArchived, + BigDecimal totalBudget, + BigDecimal fixedExpenseAmount, + BigDecimal expectedExpense, + BigDecimal actualExpense + ) { + BigDecimal totalPlannedBudget() { + return totalBudget.add(fixedExpenseAmount).add(expectedExpense); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dao/BudgetRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dao/BudgetRepository.java new file mode 100644 index 0000000..b503c09 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dao/BudgetRepository.java @@ -0,0 +1,217 @@ +package com.chaewookim.accountbookformoms.domain.budget.dao; + +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BudgetRepository extends JpaRepository { + + boolean existsByTransactionCategoryId(Long categoryId); + + Optional findByUserIdAndYearMonthAndTransactionCategoryId(Long userId, String yearMonth, Long categoryId); + + @Query(value = """ + SELECT * + FROM budget + WHERE user_id = :userId + AND `year_month` = :yearMonth + AND category_id = :categoryId + LIMIT 1 + """, nativeQuery = true) + Optional findByUserIdAndYearMonthAndCategoryIdIncludingDeleted(@Param("userId") Long userId, + @Param("yearMonth") String yearMonth, + @Param("categoryId") Long categoryId); + + @Query(""" + SELECT b + FROM Budget b + LEFT JOIN FETCH b.transactionCategory + WHERE b.user.id = :userId + AND b.yearMonth = :yearMonth + """) + List findByUserIdAndYearMonth(@Param("userId") Long userId, + @Param("yearMonth") String yearMonth); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Budget b + SET b.snapshotCategoryId = :categoryId, + b.snapshotCategoryName = :categoryName + WHERE b.transactionCategory.id = :categoryId + AND b.snapshotCategoryName IS NULL + """) + void backfillCategorySnapshot(@Param("categoryId") Long categoryId, @Param("categoryName") String categoryName); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Budget b SET b.categoryArchived = true WHERE b.snapshotCategoryId = :categoryId") + void markCategoryArchived(@Param("categoryId") Long categoryId); + + @Query(value = """ + SELECT b.`year_month` + FROM budget b + WHERE b.user_id = :userId + AND b.`year_month` < :targetYearMonth + AND b.deleted_at IS NULL + GROUP BY b.`year_month` + ORDER BY b.`year_month` DESC + LIMIT 1 + """, nativeQuery = true) + Optional findLatestBudgetYearMonthBefore(@Param("userId") Long userId, + @Param("targetYearMonth") String targetYearMonth); + + @Query(""" + SELECT COALESCE(SUM(b.totalBudget), 0) + FROM Budget b + WHERE b.user.id = :userId + AND b.yearMonth = :yearMonth + """) + BigDecimal sumTotalBudgetByUserIdAndYearMonth(@Param("userId") Long userId, + @Param("yearMonth") String yearMonth); + + /** + * 특정 월에 포트폴리오 활동(고정/변동 수입·지출, 예산)이 하나라도 있는 + * 공개 사용자들의 월 총 예산 목록. 예산이 없는 사용자는 totalBudget = 0. + * 종합 비교 페이지의 사용자 집합과 일치하도록 UNION 기반 EXISTS 로 판정. + * 컬럼 순서: userId, username, yearMonth, totalBudget + */ + @Query(value = """ + SELECT u.id AS user_id, + u.username AS username, + :yearMonth AS year_month, + COALESCE(SUM(b.total_budget), 0) AS total_budget + FROM `user` u + JOIN user_setting s ON s.user_id = u.id AND s.is_portfolio_public = TRUE + LEFT JOIN budget b + ON b.user_id = u.id + AND b.`year_month` = :yearMonth + AND b.deleted_at IS NULL + WHERE u.deleted_at IS NULL + AND ( + EXISTS (SELECT 1 FROM fixed_transaction ft + WHERE ft.user_id = u.id + AND ft.type = 'INCOME' + AND ft.is_active = TRUE + AND ft.deleted_at IS NULL + AND ft.start_date <= :endDate + AND (ft.end_date IS NULL OR ft.end_date >= :startDate)) + OR EXISTS (SELECT 1 FROM fixed_transaction ft + WHERE ft.user_id = u.id + AND ft.type = 'EXPENSE' + AND ft.is_active = TRUE + AND ft.deleted_at IS NULL + AND ft.start_date <= :endDate + AND (ft.end_date IS NULL OR ft.end_date >= :startDate)) + OR EXISTS (SELECT 1 FROM `transaction` t + WHERE t.user_id = u.id + AND t.deleted_at IS NULL + AND t.transaction_date BETWEEN :startDate AND :endDate) + OR EXISTS (SELECT 1 FROM budget b2 + WHERE b2.user_id = u.id + AND b2.`year_month` = :yearMonth + AND b2.deleted_at IS NULL) + ) + GROUP BY u.id, u.username + HAVING (:minAmount IS NULL OR COALESCE(SUM(b.total_budget), 0) >= :minAmount) + AND (:maxAmount IS NULL OR COALESCE(SUM(b.total_budget), 0) <= :maxAmount) + ORDER BY total_budget DESC + """, nativeQuery = true) + List findPublicMonthlyTotals(@Param("yearMonth") String yearMonth, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("minAmount") BigDecimal minAmount, + @Param("maxAmount") BigDecimal maxAmount); + + /** + * 같은 나이대(생년월일 범위) 공개 사용자들의 월 총 예산 합계(사용자 단위). + * 반환: [userId, totalSum] + */ + @Query(""" + SELECT b.user.id, SUM(b.totalBudget) + FROM Budget b + JOIN b.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND b.yearMonth = :yearMonth + AND u.birthDate IS NOT NULL + AND u.birthDate BETWEEN :birthFrom AND :birthTo + GROUP BY b.user.id + """) + List sumMonthlyTotalsByAgeRange(@Param("yearMonth") String yearMonth, + @Param("birthFrom") LocalDate birthFrom, + @Param("birthTo") LocalDate birthTo, + @Param("excludeUserId") Long excludeUserId); + + /** + * 지정 금액 구간 안에 드는 공개 사용자들의 월 총 예산 합계(사용자 단위). + */ + @Query(""" + SELECT b.user.id, SUM(b.totalBudget) + FROM Budget b + JOIN b.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND b.yearMonth = :yearMonth + GROUP BY b.user.id + HAVING (:minAmount IS NULL OR SUM(b.totalBudget) >= :minAmount) + AND (:maxAmount IS NULL OR SUM(b.totalBudget) <= :maxAmount) + """) + List sumMonthlyTotalsByAmountRange(@Param("yearMonth") String yearMonth, + @Param("minAmount") BigDecimal minAmount, + @Param("maxAmount") BigDecimal maxAmount, + @Param("excludeUserId") Long excludeUserId); + + /** + * 같은 카테고리에 예산을 설정한 공개 사용자들의 totalBudget 평균. + */ + @Query(""" + SELECT AVG(b.totalBudget), COUNT(b) + FROM Budget b + JOIN b.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id <> :excludeUserId + AND b.yearMonth = :yearMonth + AND b.transactionCategory.id = :categoryId + """) + Object[] averageCategoryBudget(@Param("yearMonth") String yearMonth, + @Param("categoryId") Long categoryId, + @Param("excludeUserId") Long excludeUserId); + + @Query(""" + SELECT b.totalBudget + FROM Budget b + WHERE b.user.id = :userId + AND b.yearMonth = :yearMonth + AND b.transactionCategory.id = :categoryId + """) + BigDecimal findMyCategoryBudget(@Param("userId") Long userId, + @Param("yearMonth") String yearMonth, + @Param("categoryId") Long categoryId); + + /** + * 지정 userId 집합(공개 사용자만) 의 월 총 예산 합계(사용자 단위). 위치 기반 비교용. + * 반환: [userId, totalSum] + */ + @Query(""" + SELECT b.user.id, SUM(b.totalBudget) + FROM Budget b + JOIN b.user u + JOIN u.userSetting s + WHERE s.isPortfolioPublic = true + AND u.id IN :userIds + AND b.yearMonth = :yearMonth + GROUP BY b.user.id + """) + List sumMonthlyTotalsByUserIds(@Param("yearMonth") String yearMonth, + @Param("userIds") Collection userIds); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetCompareRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetCompareRequest.java new file mode 100644 index 0000000..7ac68dd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetCompareRequest.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.request; + +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.math.BigDecimal; + +public record BudgetCompareRequest( + + @NotNull(message = "비교 기준은 필수입니다.") + BudgetCompareType type, + + @NotBlank(message = "연/월은 필수입니다.") + @Pattern(regexp = "^\\d{4}-(0[1-9]|1[0-2])$", message = "연/월은 YYYY-MM 형식이어야 합니다.") + String yearMonth, + + BigDecimal minAmount, + BigDecimal maxAmount, + Long categoryId, + + @DecimalMin(value = "0.1", message = "반경은 0.1km 이상이어야 합니다.") + @DecimalMax(value = "50.0", message = "반경은 50km 이하여야 합니다.") + Double radiusKm +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetRequest.java new file mode 100644 index 0000000..300e4fe --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/BudgetRequest.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.request; + +import java.math.BigDecimal; + +public record BudgetRequest( + + Long categoryId, + String yearMonth, + BigDecimal totalBudget, + BigDecimal expectedExpense +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/PublicBudgetFilterRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/PublicBudgetFilterRequest.java new file mode 100644 index 0000000..f0e65c6 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/request/PublicBudgetFilterRequest.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.math.BigDecimal; + +public record PublicBudgetFilterRequest( + + @Min(1900) @Max(2100) + Integer year, + + @Min(1) @Max(12) + Integer month, + + BigDecimal minAmount, + BigDecimal maxAmount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCompareResponse.java new file mode 100644 index 0000000..be32543 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCompareResponse.java @@ -0,0 +1,20 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; + +import java.math.BigDecimal; + +public record BudgetCompareResponse( + BudgetCompareType type, + String yearMonth, + BigDecimal myAmount, + BigDecimal averageAmount, + long sampleSize, + BigDecimal difference +) { + public static BudgetCompareResponse of(BudgetCompareType type, String yearMonth, + BigDecimal myAmount, BigDecimal averageAmount, long sampleSize) { + BigDecimal diff = myAmount.subtract(averageAmount); + return new BudgetCompareResponse(type, yearMonth, myAmount, averageAmount, sampleSize, diff); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCopyResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCopyResponse.java new file mode 100644 index 0000000..afee0f4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetCopyResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record BudgetCopyResponse( + String sourceYearMonth, + String targetYearMonth, + List items, + int copyCount +) { + public record Item( + Long categoryId, + String categoryName, + BigDecimal totalBudget, + BigDecimal expectedExpense, + boolean selected, + String skipReason + ) { + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetResponse.java new file mode 100644 index 0000000..da5bdf8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetResponse.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; + +public record BudgetResponse( + + Long id, + Long categoryId, + String categoryName, + boolean categoryArchived, + BigDecimal totalBudget, + BigDecimal fixedExpenseAmount, + BigDecimal expectedExpense, + BigDecimal totalPlannedBudget, + BigDecimal actualExpense, + BigDecimal remainingBudget, + BigDecimal progress +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetSummaryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetSummaryResponse.java new file mode 100644 index 0000000..c8a89e0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/BudgetSummaryResponse.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; + +public record BudgetSummaryResponse( + + String yearMonth, + BigDecimal totalPlannedBudgetSum, + BigDecimal totalActualExpenseSum, + BigDecimal totalRemainingBudget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/CategoryBudgetResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/CategoryBudgetResponse.java new file mode 100644 index 0000000..0134252 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/CategoryBudgetResponse.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; + +import java.math.BigDecimal; + +public record CategoryBudgetResponse( + Long categoryId, + String categoryName, + BigDecimal totalBudget, + BigDecimal expectedExpense +) { + public static CategoryBudgetResponse from(Budget budget) { + Long categoryId = budget.getTransactionCategory() != null ? budget.getTransactionCategory().getId() : null; + String categoryName = budget.getTransactionCategory() != null ? budget.getTransactionCategory().getName() : null; + return new CategoryBudgetResponse(categoryId, categoryName, budget.getTotalBudget(), budget.getExpectedExpense()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/MyBudgetResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/MyBudgetResponse.java new file mode 100644 index 0000000..5a86327 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/MyBudgetResponse.java @@ -0,0 +1,11 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record MyBudgetResponse( + String yearMonth, + BigDecimal totalBudget, + List categoryBudgets +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PairBudgetDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PairBudgetDetailResponse.java new file mode 100644 index 0000000..6b4882b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PairBudgetDetailResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +public record PairBudgetDetailResponse( + UserBudgetDetailResponse me, + UserBudgetDetailResponse target +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PublicMonthlyBudgetResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PublicMonthlyBudgetResponse.java new file mode 100644 index 0000000..6ddccef --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/PublicMonthlyBudgetResponse.java @@ -0,0 +1,11 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; + +public record PublicMonthlyBudgetResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalBudget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetCompareResponse.java new file mode 100644 index 0000000..0ef29c8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetCompareResponse.java @@ -0,0 +1,27 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; + +import java.math.BigDecimal; + +public record UserBudgetCompareResponse( + BudgetCompareType type, + String yearMonth, + Long myUserId, + String myLabel, + BigDecimal myAmount, + Long targetUserId, + String targetLabel, + BigDecimal targetAmount, + BigDecimal difference +) { + public static UserBudgetCompareResponse of(BudgetCompareType type, String yearMonth, + Long myUserId, String myLabel, BigDecimal myAmount, + Long targetUserId, String targetLabel, BigDecimal targetAmount) { + return new UserBudgetCompareResponse( + type, yearMonth, + myUserId, myLabel, myAmount, + targetUserId, targetLabel, targetAmount, + myAmount.subtract(targetAmount)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetDetailResponse.java new file mode 100644 index 0000000..af589c0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/dto/response/UserBudgetDetailResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.budget.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record UserBudgetDetailResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalBudget, + List categoryBudgets +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/entity/Budget.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/entity/Budget.java new file mode 100644 index 0000000..4a43af3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/entity/Budget.java @@ -0,0 +1,94 @@ +package com.chaewookim.accountbookformoms.domain.budget.entity; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE budget SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "budget", + uniqueConstraints = { + @UniqueConstraint(name = "uk_budget_user_month_category", + columnNames = {"user_id", "`year_month`", "category_id"}) + }, + indexes = { + @Index(name = "idx_budget_user_year_month", + columnList = "user_id, `year_month`") + }) +public class Budget extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private TransactionCategory transactionCategory; + + @Column(nullable = false, name = "`year_month`") + private String yearMonth; + + @Column(nullable = false) + private BigDecimal totalBudget; + + @Column(nullable = false) + private BigDecimal expectedExpense; + + @Column(name = "snapshot_category_id") + private Long snapshotCategoryId; + + @Column(name = "snapshot_category_name") + private String snapshotCategoryName; + + @Column(nullable = false) + private boolean categoryArchived = false; + + @Builder + public Budget(User user, TransactionCategory transactionCategory, String yearMonth, BigDecimal totalBudget, BigDecimal expectedExpense) { + this.user = user; + this.transactionCategory = transactionCategory; + this.yearMonth = yearMonth; + this.totalBudget = totalBudget; + this.expectedExpense = expectedExpense; + syncCategorySnapshot(); + } + + public void update(BigDecimal totalBudget, BigDecimal expectedExpense) { + this.totalBudget = totalBudget; + this.expectedExpense = expectedExpense; + } + + public void syncCategorySnapshot() { + if (this.transactionCategory != null) { + this.snapshotCategoryId = this.transactionCategory.getId(); + this.snapshotCategoryName = this.transactionCategory.getName(); + this.categoryArchived = false; + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/enums/BudgetCompareType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/enums/BudgetCompareType.java new file mode 100644 index 0000000..d0b8891 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/enums/BudgetCompareType.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.budget.enums; + +public enum BudgetCompareType { + AGE, + AMOUNT, + CATEGORY, + LOCATION +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/error/BudgetErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/error/BudgetErrorCode.java new file mode 100644 index 0000000..c78b14c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/error/BudgetErrorCode.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.budget.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BudgetErrorCode implements BaseErrorCode { + + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "연/월 값이 올바르지 않습니다."), + INVALID_AMOUNT_RANGE(HttpStatus.BAD_REQUEST, "금액 구간이 올바르지 않습니다."), + BIRTH_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "나이대 비교를 위해 생년월일이 필요합니다."), + CATEGORY_ID_REQUIRED(HttpStatus.BAD_REQUEST, "카테고리 비교를 위해 카테고리 ID가 필요합니다."), + RADIUS_REQUIRED(HttpStatus.BAD_REQUEST, "위치 비교를 위해 반경(km)이 필요합니다."), + INVALID_RADIUS(HttpStatus.BAD_REQUEST, "반경은 0.1km 이상 50km 이하여야 합니다."), + LOCATION_PAIR_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "위치 기반 1:1 비교는 지원하지 않습니다."), + CANNOT_COMPARE_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 비교할 수 없습니다."), + TARGET_NOT_PUBLIC(HttpStatus.FORBIDDEN, "비공개 사용자의 예산은 조회할 수 없습니다."), + BUDGET_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 해당 월/카테고리에 설정된 예산이 존재합니다."), + BUDGET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 예산을 찾을 수 없습니다."), + BUDGET_FORBIDDEN(HttpStatus.FORBIDDEN, "해당 예산에 대한 수정/삭제 권한이 없습니다."), + NO_SOURCE_BUDGET(HttpStatus.BAD_REQUEST, "복사할 예산이 있는 이전 월이 없습니다."), + TARGET_MONTH_NOT_EMPTY(HttpStatus.CONFLICT, "이미 이번 달에 등록된 예산이 있어 불러올 수 없습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/budget/event/BudgetExceededCheckEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/event/BudgetExceededCheckEvent.java new file mode 100644 index 0000000..5fb001e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/budget/event/BudgetExceededCheckEvent.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.budget.event; + +public record BudgetExceededCheckEvent( + + Long userId, + String yearMonth, + Long categoryId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/AdminCommentController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/AdminCommentController.java new file mode 100644 index 0000000..0dbfc4e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/AdminCommentController.java @@ -0,0 +1,63 @@ +package com.chaewookim.accountbookformoms.domain.comment.api; + +import com.chaewookim.accountbookformoms.domain.comment.application.AdminCommentService; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.AdminCommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Comment API") +@RestController +@RequestMapping("/api/v1/admin/comments") +@RequiredArgsConstructor +public class AdminCommentController { + + private final AdminCommentService adminCommentService; + + @Operation(summary = "관리자 댓글 목록", + description = "댓글을 admin_deleted / user-deleted 포함 원문 그대로 반환합니다. " + + "referenceType/referenceId 로 필터 가능.") + @PageableAsQueryParam + @GetMapping + public ResponseEntity>> list( + @RequestParam(required = false) ReferenceType referenceType, + @RequestParam(required = false) Long referenceId, + @Parameter(hidden = true) + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + return ResponseEntity.ok(ApiResponse.success( + adminCommentService.list(referenceType, referenceId, pageable))); + } + + @Operation(summary = "관리자 댓글 삭제", description = "관리자 권한으로 댓글을 삭제 표시합니다.") + @DeleteMapping("/{comment-id}") + public ResponseEntity> delete( + @PathVariable("comment-id") Long commentId + ) { + return ResponseEntity.ok(ApiResponse.success(adminCommentService.deleteByAdmin(commentId))); + } + + @Operation(summary = "관리자 댓글 일괄 삭제", description = "관리자 권한으로 여러 댓글을 일괄 삭제 표시합니다.") + @DeleteMapping("/bulk") + public ResponseEntity>> deleteBulk( + @org.springframework.web.bind.annotation.RequestBody java.util.List commentIds + ) { + return ResponseEntity.ok(ApiResponse.success(adminCommentService.deleteBulkByAdmin(commentIds))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/CommentController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/CommentController.java new file mode 100644 index 0000000..4e15fb2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/api/CommentController.java @@ -0,0 +1,122 @@ +package com.chaewookim.accountbookformoms.domain.comment.api; + +import com.chaewookim.accountbookformoms.domain.comment.application.CommentService; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentCreateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentUpdateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.CommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.CommentThreadResponse; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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 java.util.List; + +@Tag(name = "Comment API") +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 추가", description = "게시물에 댓글을 추가합니다.") + @PostMapping("/{post-id}") + public ResponseEntity> create( + @PathVariable("post-id") Long postId, + @RequestBody @Valid CommentCreateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(commentService.create(postId, request, userId))); + } + + @Operation(summary = "대댓글 추가", description = "댓글에 대댓글을 추가합니다.") + @PostMapping("/{post-id}/{comment-id}") + public ResponseEntity> reply( + @PathVariable("post-id") Long postId, + @PathVariable("comment-id") Long parentCommentId, + @RequestBody @Valid CommentCreateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(commentService.reply(postId, parentCommentId, request, userId))); + } + + @Operation(summary = "댓글 수정", description = "본인의 댓글을 수정합니다.") + @PatchMapping("/{comment-id}") + public ResponseEntity> update( + @PathVariable("comment-id") Long commentId, + @RequestBody @Valid CommentUpdateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(commentService.update(commentId, request, userId))); + } + + @Operation(summary = "Q&A 답변 채택", description = "Q&A 게시물 작성자가 답변을 채택합니다.") + @PatchMapping("/{comment-id}/accept") + public ResponseEntity> accept( + @PathVariable("comment-id") Long commentId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(ApiResponse.success( + commentService.acceptAnswer(commentId, userPrincipal.getUserId()))); + } + + @Operation(summary = "댓글 삭제", description = "본인의 댓글을 삭제합니다 (Soft Delete).") + @DeleteMapping("/{comment-id}") + public ResponseEntity> delete( + @PathVariable("comment-id") Long commentId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(commentService.delete(commentId, userId))); + } + + @Operation(summary = "댓글 조회 (전체)", description = "게시물에 달린 댓글 전체를 flat 리스트로 반환합니다.") + @GetMapping("/{post-id}") + public ResponseEntity>> list( + @PathVariable("post-id") Long postId, + @RequestParam ReferenceType referenceType, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long viewerId = principal == null ? null : principal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(commentService.list(postId, referenceType, viewerId))); + } + + @Operation(summary = "댓글 스레드 페이지 조회", + description = "top-level 댓글을 페이지 단위로 반환하고 각 스레드에 대댓글을 포함합니다.") + @PageableAsQueryParam + @GetMapping("/{post-id}/threads") + public ResponseEntity>> listThreads( + @PathVariable("post-id") Long postId, + @RequestParam ReferenceType referenceType, + @Parameter(hidden = true) + @PageableDefault(size = 20) Pageable pageable, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long viewerId = principal == null ? null : principal.getUserId(); + return ResponseEntity.ok(ApiResponse.success( + commentService.listThreads(postId, referenceType, pageable, viewerId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentService.java new file mode 100644 index 0000000..64e8cd8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentService.java @@ -0,0 +1,61 @@ +package com.chaewookim.accountbookformoms.domain.comment.application; + +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.AdminCommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminCommentService { + + private final CommentRepository commentRepository; + + public Long deleteByAdmin(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + comment.markAsAdminDeleted(); + return comment.getId(); + } + + public java.util.List deleteBulkByAdmin(java.util.List commentIds) { + java.util.List comments = commentRepository.findAllById(commentIds); + for (Comment comment : comments) { + comment.markAsAdminDeleted(); + } + return comments.stream().map(Comment::getId).toList(); + } + + + /** + * 관리자 댓글 목록 조회. + *

Comment 엔티티에는 {@code @SQLRestriction} 이 없어 소프트 삭제된 댓글도 findAll 로 함께 조회된다. + * 원문(content)을 마스킹 없이 반환.

+ * + * @param referenceType null 이면 전체 + * @param referenceId null 이면 전체 (referenceType 과 함께 있어야 함) + */ + @Transactional(readOnly = true) + public Page list(ReferenceType referenceType, + Long referenceId, + Pageable pageable) { + Page page; + if (referenceType != null && referenceId != null) { + page = commentRepository + .findByReferenceIdAndReferenceType(referenceId, referenceType, pageable); + } else if (referenceType != null) { + page = commentRepository.findByReferenceType(referenceType, pageable); + } else { + page = commentRepository.findAll(pageable); + } + return page.map(AdminCommentResponse::from); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentService.java new file mode 100644 index 0000000..9ed34db --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentService.java @@ -0,0 +1,238 @@ +package com.chaewookim.accountbookformoms.domain.comment.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentCreateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentUpdateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.CommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseParticipantRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseParticipant; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.like.application.PostLikeService; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final BoardRepository boardRepository; + private final GroupPurchaseRepository groupPurchaseRepository; + private final GroupPurchaseParticipantRepository groupPurchaseParticipantRepository; + private final UserRepository userRepository; + private final PostLikeService likeService; + + @Transactional + public Long create(Long postId, CommentCreateRequest request, Long userId) { + validatePostExists(postId, request.referenceType()); + Comment comment = Comment.builder() + .userId(userId) + .referenceId(postId) + .referenceType(request.referenceType()) + .parentId(null) + .content(request.content()) + .isSecret(request.isSecret()) + .build(); + return commentRepository.save(comment).getId(); + } + + @Transactional + public Long reply(Long postId, Long parentCommentId, CommentCreateRequest request, Long userId) { + validatePostExists(postId, request.referenceType()); + Comment parent = findCommentOrThrow(parentCommentId); + + if (parent.getParentId() != null) { + throw new CustomException(CommentErrorCode.COMMENT_REPLY_DEPTH_EXCEEDED); + } + if (!parent.getReferenceId().equals(postId) || parent.getReferenceType() != request.referenceType()) { + throw new CustomException(CommentErrorCode.COMMENT_REFERENCE_MISMATCH); + } + + Comment reply = Comment.builder() + .userId(userId) + .referenceId(postId) + .referenceType(request.referenceType()) + .parentId(parentCommentId) + .content(request.content()) + .isSecret(request.isSecret()) + .build(); + return commentRepository.save(reply).getId(); + } + + @Transactional + public Long update(Long commentId, CommentUpdateRequest request, Long userId) { + Comment comment = findCommentOrThrow(commentId); + validateOwner(comment, userId); + comment.update(request.content()); + return comment.getId(); + } + + @Transactional + public Long delete(Long commentId, Long userId) { + Comment comment = findCommentOrThrow(commentId); + validateOwner(comment, userId); + commentRepository.delete(comment); + likeService.evictCount( + com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType.COMMENT, + comment.getId()); + return comment.getId(); + } + + @Transactional + public Long acceptAnswer(Long commentId, Long requesterId) { + Comment comment = findCommentOrThrow(commentId); + if (comment.getReferenceType() != ReferenceType.QNA) { + throw new CustomException(CommentErrorCode.COMMENT_ACCEPT_NOT_QNA); + } + if (comment.getParentId() != null) { + throw new CustomException(CommentErrorCode.COMMENT_ACCEPT_REPLY_NOT_ALLOWED); + } + Board board = boardRepository.findById(comment.getReferenceId()) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + if (!board.getUserId().equals(requesterId)) { + throw new CustomException(BoardErrorCode.BOARD_ACCESS_DENIED); + } + + List siblings = commentRepository + .findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc( + comment.getReferenceId(), ReferenceType.QNA); + for (Comment sibling : siblings) { + if (sibling.getParentId() == null && sibling.isAccepted() && !sibling.getId().equals(commentId)) { + sibling.setAccepted(false); + } + } + comment.setAccepted(true); + board.setResolved(true); + return comment.getId(); + } + + public List list(Long postId, ReferenceType referenceType, Long viewerId) { + List comments = commentRepository + .findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc(postId, referenceType); + return enrich(comments, viewerId, postId, referenceType); + } + + public org.springframework.data.domain.Page listThreads( + Long postId, + ReferenceType referenceType, + org.springframework.data.domain.Pageable pageable, + Long viewerId + ) { + org.springframework.data.domain.Page topLevel = commentRepository + .findByReferenceIdAndReferenceTypeAndParentIdIsNullOrderByCreatedAtAsc(postId, referenceType, pageable); + List parentIds = topLevel.getContent().stream().map(Comment::getId).toList(); + List replies = parentIds.isEmpty() + ? List.of() + : commentRepository.findByParentIdInOrderByCreatedAtAsc(parentIds); + List all = new java.util.ArrayList<>(topLevel.getContent()); + all.addAll(replies); + Map byId = enrich(all, viewerId, postId, referenceType).stream() + .collect(Collectors.toMap(CommentResponse::id, r -> r)); + Map> repliesByParent = new java.util.HashMap<>(); + replies.forEach(r -> repliesByParent + .computeIfAbsent(r.getParentId(), k -> new java.util.ArrayList<>()) + .add(byId.get(r.getId()))); + return topLevel.map(parent -> new com.chaewookim.accountbookformoms.domain.comment.dto.response.CommentThreadResponse( + byId.get(parent.getId()), + repliesByParent.getOrDefault(parent.getId(), List.of()) + )); + } + + private List enrich(List comments, Long viewerId, Long postId, ReferenceType referenceType) { + List ids = comments.stream().map(Comment::getId).toList(); + Map nicknames = loadNicknames(comments.stream().map(Comment::getUserId).toList()); + Map likeCounts = likeService.countByTargets(LikeTargetType.COMMENT, ids); + Set liked = likeService.likedTargets(LikeTargetType.COMMENT, ids, viewerId); + + Long postCreatorId = null; + Set participantIds = Set.of(); + + if (referenceType == ReferenceType.GROUPPURCHASE && postId != null) { + GroupPurchase gp = groupPurchaseRepository.findById(postId).orElse(null); + if (gp != null) { + postCreatorId = gp.getCreatorId(); + participantIds = groupPurchaseParticipantRepository.findByGroupPurchaseId(postId) + .stream().map(GroupPurchaseParticipant::getUserId).collect(Collectors.toSet()); + } + } else if ((referenceType == ReferenceType.QNA || referenceType == ReferenceType.KNOWHOW) && postId != null) { + Board board = boardRepository.findById(postId).orElse(null); + if (board != null) { + postCreatorId = board.getUserId(); + } + } + + final Long finalPostCreatorId = postCreatorId; + final Set finalParticipantIds = participantIds; + + return comments.stream() + .map(c -> { + String authorRole = "NONE"; + if (finalPostCreatorId != null && finalPostCreatorId.equals(c.getUserId())) { + authorRole = "CREATOR"; + } else if (finalParticipantIds.contains(c.getUserId())) { + authorRole = "PARTICIPANT"; + } + + boolean canRead = !c.isSecret() || (viewerId != null && (viewerId.equals(c.getUserId()) || viewerId.equals(finalPostCreatorId))); + + String overrideContent = canRead ? null : "비밀 댓글입니다."; + + return CommentResponse.from( + c, + nicknames.get(c.getUserId()), + likeCounts.getOrDefault(c.getId(), 0L), + liked.contains(c.getId()), + authorRole, + overrideContent); + }) + .toList(); + } + + private Map loadNicknames(List userIds) { + if (userIds.isEmpty()) return Map.of(); + return userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getUsername, (a, b) -> a)); + } + + private Comment findCommentOrThrow(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + } + + private void validateOwner(Comment comment, Long userId) { + if (!comment.getUserId().equals(userId)) { + throw new CustomException(CommentErrorCode.COMMENT_ACCESS_DENIED); + } + } + + private void validatePostExists(Long postId, ReferenceType referenceType) { + if (referenceType == ReferenceType.QNA || referenceType == ReferenceType.KNOWHOW) { + if (!boardRepository.existsById(postId)) { + throw new CustomException(BoardErrorCode.BOARD_NOT_FOUND); + } + } else if (referenceType == ReferenceType.GROUPPURCHASE) { + if (!groupPurchaseRepository.existsById(postId)) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND); + } + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dao/CommentRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dao/CommentRepository.java new file mode 100644 index 0000000..386ab30 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dao/CommentRepository.java @@ -0,0 +1,27 @@ +package com.chaewookim.accountbookformoms.domain.comment.dao; + +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + List findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc( + Long referenceId, ReferenceType referenceType); + + Page findByReferenceIdAndReferenceTypeAndParentIdIsNullOrderByCreatedAtAsc( + Long referenceId, ReferenceType referenceType, Pageable pageable); + + List findByParentIdInOrderByCreatedAtAsc(java.util.Collection parentIds); + + Page findByReferenceType(ReferenceType referenceType, Pageable pageable); + + Page findByReferenceIdAndReferenceType( + Long referenceId, ReferenceType referenceType, Pageable pageable); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..3801e72 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentCreateRequest.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.comment.dto.request; + +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CommentCreateRequest( + @NotNull ReferenceType referenceType, + @NotBlank String content, + Boolean isSecret +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentUpdateRequest.java new file mode 100644 index 0000000..e133ac0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/request/CommentUpdateRequest.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest( + @NotBlank String content +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/AdminCommentResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/AdminCommentResponse.java new file mode 100644 index 0000000..c4ac327 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/AdminCommentResponse.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.comment.dto.response; + +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; + +import java.time.LocalDateTime; + +/** + * 관리자 조회용 댓글 응답. 원문 내용을 마스킹 없이 반환하고 관리자/유저 삭제 여부를 그대로 노출. + */ +public record AdminCommentResponse( + Long id, + Long userId, + Long referenceId, + ReferenceType referenceType, + Long parentId, + String content, + boolean adminDeleted, + boolean userDeleted, + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + public static AdminCommentResponse from(Comment comment) { + return new AdminCommentResponse( + comment.getId(), + comment.getUserId(), + comment.getReferenceId(), + comment.getReferenceType(), + comment.getParentId(), + comment.getContent(), + comment.isAdminDeleted(), + comment.isDeleted(), + comment.getCreatedAt(), + comment.getDeletedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..334de6d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentResponse.java @@ -0,0 +1,72 @@ +package com.chaewookim.accountbookformoms.domain.comment.dto.response; + +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; + +import java.time.LocalDateTime; + +public record CommentResponse( + Long id, + Long userId, + String authorNickname, + Long referenceId, + ReferenceType referenceType, + Long parentId, + String content, + boolean deleted, + boolean accepted, + long likeCount, + boolean liked, + LocalDateTime createdAt, + LocalDateTime updatedAt, + boolean isSecret, + String authorRole +) { + private static final String DELETED_MESSAGE = "삭제된 댓글입니다."; + private static final String ADMIN_DELETED_MESSAGE = "관리자가 삭제한 댓글입니다."; + private static final String UNKNOWN_AUTHOR = "탈퇴한 사용자"; + + public static CommentResponse from(Comment comment) { + return from(comment, null, 0L, false, "NONE"); + } + + public static CommentResponse from(Comment comment, String authorNickname) { + return from(comment, authorNickname, 0L, false, "NONE"); + } + + public static CommentResponse from(Comment comment, String authorNickname, long likeCount, boolean liked, String authorRole) { + return from(comment, authorNickname, likeCount, liked, authorRole, null); + } + + public static CommentResponse from(Comment comment, String authorNickname, long likeCount, boolean liked, String authorRole, String overrideContent) { + boolean deleted = comment.isDeleted(); + boolean adminDeleted = comment.isAdminDeleted(); + String content; + if (adminDeleted) { + content = ADMIN_DELETED_MESSAGE; + } else if (deleted) { + content = DELETED_MESSAGE; + } else if (overrideContent != null) { + content = overrideContent; + } else { + content = comment.getContent(); + } + return new CommentResponse( + comment.getId(), + comment.getUserId(), + authorNickname != null ? authorNickname : UNKNOWN_AUTHOR, + comment.getReferenceId(), + comment.getReferenceType(), + comment.getParentId(), + content, + deleted || adminDeleted, + comment.isAccepted(), + likeCount, + liked, + comment.getCreatedAt(), + comment.getUpdatedAt(), + comment.isSecret(), + authorRole + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentThreadResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentThreadResponse.java new file mode 100644 index 0000000..cb090e2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/dto/response/CommentThreadResponse.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.comment.dto.response; + +import java.util.List; + +public record CommentThreadResponse( + CommentResponse parent, + List replies +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/entity/Comment.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/entity/Comment.java new file mode 100644 index 0000000..8d31fe3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/entity/Comment.java @@ -0,0 +1,95 @@ +package com.chaewookim.accountbookformoms.domain.comment.entity; + +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +@Entity +@Getter +@Table( + name = "comment", + indexes = { + @Index( + name = "idx_comment_reference", + columnList = "reference_id, reference_type" + ), + @Index( + name = "idx_comment_user", + columnList = "user_id, created_at" + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE comment SET deleted_at = NOW() WHERE id = ?") +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "reference_id", nullable = false) + private Long referenceId; + + @Column(name = "reference_type", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private ReferenceType referenceType; + + @Column(name = "parent_id") + private Long parentId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "admin_deleted", nullable = false) + private boolean adminDeleted; + + @Column(name = "is_accepted", nullable = false) + private boolean accepted; + + @Column(name = "is_secret", nullable = false) + private boolean isSecret; + + @Builder + public Comment(Long userId, Long referenceId, ReferenceType referenceType, Long parentId, String content, Boolean isSecret) { + this.userId = userId; + this.referenceId = referenceId; + this.referenceType = referenceType; + this.parentId = parentId; + this.content = content; + this.adminDeleted = false; + this.accepted = false; + this.isSecret = isSecret != null ? isSecret : false; + } + + public void update(String content) { + this.content = content; + } + + public void markAsAdminDeleted() { + this.adminDeleted = true; + } + + public void setAccepted(boolean accepted) { + this.accepted = accepted; + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/enums/ReferenceType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/enums/ReferenceType.java new file mode 100644 index 0000000..8a25eed --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/enums/ReferenceType.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.comment.enums; + +public enum ReferenceType { + QNA, + KNOWHOW, + GROUPPURCHASE +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/comment/error/CommentErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/error/CommentErrorCode.java new file mode 100644 index 0000000..f993dbb --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/comment/error/CommentErrorCode.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.comment.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommentErrorCode implements BaseErrorCode { + + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."), + COMMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "본인의 댓글만 수정/삭제할 수 있습니다."), + COMMENT_REPLY_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "대댓글에는 답글을 달 수 없습니다."), + COMMENT_REFERENCE_MISMATCH(HttpStatus.BAD_REQUEST, "부모 댓글의 게시물 정보와 일치하지 않습니다."), + COMMENT_ACCEPT_NOT_QNA(HttpStatus.BAD_REQUEST, "Q&A 댓글만 채택할 수 있습니다."), + COMMENT_ACCEPT_REPLY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "대댓글은 채택할 수 없습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/api/DashboardController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/api/DashboardController.java new file mode 100644 index 0000000..8838032 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/api/DashboardController.java @@ -0,0 +1,36 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.api; + +import com.chaewookim.accountbookformoms.domain.dashboard.application.DashboardService; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.DashboardResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "대시보드(Dashboard)", description = "대시보드 및 통계 데이터 조회 API") +@RestController +@RequestMapping("/api/v1/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + @Operation(summary = "대시보드 조회", description = "카테고리별 통계, 소비 추이, 예산 현황 등 종합 데이터 제공") + @GetMapping + public ResponseEntity> getDashboard( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam String yearMonth + ) { + if (userPrincipal == null) { + return ResponseEntity.status(401) + .body(ApiResponse.error("인증이 필요합니다.")); + } + return ResponseEntity.ok(ApiResponse.success( + dashboardService.getDashboard(userPrincipal.getUserId(), yearMonth) + )); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardService.java new file mode 100644 index 0000000..c391a9b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardService.java @@ -0,0 +1,89 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.application; + +import com.chaewookim.accountbookformoms.domain.asset.application.MonthlyAllocationService; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetService; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.BudgetStatusResponse; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.DashboardResponse; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.MonthlyTrendResponse; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.SummaryResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.application.PortfolioService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DashboardService { + + private final BudgetService budgetService; + private final TransactionRepository transactionRepository; + private final PortfolioService portfolioService; + private final MonthlyAllocationService monthlyAllocationService; + + @Cacheable(value = RedisConfig.CACHE_DASHBOARD, key = "#userId + ':' + #yearMonth") + public DashboardResponse getDashboard(Long userId, String yearMonth) { + + Map categoryExpenses = transactionRepository.sumCategoryExpense(userId, yearMonth) + .stream() + .collect(Collectors.toMap( + row -> toString(row[0]), + row -> toBigDecimal(row[1]), + BigDecimal::add + )); + + List trends = transactionRepository.sumMonthlyTrends(userId, LocalDate.now().minusMonths(6)) + .stream() + .map(row -> new MonthlyTrendResponse( + toString(row[0]), + toBigDecimal(row[1]), + toBigDecimal(row[2]) + )) + .toList(); + + var budgetSummary = budgetService.getMonthlyBudgetSummary(userId, yearMonth); + SummaryResponse summary = new SummaryResponse(budgetSummary.totalActualExpenseSum()); + + var portfolio = portfolioService.getMyPortfolio(userId, yearMonth); + var allocation = monthlyAllocationService.computeMonthlyAllocation( + userId, + yearMonth, + portfolio.totalIncome() + ); + var goalProgress = monthlyAllocationService.buildGoalProgress(userId); + var totalAsset = monthlyAllocationService.computeTotalAsset(userId); + + return new DashboardResponse( + categoryExpenses, + trends, + new BudgetStatusResponse(budgetSummary), + summary, + allocation, + goalProgress, + totalAsset + ); + } + + private static String toString(Object value) { + return value == null ? "" : value.toString(); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal bd) { + return bd; + } + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/BudgetStatusResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/BudgetStatusResponse.java new file mode 100644 index 0000000..a072c74 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/BudgetStatusResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetSummaryResponse; + +import java.io.Serializable; +import java.math.BigDecimal; + +public record BudgetStatusResponse( + + BigDecimal totalPlanned, + BigDecimal actualExpense, + BigDecimal remaining +) implements Serializable { + public BudgetStatusResponse(BudgetSummaryResponse b) { + this( + b.totalPlannedBudgetSum(), + b.totalActualExpenseSum(), + b.totalRemainingBudget() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/DashboardResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/DashboardResponse.java new file mode 100644 index 0000000..84a9d50 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/DashboardResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.dto.response; + +import com.chaewookim.accountbookformoms.domain.asset.dto.response.GoalProgressResponse; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationResponse; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public record DashboardResponse( + + Map categoryExpenses, // 카테고리별 지출 + List trends, // 최근 6개월 추이 + BudgetStatusResponse budgetStatus, // 예산 상태 + SummaryResponse summary, // 요약 (총지출, 전월비 등) + MonthlyAllocationResponse allocation, // 저축률/투자율 + List goalProgress, // 목표 달성률 + BigDecimal totalAsset +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/MonthlyTrendResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/MonthlyTrendResponse.java new file mode 100644 index 0000000..e091e88 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/MonthlyTrendResponse.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.dto.response; + +import java.io.Serializable; +import java.math.BigDecimal; + +public record MonthlyTrendResponse( + + String yearMonth, + BigDecimal income, + BigDecimal expense +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/SummaryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/SummaryResponse.java new file mode 100644 index 0000000..61bae20 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/dashboard/dto/response/SummaryResponse.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.dto.response; + +import java.io.Serializable; +import java.math.BigDecimal; + +public record SummaryResponse( + + BigDecimal totalExpense +) implements Serializable { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/api/ExpenseCompareController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/api/ExpenseCompareController.java new file mode 100644 index 0000000..ac45c15 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/api/ExpenseCompareController.java @@ -0,0 +1,93 @@ +package com.chaewookim.accountbookformoms.domain.expense.api; + +import com.chaewookim.accountbookformoms.domain.expense.application.ExpenseCompareService; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.ExpenseCompareRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.PublicExpenseFilterRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.ExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.MyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PairExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PublicMonthlyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "지출 비교(Expense Compare)", description = "내 지출 조회 및 공개 사용자 지출 비교 API (고정/변동 구분)") +@RestController +@RequestMapping("/api/v1/expenses") +@RequiredArgsConstructor +public class ExpenseCompareController { + + private final ExpenseCompareService expenseCompareService; + + @Operation(summary = "내 월별 지출 조회", description = "월 총 지출과 고정/변동별 카테고리 합계") + @GetMapping("/me") + public ResponseEntity> getMyMonthlyExpense( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + expenseCompareService.getMyMonthlyExpense(principal.getUserId(), yearMonth))); + } + + @Operation(summary = "공개 사용자 월별 총 지출 목록", + description = "포트폴리오 공개 사용자들의 월별 총 지출. 연/월/금액 구간 필터 지원") + @GetMapping("/compare/users") + public ResponseEntity>> getPublicMonthlyExpenses( + @ParameterObject @Valid @ModelAttribute PublicExpenseFilterRequest filter + ) { + return ResponseEntity.ok(ApiResponse.success(expenseCompareService.getPublicMonthlyExpenses(filter))); + } + + @Operation(summary = "그룹 평균과 내 지출 비교", + description = "type: AGE(본인 나이대) / AMOUNT(금액 구간) / CATEGORY(특정 카테고리). 고정/변동 분리 반환") + @GetMapping("/compare") + public ResponseEntity> compare( + @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject @Valid @ModelAttribute ExpenseCompareRequest request + ) { + return ResponseEntity.ok(ApiResponse.success( + expenseCompareService.compareWithGroup(principal.getUserId(), request))); + } + + @Operation(summary = "선택 사용자와 본인 지출 세부 조회", + description = "본인과 선택한 공개 사용자의 월 지출(고정/변동/카테고리별)을 함께 반환") + @GetMapping("/compare/users/{targetUserId}/details") + public ResponseEntity> getPairExpenseDetails( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + expenseCompareService.getPairExpenseDetails(principal.getUserId(), targetUserId, yearMonth))); + } + + @Operation(summary = "선택 사용자와 본인 지출 비교", + description = "type: AGE / AMOUNT / CATEGORY. CATEGORY는 categoryId 필수. 고정/변동 분리 반환") + @GetMapping("/compare/users/{targetUserId}") + public ResponseEntity> compareWithUser( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam ExpenseCompareType type, + @RequestParam String yearMonth, + @RequestParam(required = false) Long categoryId + ) { + return ResponseEntity.ok(ApiResponse.success( + expenseCompareService.compareWithUser(principal.getUserId(), targetUserId, type, yearMonth, categoryId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseCompareService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseCompareService.java new file mode 100644 index 0000000..300a6c1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseCompareService.java @@ -0,0 +1,428 @@ +package com.chaewookim.accountbookformoms.domain.expense.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.ExpenseCompareRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.PublicExpenseFilterRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.CategoryExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.ExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.MyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PairExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PublicMonthlyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; +import com.chaewookim.accountbookformoms.domain.expense.error.ExpenseErrorCode; +import com.chaewookim.accountbookformoms.domain.user.application.UserLocationService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExpenseCompareService { + + private static final TransactionType TYPE = TransactionType.EXPENSE; + private static final double MIN_RADIUS_KM = 0.1; + private static final double MAX_RADIUS_KM = 50.0; + + private final TransactionRepository transactionRepository; + private final FixedTransactionRepository fixedTransactionRepository; + private final UserRepository userRepository; + private final UserLocationService userLocationService; + private final ExpenseGroupCacheService groupCache; + + public MyExpenseResponse getMyMonthlyExpense(Long userId, String yearMonth) { + + YearMonth ym = parseYearMonth(yearMonth); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + List fixedCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(userId, TYPE, true, startDate, endDate)); + List variableCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(userId, TYPE, false, startDate, endDate)); + + BigDecimal fixedTotal = sumCategoryAmount(fixedCategories); + BigDecimal variableTotal = sumCategoryAmount(variableCategories); + + return new MyExpenseResponse( + yearMonth, + fixedTotal.add(variableTotal), + fixedTotal, + variableTotal, + fixedCategories, + variableCategories); + } + + public List getPublicMonthlyExpenses(PublicExpenseFilterRequest filter) { + + validateAmountRange(filter.minAmount(), filter.maxAmount()); + + YearMonth ym = buildYearMonth(filter.year(), filter.month()); + String yearMonth = ym.toString(); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + List fixedRows = fixedTransactionRepository.sumPublicMonthly(TYPE, startDate, endDate); + List variableRows = transactionRepository.sumPublicMonthly(TYPE, startDate, endDate); + + Map fixedSums = toUserSumMap(fixedRows); + Map variableSums = toUserSumMap(variableRows); + Map usernames = collectUsernames(fixedRows, variableRows); + + return usernames.entrySet().stream() + .map(e -> { + Long userId = e.getKey(); + BigDecimal fixed = fixedSums.getOrDefault(userId, BigDecimal.ZERO); + BigDecimal variable = variableSums.getOrDefault(userId, BigDecimal.ZERO); + return new PublicMonthlyExpenseResponse( + userId, e.getValue(), yearMonth, + fixed.add(variable), fixed, variable); + }) + .filter(r -> withinRange(r.totalExpense(), filter.minAmount(), filter.maxAmount())) + .sorted((a, b) -> b.totalExpense().compareTo(a.totalExpense())) + .toList(); + } + + public PairExpenseDetailResponse getPairExpenseDetails(Long myUserId, Long targetUserId, String yearMonth) { + + YearMonth ym = parseYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(ExpenseErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return new PairExpenseDetailResponse( + toUserExpenseDetail(me, ym, yearMonth), + toUserExpenseDetail(target, ym, yearMonth)); + } + + public UserExpenseCompareResponse compareWithUser(Long myUserId, Long targetUserId, + ExpenseCompareType type, String yearMonth, Long categoryId) { + + YearMonth ym = parseYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(ExpenseErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return switch (type) { + case AGE -> compareUsersByAge(me, target, ym, yearMonth); + case AMOUNT -> compareUsersByAmount(me, target, ym, yearMonth); + case CATEGORY -> compareUsersByCategory(me, target, ym, yearMonth, categoryId); + case LOCATION -> throw new CustomException(ExpenseErrorCode.LOCATION_PAIR_NOT_SUPPORTED); + }; + } + + public ExpenseCompareResponse compareWithGroup(Long userId, ExpenseCompareRequest request) { + + YearMonth ym = parseYearMonth(request.yearMonth()); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + return switch (request.type()) { + case AGE -> compareByAge(user, ym, request.yearMonth()); + case AMOUNT -> compareByAmount(user, ym, request.yearMonth(), request.minAmount(), request.maxAmount()); + case CATEGORY -> compareByCategory(user, ym, request.yearMonth(), request.categoryId()); + case LOCATION -> compareByLocation(user, ym, request.yearMonth(), request.radiusKm()); + }; + } + + private ExpenseCompareResponse compareByLocation(User user, YearMonth ym, String yearMonth, Double radiusKm) { + + if (radiusKm == null) { + throw new CustomException(ExpenseErrorCode.RADIUS_REQUIRED); + } + if (radiusKm < MIN_RADIUS_KM || radiusKm > MAX_RADIUS_KM) { + throw new CustomException(ExpenseErrorCode.INVALID_RADIUS); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Set nearbyUserIds = userLocationService.findNearbyUserIds(user.getId(), radiusKm); + + Map fixedSums = nearbyUserIds.isEmpty() + ? Map.of() + : toUserSumMap(fixedTransactionRepository.sumPublicByUserIds(TYPE, startDate, endDate, nearbyUserIds)); + Map variableSums = nearbyUserIds.isEmpty() + ? Map.of() + : toUserSumMap(transactionRepository.sumPublicByUserIds(TYPE, startDate, endDate, nearbyUserIds)); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, ExpenseCompareType.LOCATION); + } + + private UserExpenseCompareResponse compareUsersByAge(User me, User target, YearMonth ym, String yearMonth) { + if (me.getBirthDate() == null || target.getBirthDate() == null) { + throw new CustomException(ExpenseErrorCode.BIRTH_DATE_REQUIRED); + } + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + return UserExpenseCompareResponse.of( + ExpenseCompareType.AGE, yearMonth, + me.getId(), decadeLabel(me.getBirthDate()), myFixed, myVariable, + target.getId(), decadeLabel(target.getBirthDate()), targetFixed, targetVariable); + } + + private UserExpenseCompareResponse compareUsersByAmount(User me, User target, YearMonth ym, String yearMonth) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + return UserExpenseCompareResponse.of( + ExpenseCompareType.AMOUNT, yearMonth, + me.getId(), "월 총 지출", myFixed, myVariable, + target.getId(), "월 총 지출", targetFixed, targetVariable); + } + + private UserExpenseCompareResponse compareUsersByCategory(User me, User target, YearMonth ym, + String yearMonth, Long categoryId) { + if (categoryId == null) { + throw new CustomException(ExpenseErrorCode.CATEGORY_ID_REQUIRED); + } + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + me.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + me.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + target.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + target.getId(), TYPE, categoryId, startDate, endDate)); + String categoryLabel = "카테고리 #" + categoryId; + return UserExpenseCompareResponse.of( + ExpenseCompareType.CATEGORY, yearMonth, + me.getId(), categoryLabel, myFixed, myVariable, + target.getId(), categoryLabel, targetFixed, targetVariable); + } + + private ExpenseCompareResponse compareByAge(User user, YearMonth ym, String yearMonth) { + + if (user.getBirthDate() == null) { + throw new CustomException(ExpenseErrorCode.BIRTH_DATE_REQUIRED); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + int currentYear = LocalDate.now().getYear(); + int age = currentYear - user.getBirthDate().getYear(); + int decadeStart = (age / 10) * 10; + LocalDate birthFrom = LocalDate.of(currentYear - (decadeStart + 9), 1, 1); + LocalDate birthTo = LocalDate.of(currentYear - decadeStart, 12, 31); + + Map fixedSums = groupCache.getAgeFixedSums(startDate, endDate, birthFrom, birthTo); + Map variableSums = groupCache.getAgeVariableSums(startDate, endDate, birthFrom, birthTo); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, ExpenseCompareType.AGE); + } + + private ExpenseCompareResponse compareByAmount(User user, YearMonth ym, String yearMonth, + BigDecimal minAmount, BigDecimal maxAmount) { + + validateAmountRange(minAmount, maxAmount); + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Map fixedSums = groupCache.getAmountFixedSums(startDate, endDate, minAmount, maxAmount); + Map variableSums = groupCache.getAmountVariableSums(startDate, endDate, minAmount, maxAmount); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, ExpenseCompareType.AMOUNT); + } + + private ExpenseCompareResponse compareByCategory(User user, YearMonth ym, String yearMonth, Long categoryId) { + + if (categoryId == null) { + throw new CustomException(ExpenseErrorCode.CATEGORY_ID_REQUIRED); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Map fixedSums = groupCache.getCategoryFixedSums(startDate, endDate, categoryId); + Map variableSums = groupCache.getCategoryVariableSums(startDate, endDate, categoryId); + + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + user.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + user.getId(), TYPE, categoryId, startDate, endDate)); + + return buildAverage(user.getId(), yearMonth, ExpenseCompareType.CATEGORY, + myFixed, myVariable, fixedSums, variableSums); + } + + private ExpenseCompareResponse buildGroupCompare(User user, YearMonth ym, String yearMonth, + Map fixedSums, + Map variableSums, + ExpenseCompareType type) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(user.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(user.getId(), TYPE, startDate, endDate)); + return buildAverage(user.getId(), yearMonth, type, myFixed, myVariable, fixedSums, variableSums); + } + + private ExpenseCompareResponse buildAverage(Long myUserId, String yearMonth, ExpenseCompareType type, + BigDecimal myFixed, BigDecimal myVariable, + Map fixedSums, + Map variableSums) { + Map othersFixed = new HashMap<>(fixedSums); + othersFixed.remove(myUserId); + Map othersVariable = new HashMap<>(variableSums); + othersVariable.remove(myUserId); + + Map merged = new HashMap<>(othersFixed); + othersVariable.forEach((k, v) -> merged.merge(k, v, BigDecimal::add)); + long sampleSize = merged.size(); + + BigDecimal averageFixed = average(othersFixed, sampleSize); + BigDecimal averageVariable = average(othersVariable, sampleSize); + + return ExpenseCompareResponse.of(type, yearMonth, myFixed, myVariable, averageFixed, averageVariable, sampleSize); + } + + private BigDecimal average(Map sums, long sampleSize) { + if (sampleSize <= 0) { + return BigDecimal.ZERO; + } + BigDecimal total = sums.values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add); + return total.divide(BigDecimal.valueOf(sampleSize), 2, RoundingMode.HALF_UP); + } + + private UserExpenseDetailResponse toUserExpenseDetail(User user, YearMonth ym, String yearMonth) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + List fixedCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(user.getId(), TYPE, true, startDate, endDate)); + List variableCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(user.getId(), TYPE, false, startDate, endDate)); + BigDecimal fixedTotal = sumCategoryAmount(fixedCategories); + BigDecimal variableTotal = sumCategoryAmount(variableCategories); + return new UserExpenseDetailResponse( + user.getId(), user.getUsername(), yearMonth, + fixedTotal.add(variableTotal), fixedTotal, variableTotal, + fixedCategories, variableCategories); + } + + private User loadPublicTarget(Long targetUserId) { + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (target.getUserSetting() == null || !Boolean.TRUE.equals(target.getUserSetting().getIsPortfolioPublic())) { + throw new CustomException(ExpenseErrorCode.TARGET_NOT_PUBLIC); + } + return target; + } + + private List toCategoryResponses(List rows) { + return rows.stream() + .map(r -> new CategoryExpenseResponse( + (Long) r[0], + (String) r[1], + toBigDecimal(r[2]))) + .toList(); + } + + private Map toUserSumMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put((Long) row[0], toBigDecimal(row[row.length - 1])); + } + return map; + } + + private Map collectUsernames(List rowsA, List rowsB) { + Map map = new HashMap<>(); + for (Object[] row : rowsA) { + map.putIfAbsent((Long) row[0], (String) row[1]); + } + for (Object[] row : rowsB) { + map.putIfAbsent((Long) row[0], (String) row[1]); + } + return map; + } + + private BigDecimal sumCategoryAmount(List categories) { + return categories.stream() + .map(CategoryExpenseResponse::amount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private boolean withinRange(BigDecimal value, BigDecimal min, BigDecimal max) { + if (min != null && value.compareTo(min) < 0) return false; + if (max != null && value.compareTo(max) > 0) return false; + return true; + } + + private String decadeLabel(LocalDate birthDate) { + int age = LocalDate.now().getYear() - birthDate.getYear(); + return ((age / 10) * 10) + "대"; + } + + private BigDecimal nullToZero(BigDecimal value) { + return value != null ? value : BigDecimal.ZERO; + } + + private BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } + + private YearMonth parseYearMonth(String yearMonth) { + try { + return YearMonth.parse(yearMonth); + } catch (DateTimeParseException | NullPointerException e) { + throw new CustomException(ExpenseErrorCode.INVALID_YEAR_MONTH); + } + } + + private YearMonth buildYearMonth(Integer year, Integer month) { + if (year == null || month == null) { + throw new CustomException(ExpenseErrorCode.INVALID_YEAR_MONTH); + } + try { + return YearMonth.of(year, month); + } catch (Exception e) { + throw new CustomException(ExpenseErrorCode.INVALID_YEAR_MONTH); + } + } + + private void validateAmountRange(BigDecimal minAmount, BigDecimal maxAmount) { + if (minAmount != null && maxAmount != null && minAmount.compareTo(maxAmount) > 0) { + throw new CustomException(ExpenseErrorCode.INVALID_AMOUNT_RANGE); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseGroupCacheService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseGroupCacheService.java new file mode 100644 index 0000000..9c28916 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/application/ExpenseGroupCacheService.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.expense.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 지출 그룹 평균 캐싱 wrapper. fixed/variable 각각 별도 캐시. + */ +@Service +@RequiredArgsConstructor +public class ExpenseGroupCacheService { + + private static final Long NO_EXCLUDE = -1L; + private static final TransactionType TYPE = TransactionType.EXPENSE; + + private final FixedTransactionRepository fixedTransactionRepository; + private final TransactionRepository transactionRepository; + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_AGE_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #birthFrom.toString(), #birthTo.toString())") + public Map getAgeFixedSums(LocalDate startDate, LocalDate endDate, LocalDate birthFrom, LocalDate birthTo) { + return toUserSumMap(fixedTransactionRepository.sumPublicByAgeRange(TYPE, startDate, endDate, birthFrom, birthTo, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_AGE_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #birthFrom.toString(), #birthTo.toString())") + public Map getAgeVariableSums(LocalDate startDate, LocalDate endDate, LocalDate birthFrom, LocalDate birthTo) { + return toUserSumMap(transactionRepository.sumPublicByAgeRange(TYPE, startDate, endDate, birthFrom, birthTo, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_AMOUNT_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #minAmount, #maxAmount)") + public Map getAmountFixedSums(LocalDate startDate, LocalDate endDate, BigDecimal minAmount, BigDecimal maxAmount) { + return toUserSumMap(fixedTransactionRepository.sumPublicByAmountRange(TYPE, startDate, endDate, minAmount, maxAmount, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_AMOUNT_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #minAmount, #maxAmount)") + public Map getAmountVariableSums(LocalDate startDate, LocalDate endDate, BigDecimal minAmount, BigDecimal maxAmount) { + return toUserSumMap(transactionRepository.sumPublicByAmountRange(TYPE, startDate, endDate, minAmount, maxAmount, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_CATEGORY_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #categoryId)") + public Map getCategoryFixedSums(LocalDate startDate, LocalDate endDate, Long categoryId) { + return toUserSumMap(fixedTransactionRepository.sumPublicCategoryByUser(TYPE, categoryId, startDate, endDate, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_EXPENSE_CATEGORY_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #categoryId)") + public Map getCategoryVariableSums(LocalDate startDate, LocalDate endDate, Long categoryId) { + return toUserSumMap(transactionRepository.sumPublicCategoryByUser(TYPE, categoryId, startDate, endDate, NO_EXCLUDE)); + } + + private static Map toUserSumMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(((Number) row[0]).longValue(), toBigDecimal(row[1])); + } + return map; + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/ExpenseCompareRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/ExpenseCompareRequest.java new file mode 100644 index 0000000..dd59018 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/ExpenseCompareRequest.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.request; + +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.math.BigDecimal; + +public record ExpenseCompareRequest( + + @NotNull(message = "비교 기준은 필수입니다.") + ExpenseCompareType type, + + @NotBlank(message = "연/월은 필수입니다.") + @Pattern(regexp = "^\\d{4}-(0[1-9]|1[0-2])$", message = "연/월은 YYYY-MM 형식이어야 합니다.") + String yearMonth, + + BigDecimal minAmount, + BigDecimal maxAmount, + Long categoryId, + + @DecimalMin(value = "0.1", message = "반경은 0.1km 이상이어야 합니다.") + @DecimalMax(value = "50.0", message = "반경은 50km 이하여야 합니다.") + Double radiusKm +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/PublicExpenseFilterRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/PublicExpenseFilterRequest.java new file mode 100644 index 0000000..08ea113 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/request/PublicExpenseFilterRequest.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.math.BigDecimal; + +public record PublicExpenseFilterRequest( + + @Min(1900) @Max(2100) + Integer year, + + @Min(1) @Max(12) + Integer month, + + BigDecimal minAmount, + BigDecimal maxAmount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/CategoryExpenseResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/CategoryExpenseResponse.java new file mode 100644 index 0000000..ca2bcfa --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/CategoryExpenseResponse.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import java.math.BigDecimal; + +public record CategoryExpenseResponse( + Long categoryId, + String categoryName, + BigDecimal amount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/ExpenseCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/ExpenseCompareResponse.java new file mode 100644 index 0000000..d1e9b5b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/ExpenseCompareResponse.java @@ -0,0 +1,32 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; + +import java.math.BigDecimal; + +public record ExpenseCompareResponse( + ExpenseCompareType type, + String yearMonth, + BigDecimal myAmount, + BigDecimal myFixedAmount, + BigDecimal myVariableAmount, + BigDecimal averageAmount, + BigDecimal averageFixedAmount, + BigDecimal averageVariableAmount, + long sampleSize, + BigDecimal difference +) { + public static ExpenseCompareResponse of(ExpenseCompareType type, String yearMonth, + BigDecimal myFixed, BigDecimal myVariable, + BigDecimal averageFixed, BigDecimal averageVariable, + long sampleSize) { + BigDecimal myAmount = myFixed.add(myVariable); + BigDecimal averageAmount = averageFixed.add(averageVariable); + return new ExpenseCompareResponse( + type, yearMonth, + myAmount, myFixed, myVariable, + averageAmount, averageFixed, averageVariable, + sampleSize, + myAmount.subtract(averageAmount)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/MyExpenseResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/MyExpenseResponse.java new file mode 100644 index 0000000..fae7750 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/MyExpenseResponse.java @@ -0,0 +1,14 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record MyExpenseResponse( + String yearMonth, + BigDecimal totalExpense, + BigDecimal fixedExpense, + BigDecimal variableExpense, + List fixedCategoryExpenses, + List variableCategoryExpenses +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PairExpenseDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PairExpenseDetailResponse.java new file mode 100644 index 0000000..7084ad7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PairExpenseDetailResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +public record PairExpenseDetailResponse( + UserExpenseDetailResponse me, + UserExpenseDetailResponse target +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PublicMonthlyExpenseResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PublicMonthlyExpenseResponse.java new file mode 100644 index 0000000..9ae4075 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/PublicMonthlyExpenseResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import java.math.BigDecimal; + +public record PublicMonthlyExpenseResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalExpense, + BigDecimal fixedExpense, + BigDecimal variableExpense +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseCompareResponse.java new file mode 100644 index 0000000..ee6ad97 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseCompareResponse.java @@ -0,0 +1,35 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; + +import java.math.BigDecimal; + +public record UserExpenseCompareResponse( + ExpenseCompareType type, + String yearMonth, + Long myUserId, + String myLabel, + BigDecimal myAmount, + BigDecimal myFixedAmount, + BigDecimal myVariableAmount, + Long targetUserId, + String targetLabel, + BigDecimal targetAmount, + BigDecimal targetFixedAmount, + BigDecimal targetVariableAmount, + BigDecimal difference +) { + public static UserExpenseCompareResponse of(ExpenseCompareType type, String yearMonth, + Long myUserId, String myLabel, + BigDecimal myFixed, BigDecimal myVariable, + Long targetUserId, String targetLabel, + BigDecimal targetFixed, BigDecimal targetVariable) { + BigDecimal myAmount = myFixed.add(myVariable); + BigDecimal targetAmount = targetFixed.add(targetVariable); + return new UserExpenseCompareResponse( + type, yearMonth, + myUserId, myLabel, myAmount, myFixed, myVariable, + targetUserId, targetLabel, targetAmount, targetFixed, targetVariable, + myAmount.subtract(targetAmount)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseDetailResponse.java new file mode 100644 index 0000000..b14182f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/dto/response/UserExpenseDetailResponse.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.expense.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record UserExpenseDetailResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalExpense, + BigDecimal fixedExpense, + BigDecimal variableExpense, + List fixedCategoryExpenses, + List variableCategoryExpenses +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/enums/ExpenseCompareType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/enums/ExpenseCompareType.java new file mode 100644 index 0000000..202e06f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/enums/ExpenseCompareType.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.expense.enums; + +public enum ExpenseCompareType { + AGE, + AMOUNT, + CATEGORY, + LOCATION +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/expense/error/ExpenseErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/error/ExpenseErrorCode.java new file mode 100644 index 0000000..095f6ca --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/expense/error/ExpenseErrorCode.java @@ -0,0 +1,24 @@ +package com.chaewookim.accountbookformoms.domain.expense.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ExpenseErrorCode implements BaseErrorCode { + + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "연/월 값이 올바르지 않습니다."), + INVALID_AMOUNT_RANGE(HttpStatus.BAD_REQUEST, "금액 구간이 올바르지 않습니다."), + BIRTH_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "나이대 비교를 위해 생년월일이 필요합니다."), + CATEGORY_ID_REQUIRED(HttpStatus.BAD_REQUEST, "카테고리 비교를 위해 카테고리 ID가 필요합니다."), + RADIUS_REQUIRED(HttpStatus.BAD_REQUEST, "위치 비교를 위해 반경(km)이 필요합니다."), + INVALID_RADIUS(HttpStatus.BAD_REQUEST, "반경은 0.1km 이상 50km 이하여야 합니다."), + LOCATION_PAIR_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "위치 기반 1:1 비교는 지원하지 않습니다."), + CANNOT_COMPARE_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 비교할 수 없습니다."), + TARGET_NOT_PUBLIC(HttpStatus.FORBIDDEN, "비공개 사용자의 지출은 조회할 수 없습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/api/FollowController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/api/FollowController.java new file mode 100644 index 0000000..20c1ee4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/api/FollowController.java @@ -0,0 +1,50 @@ +package com.chaewookim.accountbookformoms.domain.follow.api; + +import com.chaewookim.accountbookformoms.domain.follow.application.FollowService; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowToggleResponse; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowUserResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 java.util.List; + +@Tag(name = "Follow API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users/{userId}") +public class FollowController { + + private final FollowService followService; + + @Operation(summary = "팔로우 토글") + @PostMapping("/follow") + public ResponseEntity> toggle( + @PathVariable Long userId, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + followService.toggle(userId, principal.getUserId()))); + } + + @Operation(summary = "팔로워 목록") + @GetMapping("/followers") + public ResponseEntity>> followers(@PathVariable Long userId) { + return ResponseEntity.ok(ApiResponse.success(followService.listFollowers(userId))); + } + + @Operation(summary = "팔로잉 목록") + @GetMapping("/following") + public ResponseEntity>> following(@PathVariable Long userId) { + return ResponseEntity.ok(ApiResponse.success(followService.listFollowing(userId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowService.java new file mode 100644 index 0000000..8edc33f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowService.java @@ -0,0 +1,81 @@ +package com.chaewookim.accountbookformoms.domain.follow.application; + +import com.chaewookim.accountbookformoms.domain.follow.dao.FollowRepository; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowToggleResponse; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowUserResponse; +import com.chaewookim.accountbookformoms.domain.follow.entity.Follow; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FollowService { + + private final FollowRepository followRepository; + private final UserRepository userRepository; + + @Transactional + public FollowToggleResponse toggle(Long targetUserId, Long followerId) { + if (targetUserId.equals(followerId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + if (!userRepository.existsById(targetUserId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + Optional existing = followRepository.findByFollowerIdAndFollowingId(followerId, targetUserId); + if (existing.isPresent()) { + followRepository.delete(existing.get()); + return new FollowToggleResponse(false, followRepository.countByFollowingId(targetUserId)); + } + followRepository.save(Follow.builder() + .followerId(followerId) + .followingId(targetUserId) + .build()); + return new FollowToggleResponse(true, followRepository.countByFollowingId(targetUserId)); + } + + public boolean isFollowing(Long targetUserId, Long followerId) { + if (followerId == null) return false; + return followRepository.findByFollowerIdAndFollowingId(followerId, targetUserId).isPresent(); + } + + public long followerCount(Long userId) { + return followRepository.countByFollowingId(userId); + } + + public long followingCount(Long userId) { + return followRepository.countByFollowerId(userId); + } + + public List listFollowers(Long userId) { + List ids = followRepository.findByFollowingId(userId).stream() + .map(Follow::getFollowerId).toList(); + return resolveUsers(ids); + } + + public List listFollowing(Long userId) { + List ids = followRepository.findByFollowerId(userId).stream() + .map(Follow::getFollowingId).toList(); + return resolveUsers(ids); + } + + private List resolveUsers(List ids) { + if (ids.isEmpty()) return List.of(); + Map nicknames = userRepository.findAllById(ids).stream() + .collect(Collectors.toMap(User::getId, User::getUsername, (a, b) -> a)); + return ids.stream() + .map(id -> new FollowUserResponse(id, nicknames.getOrDefault(id, "탈퇴한 사용자"))) + .toList(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dao/FollowRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dao/FollowRepository.java new file mode 100644 index 0000000..08fa9ec --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dao/FollowRepository.java @@ -0,0 +1,22 @@ +package com.chaewookim.accountbookformoms.domain.follow.dao; + +import com.chaewookim.accountbookformoms.domain.follow.entity.Follow; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FollowRepository extends JpaRepository { + + Optional findByFollowerIdAndFollowingId(Long followerId, Long followingId); + + long countByFollowingId(Long followingId); + + long countByFollowerId(Long followerId); + + List findByFollowingId(Long followingId); + + List findByFollowerId(Long followerId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowToggleResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowToggleResponse.java new file mode 100644 index 0000000..4eb5384 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowToggleResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.follow.dto.response; + +public record FollowToggleResponse( + boolean following, + long followerCount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowUserResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowUserResponse.java new file mode 100644 index 0000000..4076fbc --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/dto/response/FollowUserResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.follow.dto.response; + +public record FollowUserResponse( + Long userId, + String nickname +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/follow/entity/Follow.java b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/entity/Follow.java new file mode 100644 index 0000000..fc9f020 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/follow/entity/Follow.java @@ -0,0 +1,48 @@ +package com.chaewookim.accountbookformoms.domain.follow.entity; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "follow", + uniqueConstraints = @UniqueConstraint( + name = "uq_follow_follower_following", + columnNames = {"follower_id", "following_id"} + ), + indexes = { + @Index(name = "idx_follow_follower", columnList = "follower_id"), + @Index(name = "idx_follow_following", columnList = "following_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Follow extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "follower_id", nullable = false) + private Long followerId; + + @Column(name = "following_id", nullable = false) + private Long followingId; + + @Builder + public Follow(Long followerId, Long followingId) { + this.followerId = followerId; + this.followingId = followingId; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseController.java new file mode 100644 index 0000000..de1bf8a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseController.java @@ -0,0 +1,70 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.GroupPurchaseService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseDashboardResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseAdminResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.RequestParam; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; + +@Tag(name = "어드민 공동구매 관리", description = "어드민 공동구매 대시보드 및 요약 API") +@RestController +@RequestMapping("/api/v1/admin/group-purchases") +@RequiredArgsConstructor +public class AdminGroupPurchaseController { + + private final GroupPurchaseService groupPurchaseService; + + @Operation(summary = "실시간 현황 요약", description = "당일 개설된 공구 수, 실시간 참여 수, 진행/성공/무산 비율을 요약하여 반환합니다.") + @GetMapping("/summary") + public ResponseEntity> getSummary() { + GroupPurchaseDashboardResponse summary = groupPurchaseService.getDashboardSummary(); + return ResponseEntity.ok(ApiResponse.success(summary)); + } + + @Operation(summary = "공동구매 전체 목록 모니터링", description = "모든 공동구매 글을 리스트 형태로 조회하고, 상태별(모집중/성공/무산/신고됨)로 필터링할 수 있습니다.") + @GetMapping + public ResponseEntity>> getGroupPurchases( + @RequestParam(required = false) String status, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = groupPurchaseService.getGroupPurchasesForAdmin(status, pageable); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 상태 조정 (관리자용)", description = "관리자 권한으로 공동구매 상태를 강제 변경합니다.") + @PutMapping("/{id}/status") + public ResponseEntity> updateStatus( + @PathVariable Long id, + @RequestParam PurchaseStatus status + ) { + groupPurchaseService.updateGroupPurchaseStatusByAdmin(id, status); + return ResponseEntity.ok(ApiResponse.success(id)); + } + + @Operation(summary = "공동구매 글 삭제 (관리자용)", description = "관리자 권한으로 공동구매 글을 삭제(Soft Delete)합니다.") + @DeleteMapping("/{id}") + public ResponseEntity> deleteByAdmin( + @PathVariable Long id + ) { + groupPurchaseService.deleteGroupPurchaseByAdmin(id); + return ResponseEntity.ok(ApiResponse.success("공동구매가 성공적으로 삭제되었습니다.")); + } + @Operation(summary = "공동구매 글 일괄 삭제 (관리자용)", description = "관리자 권한으로 다수의 공동구매 글을 일괄 삭제합니다.") + @DeleteMapping("/bulk") + public ResponseEntity> deleteBulkByAdmin( + @RequestBody java.util.List ids + ) { + groupPurchaseService.deleteGroupPurchasesByAdmin(ids); + return ResponseEntity.ok(ApiResponse.success(ids.size() + "개의 공동구매가 성공적으로 삭제되었습니다.")); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportController.java new file mode 100644 index 0000000..8368e58 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportController.java @@ -0,0 +1,47 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.ReportService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportProcessRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ReportResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "어드민 공동구매 신고 관리", description = "관리자용 공동구매 글/댓글 신고 조회 및 처리 API") +@RestController +@RequestMapping("/api/v1/admin/reports") +@RequiredArgsConstructor +public class AdminReportController { + + private final ReportService reportService; + + @Operation(summary = "신고 목록 조회", description = "사용자들의 신고 내역 목록을 조회합니다. 처리 여부와 대상 유형으로 필터링할 수 있습니다.") + @GetMapping + public ResponseEntity>> getReports( + @RequestParam(required = false) Boolean isProcessed, + @RequestParam(required = false) ReportTargetType targetType, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page reports = reportService.getReports(isProcessed, targetType, pageable); + return ResponseEntity.ok(ApiResponse.success(reports)); + } + + @Operation(summary = "신고 처리", description = "신고 건에 대해 반려, 경고, 삭제 등의 처리를 내립니다.") + @PostMapping("/{reportId}/process") + public ResponseEntity> processReport( + @PathVariable Long reportId, + @RequestBody @Valid ReportProcessRequest request + ) { + Long processedId = reportService.processReport(reportId, request); + return ResponseEntity.ok(ApiResponse.success(processedId)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationController.java new file mode 100644 index 0000000..23a12aa --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationController.java @@ -0,0 +1,74 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.GroupPurchaseApplicationService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ApplicationStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseApplicationCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseApplicationResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "사용자 공동구매 신청", description = "공동구매 신청 생성/조회/승인 API") +@RestController +@RequestMapping("/api/v1/group-purchase-applications") +@RequiredArgsConstructor +public class GroupPurchaseApplicationController { + + private final GroupPurchaseApplicationService applicationService; + + @Operation(summary = "공동구매 신청서 등록", description = "새로운 공동구매 글 개설을 관리자에게 신청합니다.") + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid GroupPurchaseApplicationCreateRequest request + ) { + GroupPurchaseApplicationResponse response = applicationService.createApplication(userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "본인의 공동구매 신청 목록 조회", description = "로그인한 사용자가 신청한 신청서 목록을 조회합니다.") + @GetMapping("/me") + public ResponseEntity>> getMyApplications( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + List response = applicationService.getUserApplications(userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 신청서 상세 조회", description = "신청서 상세 내용을 조회합니다. 본인 혹은 관리자만 가능합니다.") + @GetMapping("/{id}") + public ResponseEntity> getOne( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + GroupPurchaseApplicationResponse response = applicationService.getApplication(id, userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "어드민 공동구매 신청 상태 수정", description = "공동구매 신청을 승인하거나 반려하고 피드백을 남깁니다.") + @PatchMapping("/{id}/status") + public ResponseEntity> updateStatus( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam ApplicationStatus status, + @RequestParam(required = false) String feedback + ) { + // 관리자 권한 직접 체크 + boolean isAdmin = userPrincipal.getAuthorities().stream() + .anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")); + if (!isAdmin) { + throw new AccessDeniedException("관리자만 신청서 상태를 변경할 수 있습니다."); + } + GroupPurchaseApplicationResponse response = applicationService.updateApplicationStatus(id, status, feedback); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryController.java new file mode 100644 index 0000000..0b79b19 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryController.java @@ -0,0 +1,88 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.GroupPurchaseCategoryService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseCategoryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "공동구매 카테고리 관리", description = "공동구매 카테고리 생성/수정/삭제/조회 API") +@RestController +@RequestMapping("/api/v1/group-purchase-categories") +@RequiredArgsConstructor +public class GroupPurchaseCategoryController { + + private final GroupPurchaseCategoryService categoryService; + + @Operation(summary = "어드민 카테고리 등록", description = "새로운 공동구매 카테고리를 등록합니다.") + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid GroupPurchaseCategoryCreateRequest request + ) { + validateAdmin(userPrincipal); + GroupPurchaseCategoryResponse response = categoryService.createCategory(request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "카테고리 단건 조회", description = "카테고리 상세 정보를 조회합니다.") + @GetMapping("/{id}") + public ResponseEntity> getOne( + @PathVariable Long id + ) { + GroupPurchaseCategoryResponse response = categoryService.getCategory(id); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "카테고리 전체 목록 조회", description = "정렬 순서대로 정렬된 카테고리 목록 전체를 조회합니다.") + @GetMapping + public ResponseEntity>> getAll() { + List response = categoryService.getAllCategories(); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "어드민 카테고리 수정", description = "카테고리 정보를 수정합니다.") + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid GroupPurchaseCategoryUpdateRequest request + ) { + validateAdmin(userPrincipal); + GroupPurchaseCategoryResponse response = categoryService.updateCategory(id, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "어드민 카테고리 삭제", description = "카테고리를 삭제합니다.") + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + validateAdmin(userPrincipal); + categoryService.deleteCategory(id); + return ResponseEntity.ok(ApiResponse.success("카테고리가 성공적으로 삭제되었습니다.")); + } + + private void validateAdmin(UserPrincipal userPrincipal) { + if (userPrincipal == null) { + throw new AccessDeniedException("인증 정보가 없습니다."); + } + boolean isAdmin = userPrincipal.getAuthorities().stream() + .anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")); + if (!isAdmin) { + throw new AccessDeniedException("관리자만 카테고리를 관리할 수 있습니다."); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseController.java new file mode 100644 index 0000000..2aa410d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseController.java @@ -0,0 +1,156 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.GroupPurchaseService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.GroupPurchaseLockFacade; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseJoinResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "공동구매(GroupPurchase)", description = "공동구매 생성/수정/삭제/조회 API") +@RestController +@RequestMapping("/api/v1/group-purchases") +@RequiredArgsConstructor +public class GroupPurchaseController { + + private final GroupPurchaseService groupPurchaseService; + private final GroupPurchaseLockFacade groupPurchaseLockFacade; + + @Operation(summary = "공동구매 개설", description = "새로운 공동구매 글을 등록합니다.") + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid GroupPurchaseCreateRequest request + ) { + GroupPurchaseResponse response = groupPurchaseService.createGroupPurchase(userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 단건 조회", description = "ID에 해당하는 공동구매 상세 정보를 조회합니다.") + @GetMapping("/{id}") + public ResponseEntity> getOne( + @PathVariable Long id + ) { + GroupPurchaseResponse response = groupPurchaseService.getGroupPurchase(id); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 목록 조회", description = "진행 중인 공동구매 목록을 조회합니다. 정렬, 지역, 카테고리 필터링 및 동네 필터링(nearMe)이 가능합니다.") + @GetMapping + public ResponseEntity>> getAll( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(required = false) String region, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Boolean nearMe, + @RequestParam(required = false, defaultValue = "latest") String sortBy + ) { + Long userId = (userPrincipal != null) ? userPrincipal.getUserId() : null; + List response = groupPurchaseService.getAllGroupPurchases(region, categoryId, nearMe, userId, sortBy); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 수정", description = "ID에 해당하는 공동구매 상세 정보를 수정합니다. 개설자만 수정 가능합니다.") + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid GroupPurchaseUpdateRequest request + ) { + GroupPurchaseResponse response = groupPurchaseService.updateGroupPurchase(id, userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 조기 마감", description = "현재 인원이 최소 인원 이상일 때 개설자가 수동으로 조기 마감(성공)시킵니다.") + @PostMapping("/{id}/early-close") + public ResponseEntity> earlyClose( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + GroupPurchaseResponse response = groupPurchaseService.earlyCloseGroupPurchase(id, userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 삭제", description = "ID에 해당하는 공동구매 정보를 삭제(Soft Delete)합니다. 개설자만 삭제 가능합니다.") + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + groupPurchaseService.deleteGroupPurchase(id, userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success("공동구매가 성공적으로 삭제되었습니다.")); + } + + @Operation(summary = "공동구매 찜하기 토글", description = "공동구매 글을 찜하거나 찜 해제합니다.") + @PostMapping("/{id}/wish") + public ResponseEntity> toggleWish( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + boolean result = groupPurchaseService.toggleWish(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + @Operation(summary = "찜한 공동구매 목록 조회", description = "내가 찜한 공동구매 목록을 페이징하여 조회합니다.") + @GetMapping("/wishes") + public ResponseEntity>> getWishes( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = groupPurchaseService.getWishedGroupPurchases(userPrincipal.getUserId(), pageable); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 참여", description = "공동구매 글에 참여 신청합니다.") + @PostMapping("/{id}/join") + public ResponseEntity> join( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseJoinRequest request + ) { + GroupPurchaseJoinResponse response = groupPurchaseLockFacade.joinGroupPurchase(userPrincipal.getUserId(), id, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "공동구매 참여 취소", description = "공동구매 참여 신청을 취소합니다.") + @PostMapping("/{id}/leave") + public ResponseEntity> leave( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + GroupPurchaseResponse response = groupPurchaseLockFacade.leaveGroupPurchase(userPrincipal.getUserId(), id); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "내가 참여한 공동구매 ID 목록 조회", description = "내가 참여 신청한 공동구매 ID 목록을 조회합니다.") + @GetMapping("/joined-ids") + public ResponseEntity>> getJoinedIds( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + List response = groupPurchaseService.getJoinedGroupPurchaseIds(userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "내가 찜한 공동구매 ID 목록 조회", description = "내가 찜한 공동구매 ID 목록을 조회합니다.") + @GetMapping("/wished-ids") + public ResponseEntity>> getWishedIds( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + List response = groupPurchaseService.getWishedGroupPurchaseIds(userPrincipal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductController.java new file mode 100644 index 0000000..8278bea --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductController.java @@ -0,0 +1,90 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.ProductService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ProductResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "공동구매 상품(Product)", description = "공동구매 상품 생성/수정/삭제/조회 API") +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @Operation(summary = "상품 등록 (관리자용)", description = "새로운 공동구매 상품 정보를 등록합니다. 관리자만 가능합니다.") + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid ProductCreateRequest request + ) { + validateAdmin(userPrincipal); + ProductResponse response = productService.createProduct(request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "상품 단건 조회", description = "ID에 해당하는 상품 상세 정보를 조회합니다.") + @GetMapping("/{id}") + public ResponseEntity> getOne( + @PathVariable Long id + ) { + ProductResponse response = productService.getProduct(id); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "상품 목록 조회", description = "상품 전체 목록을 조회합니다. 카테고리 ID로 필터링할 수 있습니다.") + @GetMapping + public ResponseEntity>> getAll( + @RequestParam(required = false) Long categoryId + ) { + List response = productService.getAllProducts(categoryId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "상품 수정 (관리자용)", description = "ID에 해당하는 상품 정보를 수정합니다. 관리자만 가능합니다.") + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid ProductUpdateRequest request + ) { + validateAdmin(userPrincipal); + ProductResponse response = productService.updateProduct(id, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "상품 삭제 (관리자용)", description = "ID에 해당하는 상품을 삭제(Soft Delete)합니다. 관리자만 가능합니다.") + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + validateAdmin(userPrincipal); + productService.deleteProduct(id); + return ResponseEntity.ok(ApiResponse.success("상품이 성공적으로 삭제되었습니다.")); + } + + private void validateAdmin(UserPrincipal userPrincipal) { + if (userPrincipal == null) { + throw new AccessDeniedException("인증 정보가 없습니다."); + } + boolean isAdmin = userPrincipal.getAuthorities().stream() + .anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")); + if (!isAdmin) { + throw new AccessDeniedException("관리자만 상품을 관리할 수 있습니다."); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportController.java new file mode 100644 index 0000000..5691b5e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportController.java @@ -0,0 +1,35 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.ReportService; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportCreateRequest; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "어드민 공동구매 신고관리", description = "공동구매 글 및 댓글 신고 API") +@RestController +@RequestMapping("/api/v1/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "신고 접수", description = "공동구매 글 또는 댓글을 신고합니다.") + @PostMapping + public ResponseEntity> createReport( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody @Valid ReportCreateRequest request + ) { + Long reportId = reportService.createReport(userPrincipal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(reportId)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseApplicationService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseApplicationService.java new file mode 100644 index 0000000..596c363 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseApplicationService.java @@ -0,0 +1,71 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseApplicationRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseApplication; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ApplicationStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseApplicationCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseApplicationResponse; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupPurchaseApplicationService { + + private final GroupPurchaseApplicationRepository applicationRepository; + private final UserRepository userRepository; + + @Transactional + public GroupPurchaseApplicationResponse createApplication(Long userId, GroupPurchaseApplicationCreateRequest request) { + GroupPurchaseApplication application = GroupPurchaseApplication.builder() + .userId(userId) + .title(request.title()) + .content(request.content()) + .productUrl(request.productUrl()) + .expectedPrice(request.expectedPrice()) + .build(); + + GroupPurchaseApplication saved = applicationRepository.save(application); + return GroupPurchaseApplicationResponse.from(saved); + } + + public GroupPurchaseApplicationResponse getApplication(Long id, Long currentUserId) { + GroupPurchaseApplication application = applicationRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 본인 또는 관리자만 조회 가능 + if (!application.getUserId().equals(currentUserId) && !currentUser.getRole().equals(UserRole.ROLE_ADMIN)) { + throw new CustomException(ErrorCode.UNAUTHORIZED_APPLICATION); + } + + return GroupPurchaseApplicationResponse.from(application); + } + + public List getUserApplications(Long userId) { + return applicationRepository.findByUserId(userId).stream() + .map(GroupPurchaseApplicationResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public GroupPurchaseApplicationResponse updateApplicationStatus(Long id, ApplicationStatus status, String feedback) { + GroupPurchaseApplication application = applicationRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + + application.updateStatus(status, feedback); + return GroupPurchaseApplicationResponse.from(application); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseCategoryService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseCategoryService.java new file mode 100644 index 0000000..be34e0a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseCategoryService.java @@ -0,0 +1,63 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseCategoryResponse; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupPurchaseCategoryService { + + private final GroupPurchaseCategoryRepository categoryRepository; + + @Transactional + public GroupPurchaseCategoryResponse createCategory(GroupPurchaseCategoryCreateRequest request) { + Category category = Category.builder() + .name(request.name()) + .sortOrder(request.sortOrder()) + .description(request.description()) + .build(); + + Category saved = categoryRepository.save(category); + return GroupPurchaseCategoryResponse.from(saved); + } + + public GroupPurchaseCategoryResponse getCategory(Long id) { + Category category = categoryRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + return GroupPurchaseCategoryResponse.from(category); + } + + public List getAllCategories() { + return categoryRepository.findAllByOrderBySortOrderAsc().stream() + .map(GroupPurchaseCategoryResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public GroupPurchaseCategoryResponse updateCategory(Long id, GroupPurchaseCategoryUpdateRequest request) { + Category category = categoryRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + category.update(request.name(), request.sortOrder(), request.description()); + return GroupPurchaseCategoryResponse.from(category); + } + + @Transactional + public void deleteCategory(Long id) { + Category category = categoryRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + categoryRepository.delete(category); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseDataInitializer.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseDataInitializer.java new file mode 100644 index 0000000..6350631 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseDataInitializer.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupPurchaseDataInitializer implements CommandLineRunner { + + private final GroupPurchaseCategoryRepository categoryRepository; + + @Override + public void run(String... args) throws Exception { + if (categoryRepository.count() == 0) { + log.info("공동구매 카테고리가 비어 있어 기본 카테고리를 등록합니다."); + categoryRepository.saveAll(List.of( + Category.builder().name("식품").sortOrder(1).build(), + Category.builder().name("생활용품").sortOrder(2).build(), + Category.builder().name("육아용품").sortOrder(3).build(), + Category.builder().name("가전").sortOrder(4).build() + )); + log.info("공동구매 기본 카테고리 등록 완료"); + } + } + +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseLockFacade.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseLockFacade.java new file mode 100644 index 0000000..4448a78 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseLockFacade.java @@ -0,0 +1,66 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseJoinResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseResponse; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupPurchaseLockFacade { + + private final RedissonClient redissonClient; + private final GroupPurchaseService groupPurchaseService; + + public GroupPurchaseJoinResponse joinGroupPurchase(Long userId, Long groupPurchaseId, com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseJoinRequest request) { + String lockKey = "group_purchase_lock:" + groupPurchaseId; + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS); + if (!available) { + log.warn("공동구매 참여 락 획득 실패 (lockKey={})", lockKey); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + return groupPurchaseService.joinGroupPurchase(userId, groupPurchaseId, request); + } catch (InterruptedException e) { + log.error("공동구매 참여 락 대기 중 인터럽트 발생", e); + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + public GroupPurchaseResponse leaveGroupPurchase(Long userId, Long groupPurchaseId) { + String lockKey = "group_purchase_lock:" + groupPurchaseId; + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS); + if (!available) { + log.warn("공동구매 취소 락 획득 실패 (lockKey={})", lockKey); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + return groupPurchaseService.leaveGroupPurchase(userId, groupPurchaseId); + } catch (InterruptedException e) { + log.error("공동구매 취소 락 대기 중 인터럽트 발생", e); + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseScheduler.java new file mode 100644 index 0000000..387ae4d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseScheduler.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupPurchaseScheduler { + + private final GroupPurchaseRepository groupPurchaseRepository; + private final GroupPurchaseService groupPurchaseService; + + /** + * 매 분 0초마다 실행되어, 마감 기한이 지난 모집 중(RECRUITING) 상태의 공동구매를 실패(FAILED)로 변경합니다. + * 이미 예약 결제 방식이므로 추가적인 환불(deduct취소) 로직은 필요 없습니다. + */ + @Scheduled(cron = "0 * * * * *") + public void closeExpiredGroupPurchases() { + LocalDateTime now = LocalDateTime.now(); + List expiredPurchases = groupPurchaseRepository.findExpiredGroupPurchases(PurchaseStatus.RECRUITING, now); + + if (!expiredPurchases.isEmpty()) { + log.info("마감 기한이 경과된 공동구매 {}건을 처리합니다.", expiredPurchases.size()); + groupPurchaseService.processExpiredGroupPurchases(expiredPurchases); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseService.java new file mode 100644 index 0000000..28cbed0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseService.java @@ -0,0 +1,620 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.asset.application.TransactionService; +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseParticipantRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.ReportRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.WishlistRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseParticipant; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Wishlist; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseJoinResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseDashboardResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseAdminResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.event.GroupPurchaseCreatedEvent; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupPurchaseService { + + private final GroupPurchaseRepository groupPurchaseRepository; + private final UserRepository userRepository; + private final GroupPurchaseCategoryRepository groupPurchaseCategoryRepository; + private final ReportRepository reportRepository; + private final WishlistRepository wishlistRepository; + private final GroupPurchaseParticipantRepository groupPurchaseParticipantRepository; + private final BudgetRepository budgetRepository; + private final TransactionRepository transactionRepository; + private final ApplicationEventPublisher eventPublisher; + private final TransactionService transactionService; + private final AccountRepository accountRepository; + private final TransactionCategoryRepository transactionCategoryRepository; + + @Transactional + public GroupPurchaseResponse createGroupPurchase(Long creatorId, GroupPurchaseCreateRequest request) { + User creator = userRepository.findById(creatorId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + GroupPurchase groupPurchase = GroupPurchase.builder() + .creatorId(creatorId) + .categoryId(request.categoryId()) + .title(request.title()) + .content(request.content()) + .price(request.price()) + .minParticipants(request.minParticipants()) + .maxParticipants(request.maxParticipants()) + .deadline(request.deadline()) + .pickupLocation(request.pickupLocation()) + .imageUrl(request.imageUrl()) + .latitude(creator.getLatitude()) + .longitude(creator.getLongitude()) + .creatorAccountId(request.accountId()) + .build(); + + GroupPurchase saved = groupPurchaseRepository.save(groupPurchase); + + // 예약 결제로 변경됨에 따라 개설 시 즉시 결제하지 않음 + // deductBudgetForUser(creatorId, request.accountId(), saved); + + String categoryName = groupPurchaseCategoryRepository.findById(request.categoryId()) + .map(Category::getName) + .orElse("기타"); + + eventPublisher.publishEvent(new GroupPurchaseCreatedEvent( + request.categoryId(), + categoryName, + "[" + categoryName + "] 새로운 공동구매가 시작되었습니다!", + saved.getId() + )); + + String creatorNickname = userRepository.findById(creatorId) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + return GroupPurchaseResponse.of(saved, creatorNickname); + } + + @Transactional + public GroupPurchaseResponse getGroupPurchase(Long id) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + groupPurchase.increaseViewCount(); + + String creatorNickname = userRepository.findById(groupPurchase.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + return GroupPurchaseResponse.of(groupPurchase, creatorNickname); + } + + public List getAllGroupPurchases(String region, Long categoryId, Boolean nearMe, Long currentUserId, String sortBy) { + String filterRegion = (region != null && !region.trim().isEmpty()) ? region.trim() : null; + + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + if ("deadline".equalsIgnoreCase(sortBy)) { + sort = Sort.by(Sort.Direction.ASC, "deadline"); + } + + List statuses = List.of(PurchaseStatus.RECRUITING, PurchaseStatus.SUCCESS, PurchaseStatus.CLOSED); + List list = groupPurchaseRepository.findActiveGroupPurchases(statuses, filterRegion, categoryId, sort); + + if (Boolean.TRUE.equals(nearMe) && currentUserId != null) { + User user = userRepository.findById(currentUserId).orElse(null); + if (user != null) { + if (user.getLatitude() != null && user.getLongitude() != null) { + list = list.stream() + .filter(gp -> gp.getLatitude() != null && gp.getLongitude() != null) + .filter(gp -> calculateDistance(user.getLatitude(), user.getLongitude(), gp.getLatitude(), gp.getLongitude()) <= 3.0) + .collect(Collectors.toList()); + } else if (user.getAddress() != null) { + String neighborhood = extractNeighborhood(user.getAddress()); + if (neighborhood != null) { + list = list.stream() + .filter(gp -> gp.getPickupLocation() != null && gp.getPickupLocation().contains(neighborhood)) + .collect(Collectors.toList()); + } + } + } + } + + List creatorIds = list.stream().map(GroupPurchase::getCreatorId).distinct().toList(); + Map nicknameMap = userRepository.findAllById(creatorIds).stream() + .collect(Collectors.toMap(User::getId, User::getUsername)); + + return list.stream() + .map(gp -> { + String nickname = nicknameMap.getOrDefault(gp.getCreatorId(), "탈퇴한 사용자"); + return GroupPurchaseResponse.of(gp, nickname); + }) + .collect(Collectors.toList()); + } + + @Transactional + public GroupPurchaseResponse updateGroupPurchase(Long id, Long currentUserId, GroupPurchaseUpdateRequest request) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + + if (!groupPurchase.getCreatorId().equals(currentUserId)) { + throw new CustomException(ErrorCode.UNAUTHORIZED_GROUP_PURCHASE); + } + + groupPurchase.update( + request.categoryId(), + request.title(), + request.content(), + request.price(), + request.minParticipants(), + request.maxParticipants(), + request.deadline(), + request.pickupLocation(), + request.imageUrl() + ); + + String creatorNickname = userRepository.findById(groupPurchase.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + return GroupPurchaseResponse.of(groupPurchase, creatorNickname); + } + + @Transactional + public void deleteGroupPurchase(Long id, Long currentUserId) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + + if (!groupPurchase.getCreatorId().equals(currentUserId)) { + throw new CustomException(ErrorCode.UNAUTHORIZED_GROUP_PURCHASE); + } + + groupPurchaseRepository.delete(groupPurchase); + } + + @Transactional + public GroupPurchaseResponse earlyCloseGroupPurchase(Long id, Long currentUserId) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + + if (!groupPurchase.getCreatorId().equals(currentUserId)) { + throw new CustomException(ErrorCode.UNAUTHORIZED_GROUP_PURCHASE); + } + + if (groupPurchase.getStatus() != PurchaseStatus.RECRUITING) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_RECRUITING); + } + + if (groupPurchase.getCurrentParticipants() < groupPurchase.getMinParticipants()) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_MIN_NOT_REACHED); + } + + groupPurchase.updateStatusByAdmin(PurchaseStatus.SUCCESS); + + // 예약 결제로 변경됨에 따라 성공 시 일괄 결제 진행 + deductBudgetForUser(groupPurchase.getCreatorId(), groupPurchase.getCreatorAccountId(), groupPurchase); + List participants = groupPurchaseParticipantRepository.findByGroupPurchaseId(id); + for (GroupPurchaseParticipant p : participants) { + deductBudgetForUser(p.getUserId(), p.getAccountId(), groupPurchase); + } + + String creatorNickname = userRepository.findById(groupPurchase.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + return GroupPurchaseResponse.of(groupPurchase, creatorNickname); + } + + private static final double EARTH_RADIUS_KM = 6371.0; + + private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + double latDistance = Math.toRadians(lat2 - lat1); + double lonDistance = Math.toRadians(lon2 - lon1); + double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; + } + + private String extractNeighborhood(String address) { + if (address == null || address.trim().isEmpty()) { + return null; + } + String[] parts = address.split("\\s+"); + for (String part : parts) { + if (part.endsWith("동") || part.endsWith("읍") || part.endsWith("면")) { + return part; + } + } + for (String part : parts) { + if (part.endsWith("구")) { + return part; + } + } + return null; + } + + public GroupPurchaseDashboardResponse getDashboardSummary() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + + long todayCreatedCount = groupPurchaseRepository.countByCreatedAtBetween(startOfDay, endOfDay); + long activeParticipantsCount = groupPurchaseRepository.sumCurrentParticipantsByStatus(PurchaseStatus.RECRUITING); + + long recruitingCount = groupPurchaseRepository.countByStatus(PurchaseStatus.RECRUITING); + long successCount = groupPurchaseRepository.countByStatus(PurchaseStatus.SUCCESS) + + groupPurchaseRepository.countByStatus(PurchaseStatus.CLOSED); + long failedCount = groupPurchaseRepository.countByStatus(PurchaseStatus.FAILED); + + long total = recruitingCount + successCount + failedCount; + double recruitingRatio = total == 0 ? 0.0 : Math.round(((double) recruitingCount / total * 100) * 100) / 100.0; + double successRatio = total == 0 ? 0.0 : Math.round(((double) successCount / total * 100) * 100) / 100.0; + double failedRatio = total == 0 ? 0.0 : Math.round(((double) failedCount / total * 100) * 100) / 100.0; + + return new GroupPurchaseDashboardResponse( + todayCreatedCount, + activeParticipantsCount, + recruitingCount, + successCount, + failedCount, + recruitingRatio, + successRatio, + failedRatio + ); + } + + public Page getGroupPurchasesForAdmin(String status, Pageable pageable) { + return groupPurchaseRepository.findAllForAdmin(status, pageable) + .map(gp -> { + String creatorUsername = userRepository.findById(gp.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + String categoryName = groupPurchaseCategoryRepository.findById(gp.getCategoryId()) + .map(Category::getName) + .orElse("미지정"); + long reportCount = reportRepository.countByTargetTypeAndTargetId( + ReportTargetType.GROUP_PURCHASE, + gp.getId() + ); + return GroupPurchaseAdminResponse.of(gp, creatorUsername, categoryName, reportCount); + }); + } + + @Transactional + public void updateGroupPurchaseStatusByAdmin(Long id, PurchaseStatus status) { + GroupPurchase gp = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + PurchaseStatus previousStatus = gp.getStatus(); + gp.updateStatusByAdmin(status); + + // 예약 결제로 변경됨에 따라, 이미 결제된 SUCCESS 상태에서 FAILED 등으로 바뀔 때만 환불 처리 + if (status == PurchaseStatus.FAILED && previousStatus == PurchaseStatus.SUCCESS) { + refundAllParticipants(gp); + } + } + + @Transactional + public void deleteGroupPurchaseByAdmin(Long id) { + GroupPurchase gp = groupPurchaseRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + groupPurchaseRepository.delete(gp); + } + + @Transactional + public void deleteGroupPurchasesByAdmin(List ids) { + List groupPurchases = groupPurchaseRepository.findAllById(ids); + groupPurchaseRepository.deleteAllInBatch(groupPurchases); + } + + @Transactional + public boolean toggleWish(Long userId, Long groupPurchaseId) { + if (!groupPurchaseRepository.existsById(groupPurchaseId)) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND); + } + + return wishlistRepository.findByUserIdAndGroupPurchaseId(userId, groupPurchaseId) + .map(wish -> { + wishlistRepository.delete(wish); + return false; + }) + .orElseGet(() -> { + Wishlist wish = Wishlist.builder() + .userId(userId) + .groupPurchaseId(groupPurchaseId) + .build(); + wishlistRepository.save(wish); + return true; + }); + } + + public Page getWishedGroupPurchases(Long userId, Pageable pageable) { + Page page = groupPurchaseRepository.findWishedGroupPurchases(userId, pageable); + + List creatorIds = page.getContent().stream().map(GroupPurchase::getCreatorId).distinct().toList(); + Map nicknameMap = userRepository.findAllById(creatorIds).stream() + .collect(Collectors.toMap(User::getId, User::getUsername)); + + return page.map(gp -> { + String nickname = nicknameMap.getOrDefault(gp.getCreatorId(), "탈퇴한 사용자"); + return GroupPurchaseResponse.of(gp, nickname); + }); + } + + @Transactional + public GroupPurchaseJoinResponse joinGroupPurchase(Long userId, Long groupPurchaseId, com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseJoinRequest request) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(groupPurchaseId) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + + if (groupPurchase.getStatus() != PurchaseStatus.RECRUITING) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_RECRUITING); + } + + if (groupPurchase.getDeadline().isBefore(LocalDateTime.now())) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_DEADLINE_PASSED); + } + + if (groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(groupPurchaseId, userId)) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_ALREADY_JOINED); + } + + if (groupPurchase.getCurrentParticipants() >= groupPurchase.getMaxParticipants()) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_FULL); + } + + GroupPurchaseParticipant participant = GroupPurchaseParticipant.builder() + .groupPurchaseId(groupPurchaseId) + .userId(userId) + .accountId(request.accountId()) + .build(); + groupPurchaseParticipantRepository.save(participant); + + groupPurchase.join(); + + if (groupPurchase.getStatus() == PurchaseStatus.SUCCESS) { + // 인원 달성 성공 시, 예약되었던 모든 인원(개설자 포함) 일괄 결제 진행 + deductBudgetForUser(groupPurchase.getCreatorId(), groupPurchase.getCreatorAccountId(), groupPurchase); + + List participants = groupPurchaseParticipantRepository.findByGroupPurchaseId(groupPurchaseId); + for (GroupPurchaseParticipant p : participants) { + deductBudgetForUser(p.getUserId(), p.getAccountId(), groupPurchase); + } + } + + String creatorNickname = userRepository.findById(groupPurchase.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + GroupPurchaseResponse groupPurchaseResponse = GroupPurchaseResponse.of(groupPurchase, creatorNickname); + + boolean budgetWarning = false; + BigDecimal remainingBudget = BigDecimal.ZERO; + + String currentYearMonth = YearMonth.now().toString(); + List budgets = + budgetRepository.findByUserIdAndYearMonth(userId, currentYearMonth); + + if (!budgets.isEmpty()) { + BigDecimal totalPlannedBudgetSum = budgets.stream() + .map(budget -> budget.getTotalBudget().add(budget.getExpectedExpense())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + LocalDate startDate = LocalDate.parse(currentYearMonth + "-01"); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + BigDecimal totalActualExpenseSum = transactionRepository.sumByUserAndType(userId, TransactionType.EXPENSE, startDate, endDate); + if (totalActualExpenseSum == null) { + totalActualExpenseSum = BigDecimal.ZERO; + } + + remainingBudget = totalPlannedBudgetSum.subtract(totalActualExpenseSum); + + BigDecimal purchasePrice = BigDecimal.valueOf(groupPurchase.getPrice()); + if (remainingBudget.compareTo(purchasePrice) < 0) { + budgetWarning = true; + } + } + + return new GroupPurchaseJoinResponse(groupPurchaseResponse, budgetWarning, remainingBudget); + } + + @Transactional + public GroupPurchaseResponse leaveGroupPurchase(Long userId, Long groupPurchaseId) { + GroupPurchase groupPurchase = groupPurchaseRepository.findById(groupPurchaseId) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_FOUND)); + + if (groupPurchase.getStatus() != PurchaseStatus.RECRUITING && groupPurchase.getStatus() != PurchaseStatus.SUCCESS) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_RECRUITING); + } + + if (groupPurchase.getDeadline().isBefore(LocalDateTime.now())) { + throw new CustomException(ErrorCode.GROUP_PURCHASE_NOT_RECRUITING); + } + + GroupPurchaseParticipant participant = groupPurchaseParticipantRepository.findByGroupPurchaseIdAndUserId(groupPurchaseId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.GROUP_PURCHASE_NOT_JOINED)); + + groupPurchaseParticipantRepository.delete(participant); + + PurchaseStatus previousStatus = groupPurchase.getStatus(); + groupPurchase.leave(); + + if (previousStatus == PurchaseStatus.SUCCESS) { + // 이미 결제(SUCCESS)가 일어난 뒤 누군가 이탈하여 인원이 미달되면, 전체 환불 후 다시 RECRUITING 상태로 돌아감 + refundAllParticipants(groupPurchase); + } + + String creatorNickname = userRepository.findById(groupPurchase.getCreatorId()) + .map(User::getUsername) + .orElse("탈퇴한 사용자"); + + return GroupPurchaseResponse.of(groupPurchase, creatorNickname); + } + + private void deductBudgetForUser(Long memberId, Long accountId, GroupPurchase groupPurchase) { + String categoryName = groupPurchaseCategoryRepository.findById(groupPurchase.getCategoryId()) + .map(Category::getName) + .orElse(""); + BigDecimal amount = BigDecimal.valueOf(groupPurchase.getPrice()); + String description = "공동구매 지출: " + groupPurchase.getTitle() + " (공구 ID: " + groupPurchase.getId() + ")"; + + Account account = null; + if (accountId != null) { + account = accountRepository.findById(accountId).orElse(null); + } + if (account == null) { + List accounts = accountRepository.findByUserId(memberId); + if (!accounts.isEmpty()) { + account = accounts.get(0); + } + } + + if (account == null) { + log.warn("가계부 자동 기입 실패: 사용자(ID={})의 자산 계좌가 존재하지 않습니다.", memberId); + throw new IllegalStateException("계좌가 존재하지 않아 결제를 진행할 수 없습니다."); + } + + List userCategories = transactionCategoryRepository.findAllByUserOrSystem(memberId); + TransactionCategory targetCategory = userCategories.stream() + .filter(c -> c.getType() == TransactionType.EXPENSE && c.getName().equals(categoryName)) + .findFirst() + .orElseGet(() -> userCategories.stream() + .filter(c -> c.getType() == TransactionType.EXPENSE && c.getName().equals("공동구매")) + .findFirst() + .orElse(null)); + + if (targetCategory == null) { + User user = userRepository.findById(memberId).orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다.")); + targetCategory = TransactionCategory.builder() + .name("공동구매") + .type(TransactionType.EXPENSE) + .user(user) + .build(); + targetCategory = transactionCategoryRepository.save(targetCategory); + } + + Transaction transaction = Transaction.builder() + .user(account.getUser()) + .account(account) + .transactionCategory(targetCategory) + .type(TransactionType.EXPENSE) + .amount(amount) + .transactionDate(LocalDate.now()) + .description(description) + .build(); + transactionRepository.save(transaction); + account.changeBalance(amount.negate()); // 잔액이 부족하면 여기서 CustomException(AssetErrorCode.INSUFFICIENT_BALANCE) 발생 후 트랜잭션 롤백됨 + } + + private void refundBudgetForUser(Long memberId, Long accountId, GroupPurchase groupPurchase) { + try { + Account account = null; + if (accountId != null) { + account = accountRepository.findById(accountId).orElse(null); + } + if (account == null) { + List accounts = accountRepository.findByUserId(memberId); + if (!accounts.isEmpty()) { + account = accounts.get(0); + } + } + + if (account == null) { + return; + } + + List transactions = transactionRepository.findByAccountIdAndDescriptionAndType( + account.getId(), + "공동구매 지출: " + groupPurchase.getTitle() + " (공구 ID: " + groupPurchase.getId() + ")", + TransactionType.EXPENSE + ); + + if (!transactions.isEmpty()) { + Transaction originalExpense = transactions.get(0); + + // 새로운 수입 내역(INCOME)을 생성하는 대신, 기존 지출 내역(EXPENSE)을 삭제하여 예산을 원상 복구함 + transactionRepository.delete(originalExpense); + // 통장 잔고 롤백 + account.changeBalance(originalExpense.getAmount()); + } + } catch (Exception e) { + log.warn("가계부 환불(결제 취소) 실패: 사용자(ID={})의 지출 취소 중 예외가 발생했습니다. 메시지: {}", memberId, e.getMessage()); + } + } + + private void refundAllParticipants(GroupPurchase groupPurchase) { + // Refund Creator + refundBudgetForUser(groupPurchase.getCreatorId(), groupPurchase.getCreatorAccountId(), groupPurchase); + + // Refund Participants + List participants = groupPurchaseParticipantRepository.findByGroupPurchaseId(groupPurchase.getId()); + for (GroupPurchaseParticipant participant : participants) { + refundBudgetForUser(participant.getUserId(), participant.getAccountId(), groupPurchase); + } + } + + public List getJoinedGroupPurchaseIds(Long userId) { + return groupPurchaseParticipantRepository.findByUserId(userId).stream() + .map(GroupPurchaseParticipant::getGroupPurchaseId) + .collect(Collectors.toList()); + } + + public List getWishedGroupPurchaseIds(Long userId) { + return wishlistRepository.findByUserId(userId).stream() + .map(Wishlist::getGroupPurchaseId) + .collect(Collectors.toList()); + } + + @Transactional + public void processExpiredGroupPurchases(List expiredPurchases) { + for (GroupPurchase gp : expiredPurchases) { + if (gp.getCurrentParticipants() >= gp.getMinParticipants()) { + gp.updateStatusByAdmin(PurchaseStatus.SUCCESS); + deductBudgetForUser(gp.getCreatorId(), gp.getCreatorAccountId(), gp); + List participants = groupPurchaseParticipantRepository.findByGroupPurchaseId(gp.getId()); + for (GroupPurchaseParticipant p : participants) { + deductBudgetForUser(p.getUserId(), p.getAccountId(), gp); + } + } else { + gp.updateStatusByAdmin(PurchaseStatus.FAILED); + } + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ProductService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ProductService.java new file mode 100644 index 0000000..359ef73 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ProductService.java @@ -0,0 +1,96 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseApplicationRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.ProductRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Product; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ProductResponse; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductService { + + private final ProductRepository productRepository; + private final GroupPurchaseApplicationRepository applicationRepository; + + @Transactional + public ProductResponse createProduct(ProductCreateRequest request) { + // 공동구매 신청 ID가 들어온 경우 유효성 검증 + if (request.applicationId() != null) { + applicationRepository.findById(request.applicationId()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + } + + Product product = Product.builder() + .name(request.name()) + .price(request.price()) + .description(request.description()) + .imageUrl(request.imageUrl()) + .categoryId(request.categoryId()) + .applicationId(request.applicationId()) + .reportId(request.reportId()) + .build(); + + Product saved = productRepository.save(product); + return ProductResponse.from(saved); + } + + public ProductResponse getProduct(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + return ProductResponse.from(product); + } + + public List getAllProducts(Long categoryId) { + List products; + if (categoryId != null) { + products = productRepository.findByCategoryId(categoryId); + } else { + products = productRepository.findAll(); + } + return products.stream() + .map(ProductResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public ProductResponse updateProduct(Long id, ProductUpdateRequest request) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + + // 공동구매 신청 ID가 들어온 경우 유효성 검증 + if (request.applicationId() != null) { + applicationRepository.findById(request.applicationId()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + } + + product.update( + request.name(), + request.price(), + request.description(), + request.imageUrl(), + request.categoryId(), + request.applicationId(), + request.reportId() + ); + + return ProductResponse.from(product); + } + + @Transactional + public void deleteProduct(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + productRepository.delete(product); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportService.java new file mode 100644 index 0000000..8d40852 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportService.java @@ -0,0 +1,140 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.ReportRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Report; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportProcessRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ReportResponse; +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final GroupPurchaseRepository groupPurchaseRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + + @Transactional + public Long createReport(Long reporterId, ReportCreateRequest request) { + validateReportTarget(request.targetType(), request.targetId()); + + Report report = Report.builder() + .reporterId(reporterId) + .targetType(request.targetType()) + .targetId(request.targetId()) + .reason(request.reason()) + .build(); + + return reportRepository.save(report).getId(); + } + + public Page getReports(Boolean isProcessed, ReportTargetType targetType, Pageable pageable) { + return reportRepository.findReports(isProcessed, targetType, pageable) + .map(ReportResponse::from); + } + + @Transactional + public Long processReport(Long reportId, ReportProcessRequest request) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_NOT_FOUND)); + + if (report.isProcessed()) { + throw new CustomException(ErrorCode.REPORT_ALREADY_PROCESSED); + } + + // 신고 처리 사유 기록 및 상태 변경 + report.process(request.action(), request.reason()); + + // 조치 사항에 따른 엔티티 삭제 및 알림 발송 처리 + if (request.action() == ReportProcessResult.WARNED || request.action() == ReportProcessResult.DELETED) { + handleActionOnTarget(report, request.action(), request.reason()); + } + + return report.getId(); + } + + private void validateReportTarget(ReportTargetType targetType, Long targetId) { + if (targetType == ReportTargetType.GROUP_PURCHASE) { + if (!groupPurchaseRepository.existsById(targetId)) { + throw new CustomException(ErrorCode.INVALID_REPORT_TARGET); + } + } else if (targetType == ReportTargetType.COMMENT) { + if (!commentRepository.existsById(targetId)) { + throw new CustomException(ErrorCode.INVALID_REPORT_TARGET); + } + } else { + throw new CustomException(ErrorCode.INVALID_REPORT_TARGET); + } + } + + private void handleActionOnTarget(Report report, ReportProcessResult action, String reason) { + if (report.getTargetType() == ReportTargetType.GROUP_PURCHASE) { + GroupPurchase gp = groupPurchaseRepository.findById(report.getTargetId()) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_REPORT_TARGET)); + + User creator = userRepository.findById(gp.getCreatorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + String title; + String message; + + if (action == ReportProcessResult.DELETED) { + title = "공동구매 글 삭제 알림"; + message = String.format("[공동구매 글 삭제] 귀하가 개설한 공동구매 글('%s')이 규정 위반으로 인해 삭제되었습니다. 사유: %s", gp.getTitle(), reason); + groupPurchaseRepository.delete(gp); + } else { + title = "공동구매 글 경고 알림"; + message = String.format("[공동구매 글 경고] 귀하가 개설한 공동구매 글('%s')에 대해 신고가 접수되어 경고 조치되었습니다. 사유: %s", gp.getTitle(), reason); + } + + notificationService.createNotification(creator, NotificationType.SYSTEM, title, message, null, null); + + } else if (report.getTargetType() == ReportTargetType.COMMENT) { + Comment comment = commentRepository.findById(report.getTargetId()) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_REPORT_TARGET)); + + User author = userRepository.findById(comment.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + String title; + String message; + + if (action == ReportProcessResult.DELETED) { + title = "댓글 삭제 알림"; + message = String.format("[댓글 삭제] 귀하가 작성한 댓글('%s')이 규정 위반으로 인해 삭제되었습니다. 사유: %s", getContentPreview(comment.getContent()), reason); + comment.markAsAdminDeleted(); + commentRepository.delete(comment); + } else { + title = "댓글 경고 알림"; + message = String.format("[댓글 경고] 귀하가 작성한 댓글('%s')에 대해 신고가 접수되어 경고 조치되었습니다. 사유: %s", getContentPreview(comment.getContent()), reason); + } + + notificationService.createNotification(author, NotificationType.SYSTEM, title, message, null, null); + } + } + + private String getContentPreview(String content) { + if (content == null) return ""; + return content.length() > 20 ? content.substring(0, 20) + "..." : content; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseApplicationRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseApplicationRepository.java new file mode 100644 index 0000000..dec742d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseApplicationRepository.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseApplication; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GroupPurchaseApplicationRepository extends JpaRepository { + List findByUserId(Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseCategoryRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseCategoryRepository.java new file mode 100644 index 0000000..70ef1f8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseCategoryRepository.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GroupPurchaseCategoryRepository extends JpaRepository { + List findAllByOrderBySortOrderAsc(); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseParticipantRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseParticipantRepository.java new file mode 100644 index 0000000..4c273f3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseParticipantRepository.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseParticipant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GroupPurchaseParticipantRepository extends JpaRepository { + + Optional findByGroupPurchaseIdAndUserId(Long groupPurchaseId, Long userId); + + boolean existsByGroupPurchaseIdAndUserId(Long groupPurchaseId, Long userId); + + List findByGroupPurchaseId(Long groupPurchaseId); + + List findByUserId(Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseRepository.java new file mode 100644 index 0000000..9d0b20d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/GroupPurchaseRepository.java @@ -0,0 +1,61 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface GroupPurchaseRepository extends JpaRepository { + + @Query("SELECT gp FROM GroupPurchase gp " + + "WHERE gp.status IN :statuses " + + "AND (:region IS NULL OR gp.pickupLocation LIKE %:region%) " + + "AND (:categoryId IS NULL OR gp.categoryId = :categoryId)") + List findActiveGroupPurchases( + @Param("statuses") List statuses, + @Param("region") String region, + @Param("categoryId") Long categoryId, + Sort sort + ); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + @Query("SELECT COALESCE(SUM(gp.currentParticipants), 0) FROM GroupPurchase gp WHERE gp.status = :status") + long sumCurrentParticipantsByStatus(@Param("status") PurchaseStatus status); + + long countByStatus(PurchaseStatus status); + + @Query("SELECT gp FROM GroupPurchase gp " + + "WHERE (:status IS NULL OR " + + " (:status = 'RECRUITING' AND gp.status = 'RECRUITING') OR " + + " (:status = 'SUCCESS' AND (gp.status = 'SUCCESS' OR gp.status = 'CLOSED')) OR " + + " (:status = 'FAILED' AND gp.status = 'FAILED') OR " + + " (:status = 'REPORTED' AND (SELECT COUNT(r) FROM Report r WHERE r.targetType = 'GROUP_PURCHASE' AND r.targetId = gp.id) > 0)" + + ")") + Page findAllForAdmin( + @Param("status") String status, + Pageable pageable + ); + + @Query("SELECT gp FROM GroupPurchase gp JOIN Wishlist w ON w.groupPurchaseId = gp.id WHERE w.userId = :userId") + Page findWishedGroupPurchases( + @Param("userId") Long userId, + Pageable pageable + ); + + @Query("SELECT gp FROM GroupPurchase gp WHERE gp.status = :status AND gp.deadline < :now") + List findExpiredGroupPurchases( + @Param("status") PurchaseStatus status, + @Param("now") LocalDateTime now + ); +} + diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ProductRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ProductRepository.java new file mode 100644 index 0000000..420e8c3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ProductRepository.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProductRepository extends JpaRepository { + List findByCategoryId(Long categoryId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ReportRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ReportRepository.java new file mode 100644 index 0000000..90b7a74 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/ReportRepository.java @@ -0,0 +1,26 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Report; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReportRepository extends JpaRepository { + + @Query("SELECT r FROM Report r " + + "WHERE (:isProcessed IS NULL OR r.isProcessed = :isProcessed) " + + "AND (:targetType IS NULL OR r.targetType = :targetType)") + Page findReports( + @Param("isProcessed") Boolean isProcessed, + @Param("targetType") ReportTargetType targetType, + Pageable pageable + ); + + long countByTargetTypeAndTargetId(ReportTargetType targetType, Long targetId); +} + diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/WishlistRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/WishlistRepository.java new file mode 100644 index 0000000..8fad9a7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dao/WishlistRepository.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Wishlist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface WishlistRepository extends JpaRepository { + + Optional findByUserIdAndGroupPurchaseId(Long userId, Long groupPurchaseId); + + List findByUserId(Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Category.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Category.java new file mode 100644 index 0000000..5084a3b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Category.java @@ -0,0 +1,46 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "group_purchase_category") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE group_purchase_category SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Category extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; // 카테고리명(밀키트, 대용량 식자재 등) + + @Column(nullable = false) + private int sortOrder; // 정렬 순서 + + @Column(columnDefinition = "TEXT") + private String description; // 카테고리 설명 + + @Builder + public Category(Long id, String name, int sortOrder, String description) { + this.id = id; + this.name = name; + this.sortOrder = sortOrder; + this.description = description; + } + + public void update(String name, int sortOrder, String description) { + this.name = name; + this.sortOrder = sortOrder; + this.description = description; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Comment.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Comment.java new file mode 100644 index 0000000..f8801fd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Comment.java @@ -0,0 +1,34 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment { + + private Long id; + private Long groupPurchaseId; + private Long userId; + private Long parentId; // 상위 댓글 ID (대댓글용 셀프 참조 필드) + private String content; // 댓글 내용 + private boolean isDeleted; // 삭제 여부 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @Builder + public Comment(Long id, Long groupPurchaseId, Long userId, Long parentId, String content) { + this.id = id; + this.groupPurchaseId = groupPurchaseId; + this.userId = userId; + this.parentId = parentId; + this.content = content; + this.isDeleted = false; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchase.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchase.java new file mode 100644 index 0000000..853e4a1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchase.java @@ -0,0 +1,142 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "group_purchase", + indexes = @Index( + name = "idx_gp_status_category_deadline", + columnList = "status, category_id, deadline")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE group_purchase SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class GroupPurchase extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long creatorId; // 개설자 유저ID + + @Column(nullable = false) + private Long categoryId; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; // 공동 구매 상세 본문 + + @Column(nullable = false) + private long price; // 인당 금액 + + @Column(nullable = false) + private int minParticipants; // 최소 성사 인원 + + @Column(nullable = false) + private int maxParticipants; // 최대 제한 인원 + + @Column(nullable = false) + private int currentParticipants; // 현재 참여 인원 + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private PurchaseStatus status; + + @Column(nullable = false) + private LocalDateTime deadline; + + @Column(nullable = false) + private String pickupLocation; // 수령 장소 + + @Column(nullable = false) + private int viewCount; // 조회수 + + @Column(nullable = true) + private Double latitude; + + @Column(nullable = true) + private Double longitude; + + @Column(nullable = true) + private String imageUrl; + + @Column(nullable = true) + private Long creatorAccountId; + + @Builder + public GroupPurchase(Long id, Long creatorId + , Long categoryId, String title + , String content, long price + , int minParticipants, int maxParticipants + , LocalDateTime deadline, String pickupLocation + , Double latitude, Double longitude + , String imageUrl, Long creatorAccountId) + { + this.id = id; + this.creatorId = creatorId; + this.categoryId = categoryId; + this.title = title; + this.content = content; + this.price = price; + this.minParticipants = minParticipants; + this.maxParticipants = maxParticipants; + this.currentParticipants = 0; + this.status = PurchaseStatus.RECRUITING; + this.deadline = deadline; + this.pickupLocation = pickupLocation; + this.viewCount = 0; + this.latitude = latitude; + this.longitude = longitude; + this.imageUrl = imageUrl; + this.creatorAccountId = creatorAccountId; + } + + public void update(Long categoryId, String title, String content, long price, int minParticipants, int maxParticipants, LocalDateTime deadline, String pickupLocation, String imageUrl) { + this.categoryId = categoryId; + this.title = title; + this.content = content; + this.price = price; + this.minParticipants = minParticipants; + this.maxParticipants = maxParticipants; + this.deadline = deadline; + this.pickupLocation = pickupLocation; + this.imageUrl = imageUrl; + } + + public void increaseViewCount() { + this.viewCount++; + } + + public void join() { + this.currentParticipants++; + if (this.currentParticipants >= this.maxParticipants) { + this.status = PurchaseStatus.SUCCESS; + } + } + + public void leave() { + if (this.currentParticipants > 0) { + this.currentParticipants--; + } + if (this.status == PurchaseStatus.SUCCESS && this.currentParticipants < this.maxParticipants) { + this.status = PurchaseStatus.RECRUITING; + } + } + + public void updateStatusByAdmin(PurchaseStatus status) { + this.status = status; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseApplication.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseApplication.java new file mode 100644 index 0000000..8a15d96 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseApplication.java @@ -0,0 +1,61 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ApplicationStatus; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE group_purchase_application SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class GroupPurchaseApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; // 신청자 유저ID + + @Column(nullable = false) + private String title; // 공구 상품명/제목 + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; // 상세 설명/신청 사유 + + @Column(nullable = true) + private String productUrl; // 상품 링크 (선택) + + @Column(nullable = false) + private int expectedPrice; // 희망 인당 금액 + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ApplicationStatus status; // 신청 상태 + + @Column(nullable = true) + private String adminFeedback; // 관리자 피드백 의견 + + @Builder + public GroupPurchaseApplication(Long id, Long userId, String title, String content, String productUrl, int expectedPrice) { + this.id = id; + this.userId = userId; + this.title = title; + this.content = content; + this.productUrl = productUrl; + this.expectedPrice = expectedPrice; + this.status = ApplicationStatus.PENDING; + } + + public void updateStatus(ApplicationStatus status, String adminFeedback) { + this.status = status; + this.adminFeedback = adminFeedback; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseParticipant.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseParticipant.java new file mode 100644 index 0000000..7ba0383 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/GroupPurchaseParticipant.java @@ -0,0 +1,50 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ParticipantStatus; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "group_purchase_participant") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE group_purchase_participant SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class GroupPurchaseParticipant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_purchase_id", nullable = false) + private Long groupPurchaseId; // 공동구매 ID + + @Column(name = "user_id", nullable = false) + private Long userId; // 참여자 ID + + @Column(name = "participant_status", nullable = false) + @Enumerated(EnumType.STRING) + private ParticipantStatus participantStatus; // 참여 상태 + + @Column(name = "account_id", nullable = false) + private Long accountId; // 결제 계좌 ID + + @Builder + public GroupPurchaseParticipant(Long id, Long groupPurchaseId, Long userId, Long accountId, ParticipantStatus participantStatus) { + this.id = id; + this.groupPurchaseId = groupPurchaseId; + this.userId = userId; + this.accountId = accountId; + this.participantStatus = participantStatus != null ? participantStatus : ParticipantStatus.JOINED; + } + + public void updateStatus(ParticipantStatus status) { + this.participantStatus = status; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Product.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Product.java new file mode 100644 index 0000000..d8a9211 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Product.java @@ -0,0 +1,65 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE product SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Product extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private long price; + + @Column(nullable = true, columnDefinition = "TEXT") + private String description; + + @Column(nullable = true) + private String imageUrl; + + @Column(nullable = false) + private Long categoryId; + + @Column(nullable = true) + private Long applicationId; // 참고한 공동구매 신청 ID (선택) + + @Column(nullable = true) + private Long reportId; // 참고한 제보 ID (선택) + + @Builder + public Product(Long id, String name, long price, String description, String imageUrl, Long categoryId, Long applicationId, Long reportId) { + this.id = id; + this.name = name; + this.price = price; + this.description = description; + this.imageUrl = imageUrl; + this.categoryId = categoryId; + this.applicationId = applicationId; + this.reportId = reportId; + } + + public void update(String name, long price, String description, String imageUrl, Long categoryId, Long applicationId, Long reportId) { + this.name = name; + this.price = price; + this.description = description; + this.imageUrl = imageUrl; + this.categoryId = categoryId; + this.applicationId = applicationId; + this.reportId = reportId; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Report.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Report.java new file mode 100644 index 0000000..ffb581c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Report.java @@ -0,0 +1,69 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "report") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE report SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Report extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long reporterId; // 신고자 ID + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ReportTargetType targetType; // 신고 대상 유형 (공동구매, 댓글) + + @Column(nullable = false) + private Long targetId; // 신고 대상 ID + + @Column(nullable = false, columnDefinition = "TEXT") + private String reason; // 신고 사유 내용 + + @Column(nullable = false) + private boolean isProcessed; // 관리자 처리 여부 + + private LocalDateTime processedAt; // 관리자 처리 일시 + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ReportProcessResult processResult; // 처리 결과 + + @Column(columnDefinition = "TEXT") + private String processReason; // 처리 사유 + + @Builder + public Report(Long reporterId, ReportTargetType targetType, Long targetId, String reason) { + this.reporterId = reporterId; + this.targetType = targetType; + this.targetId = targetId; + this.reason = reason; + this.isProcessed = false; + this.processResult = ReportProcessResult.PENDING; + } + + public void process(ReportProcessResult result, String processReason) { + this.isProcessed = true; + this.processResult = result; + this.processReason = processReason; + this.processedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Wishlist.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Wishlist.java new file mode 100644 index 0000000..4a39fcb --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/Wishlist.java @@ -0,0 +1,44 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table( + name = "wishlist", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_wishlist_user_group_purchase", + columnNames = {"user_id", "group_purchase_id"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE wishlist SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Wishlist extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "group_purchase_id", nullable = false) + private Long groupPurchaseId; + + @Builder + public Wishlist(Long id, Long userId, Long groupPurchaseId) { + this.id = id; + this.userId = userId; + this.groupPurchaseId = groupPurchaseId; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ApplicationStatus.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ApplicationStatus.java new file mode 100644 index 0000000..20df84f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ApplicationStatus.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums; + +public enum ApplicationStatus { + PENDING("대기중"), + APPROVED("승인됨"), + REJECTED("반려됨"); + + private final String description; + + ApplicationStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ParticipantStatus.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ParticipantStatus.java new file mode 100644 index 0000000..b709704 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ParticipantStatus.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums; + +public enum ParticipantStatus { + JOINED("참여 완료"), + CANCELED("참여 취소"), + RECEIVED("물품 수령 완료"); + + private final String description; + + ParticipantStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/PurchaseStatus.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/PurchaseStatus.java new file mode 100644 index 0000000..7ce31e5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/PurchaseStatus.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums; + +public enum PurchaseStatus { + RECRUITING("모집중"), + SUCCESS("모집 성공"), + FAILED("무산됨"), + CLOSED("거래 종료"), + BLIND("블라인드 처리"); + + private final String description; + + PurchaseStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportProcessResult.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportProcessResult.java new file mode 100644 index 0000000..1ff69b0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportProcessResult.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums; + +public enum ReportProcessResult { + PENDING, + DISMISSED, + WARNED, + DELETED +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportTargetType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportTargetType.java new file mode 100644 index 0000000..ef6d056 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/domain/enums/ReportTargetType.java @@ -0,0 +1,6 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums; + +public enum ReportTargetType { + GROUP_PURCHASE, + COMMENT +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseApplicationCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseApplicationCreateRequest.java new file mode 100644 index 0000000..aa0af6d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseApplicationCreateRequest.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record GroupPurchaseApplicationCreateRequest( + @NotBlank(message = "공구 제목/상품명은 필수 입력값입니다.") + String title, + + @NotBlank(message = "신청 사유 및 내용은 필수 입력값입니다.") + String content, + + String productUrl, + + @Min(value = 0, message = "희망 금액은 0원 이상이어야 합니다.") + int expectedPrice +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryCreateRequest.java new file mode 100644 index 0000000..32aa422 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryCreateRequest.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GroupPurchaseCategoryCreateRequest( + @NotBlank(message = "카테고리명은 필수 입력값입니다.") + String name, + + @NotNull(message = "정렬 순서는 필수 입력값입니다.") + Integer sortOrder, + + String description +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryUpdateRequest.java new file mode 100644 index 0000000..5523323 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCategoryUpdateRequest.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GroupPurchaseCategoryUpdateRequest( + @NotBlank(message = "카테고리명은 필수 입력값입니다.") + String name, + + @NotNull(message = "정렬 순서는 필수 입력값입니다.") + Integer sortOrder, + + String description +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCreateRequest.java new file mode 100644 index 0000000..75bc0f0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseCreateRequest.java @@ -0,0 +1,40 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record GroupPurchaseCreateRequest( + @NotNull(message = "카테고리 ID는 필수 입력값입니다.") + Long categoryId, + + @NotBlank(message = "제목은 필수 입력값입니다.") + String title, + + @NotBlank(message = "내용은 필수 입력값입니다.") + String content, + + @Min(value = 0, message = "금액은 0원 이상이어야 합니다.") + long price, + + @Min(value = 1, message = "최소 성사 인원은 1명 이상이어야 합니다.") + int minParticipants, + + @Min(value = 1, message = "최대 제한 인원은 1명 이상이어야 합니다.") + int maxParticipants, + + @NotNull(message = "마감 기한은 필수 입력값입니다.") + @Future(message = "마감 기한은 미래 시점이어야 합니다.") + LocalDateTime deadline, + + @NotBlank(message = "수령 장소는 필수 입력값입니다.") + String pickupLocation, + + String imageUrl, + + @NotNull(message = "결제 계좌 ID는 필수입니다.") + Long accountId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseJoinRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseJoinRequest.java new file mode 100644 index 0000000..21b1783 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseJoinRequest.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record GroupPurchaseJoinRequest( + @NotNull(message = "결제 계좌 ID는 필수입니다.") + Long accountId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseUpdateRequest.java new file mode 100644 index 0000000..26eb3ad --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/GroupPurchaseUpdateRequest.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record GroupPurchaseUpdateRequest( + @NotNull(message = "카테고리 ID는 필수 입력값입니다.") + Long categoryId, + + @NotBlank(message = "제목은 필수 입력값입니다.") + String title, + + @NotBlank(message = "내용은 필수 입력값입니다.") + String content, + + @Min(value = 0, message = "금액은 0원 이상이어야 합니다.") + long price, + + @Min(value = 1, message = "최소 성사 인원은 1명 이상이어야 합니다.") + int minParticipants, + + @Min(value = 1, message = "최대 제한 인원은 1명 이상이어야 합니다.") + int maxParticipants, + + @NotNull(message = "마감 기한은 필수 입력값입니다.") + @Future(message = "마감 기한은 미래 시점이어야 합니다.") + LocalDateTime deadline, + + @NotBlank(message = "수령 장소는 필수 입력값입니다.") + String pickupLocation, + + String imageUrl +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductCreateRequest.java new file mode 100644 index 0000000..dbf6a8c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductCreateRequest.java @@ -0,0 +1,25 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ProductCreateRequest( + @NotBlank(message = "상품명은 필수 입력값입니다.") + String name, + + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + long price, + + String description, + + String imageUrl, + + @NotNull(message = "카테고리는 필수 입력값입니다.") + Long categoryId, + + Long applicationId, + + Long reportId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductUpdateRequest.java new file mode 100644 index 0000000..642b1f6 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ProductUpdateRequest.java @@ -0,0 +1,25 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ProductUpdateRequest( + @NotBlank(message = "상품명은 필수 입력값입니다.") + String name, + + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + long price, + + String description, + + String imageUrl, + + @NotNull(message = "카테고리는 필수 입력값입니다.") + Long categoryId, + + Long applicationId, + + Long reportId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportCreateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportCreateRequest.java new file mode 100644 index 0000000..cbebe9e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportCreateRequest.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ReportCreateRequest( + @NotNull(message = "신고 대상 유형은 필수 입력값입니다.") + ReportTargetType targetType, + + @NotNull(message = "신고 대상 ID는 필수 입력값입니다.") + Long targetId, + + @NotBlank(message = "신고 사유는 필수 입력값입니다.") + String reason +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportProcessRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportProcessRequest.java new file mode 100644 index 0000000..6012c0c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/request/ReportProcessRequest.java @@ -0,0 +1,14 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ReportProcessRequest( + @NotNull(message = "처리 조치 결과는 필수 입력값입니다.") + ReportProcessResult action, + + @NotBlank(message = "처리 사유는 필수 입력값입니다.") + String reason +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseAdminResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseAdminResponse.java new file mode 100644 index 0000000..54a4104 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseAdminResponse.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; + +import java.time.LocalDateTime; + +public record GroupPurchaseAdminResponse( + + Long id, + Long creatorId, + String creatorUsername, + Long categoryId, + String categoryName, + String title, + long price, + int minParticipants, + int maxParticipants, + int currentParticipants, + PurchaseStatus status, + LocalDateTime deadline, + LocalDateTime createdAt, + long reportCount +) { + public static GroupPurchaseAdminResponse of(GroupPurchase gp, String creatorUsername, String categoryName, long reportCount) { + return new GroupPurchaseAdminResponse( + + gp.getId(), + gp.getCreatorId(), + creatorUsername, + gp.getCategoryId(), + categoryName, + gp.getTitle(), + gp.getPrice(), + gp.getMinParticipants(), + gp.getMaxParticipants(), + gp.getCurrentParticipants(), + gp.getStatus(), + gp.getDeadline(), + gp.getCreatedAt(), + reportCount + + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseApplicationResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseApplicationResponse.java new file mode 100644 index 0000000..96cfffa --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseApplicationResponse.java @@ -0,0 +1,34 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseApplication; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ApplicationStatus; + +import java.time.LocalDateTime; + +public record GroupPurchaseApplicationResponse( + Long id, + Long userId, + String title, + String content, + String productUrl, + int expectedPrice, + ApplicationStatus status, + String adminFeedback, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static GroupPurchaseApplicationResponse from(GroupPurchaseApplication application) { + return new GroupPurchaseApplicationResponse( + application.getId(), + application.getUserId(), + application.getTitle(), + application.getContent(), + application.getProductUrl(), + application.getExpectedPrice(), + application.getStatus(), + application.getAdminFeedback(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseCategoryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseCategoryResponse.java new file mode 100644 index 0000000..e7bc9ea --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseCategoryResponse.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; + +import java.time.LocalDateTime; + +public record GroupPurchaseCategoryResponse( + Long id, + String name, + int sortOrder, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static GroupPurchaseCategoryResponse from(Category category) { + return new GroupPurchaseCategoryResponse( + category.getId(), + category.getName(), + category.getSortOrder(), + category.getCreatedAt(), + category.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseDashboardResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseDashboardResponse.java new file mode 100644 index 0000000..8bb5e9b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseDashboardResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +public record GroupPurchaseDashboardResponse( + long todayCreatedCount, + long activeParticipantsCount, + long recruitingCount, + long successCount, + long failedCount, + double recruitingRatio, + double successRatio, + double failedRatio +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseJoinResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseJoinResponse.java new file mode 100644 index 0000000..23df1d9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseJoinResponse.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import java.math.BigDecimal; + +public record GroupPurchaseJoinResponse( + GroupPurchaseResponse groupPurchase, + boolean budgetWarning, + BigDecimal remainingBudget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseResponse.java new file mode 100644 index 0000000..fc233f5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/GroupPurchaseResponse.java @@ -0,0 +1,57 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import java.time.LocalDateTime; + +public record GroupPurchaseResponse( + Long id, + Long creatorId, + String creatorNickname, + Long categoryId, + String title, + String content, + long price, + int minParticipants, + int maxParticipants, + int currentParticipants, + PurchaseStatus status, + LocalDateTime deadline, + String pickupLocation, + int viewCount, + String imageUrl, + double achievementRate, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static GroupPurchaseResponse from(GroupPurchase groupPurchase) { + return of(groupPurchase, "탈퇴한 사용자"); + } + + public static GroupPurchaseResponse of(GroupPurchase groupPurchase, String creatorNickname) { + double rate = groupPurchase.getMaxParticipants() == 0 ? 0.0 : + ((double) groupPurchase.getCurrentParticipants() / groupPurchase.getMaxParticipants()) * 100.0; + rate = Math.round(rate * 100.0) / 100.0; + + return new GroupPurchaseResponse( + groupPurchase.getId(), + groupPurchase.getCreatorId(), + creatorNickname, + groupPurchase.getCategoryId(), + groupPurchase.getTitle(), + groupPurchase.getContent(), + groupPurchase.getPrice(), + groupPurchase.getMinParticipants(), + groupPurchase.getMaxParticipants(), + groupPurchase.getCurrentParticipants(), + groupPurchase.getStatus(), + groupPurchase.getDeadline(), + groupPurchase.getPickupLocation(), + groupPurchase.getViewCount(), + groupPurchase.getImageUrl(), + rate, + groupPurchase.getCreatedAt(), + groupPurchase.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ProductResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ProductResponse.java new file mode 100644 index 0000000..4d64049 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ProductResponse.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Product; + +import java.time.LocalDateTime; + +public record ProductResponse( + Long id, + String name, + long price, + String description, + String imageUrl, + Long categoryId, + Long applicationId, + Long reportId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getName(), + product.getPrice(), + product.getDescription(), + product.getImageUrl(), + product.getCategoryId(), + product.getApplicationId(), + product.getReportId(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ReportResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ReportResponse.java new file mode 100644 index 0000000..ee9eee7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/dto/response/ReportResponse.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Report; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; + +import java.time.LocalDateTime; + +public record ReportResponse( + Long id, + Long reporterId, + ReportTargetType targetType, + Long targetId, + String reason, + boolean isProcessed, + LocalDateTime processedAt, + ReportProcessResult processResult, + String processReason, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static ReportResponse from(Report report) { + return new ReportResponse( + report.getId(), + report.getReporterId(), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.isProcessed(), + report.getProcessedAt(), + report.getProcessResult(), + report.getProcessReason(), + report.getCreatedAt(), + report.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/event/GroupPurchaseCreatedEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/event/GroupPurchaseCreatedEvent.java new file mode 100644 index 0000000..701751e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/grouppruchase/event/GroupPurchaseCreatedEvent.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.event; + +public record GroupPurchaseCreatedEvent( + + Long categoryId, + String categoryName, + String title, + Long groupPurchaseId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/api/ImageController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/api/ImageController.java new file mode 100644 index 0000000..3629c8d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/api/ImageController.java @@ -0,0 +1,59 @@ +package com.chaewookim.accountbookformoms.domain.image.api; + +import com.chaewookim.accountbookformoms.domain.image.application.ImageService; +import com.chaewookim.accountbookformoms.domain.image.dto.request.AttachImageRequest; +import com.chaewookim.accountbookformoms.domain.image.dto.response.ImageResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Image API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class ImageController { + + private final ImageService imageService; + + @Operation(summary = "게시물 이미지 첨부", + description = "이미 업로드된 URL(S3 또는 외부)을 게시물에 연결합니다. multipart 업로드는 별도 인프라 도입 후 추가.") + @PostMapping("/boards/{postId}/images") + public ResponseEntity> attach( + @PathVariable Long postId, + @RequestBody @Valid AttachImageRequest request, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + imageService.attachToBoard(postId, request, principal.getUserId()))); + } + + @Operation(summary = "게시물 이미지 목록") + @GetMapping("/boards/{postId}/images") + public ResponseEntity>> list(@PathVariable Long postId) { + return ResponseEntity.ok(ApiResponse.success(imageService.listBoardImages(postId))); + } + + @Operation(summary = "이미지 삭제") + @DeleteMapping("/images/{imageId}") + public ResponseEntity> delete( + @PathVariable Long imageId, + @AuthenticationPrincipal UserPrincipal principal + ) { + imageService.deleteImage(imageId, principal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(imageId)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/application/ImageService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/application/ImageService.java new file mode 100644 index 0000000..208b4ea --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/application/ImageService.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.image.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.image.dao.ImageRepository; +import com.chaewookim.accountbookformoms.domain.image.dto.request.AttachImageRequest; +import com.chaewookim.accountbookformoms.domain.image.dto.response.ImageResponse; +import com.chaewookim.accountbookformoms.domain.image.entity.Image; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageService { + + private final ImageRepository imageRepository; + private final BoardRepository boardRepository; + + @Transactional + public ImageResponse attachToBoard(Long boardId, AttachImageRequest request, Long userId) { + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + if (!board.getUserId().equals(userId)) { + throw new CustomException(BoardErrorCode.BOARD_ACCESS_DENIED); + } + int nextOrder = imageRepository + .findByReferenceTypeAndReferenceIdOrderBySortOrderAsc(Image.ReferenceType.BOARD, boardId) + .size(); + Image saved = imageRepository.save(Image.builder() + .imageUrl(request.imageUrl()) + .s3Key(request.s3Key()) + .referenceType(Image.ReferenceType.BOARD) + .referenceId(boardId) + .sortOrder(nextOrder) + .build()); + return ImageResponse.from(saved); + } + + public List listBoardImages(Long boardId) { + return imageRepository + .findByReferenceTypeAndReferenceIdOrderBySortOrderAsc(Image.ReferenceType.BOARD, boardId) + .stream().map(ImageResponse::from).toList(); + } + + public List urlsOfBoard(Long boardId) { + return imageRepository + .findByReferenceTypeAndReferenceIdOrderBySortOrderAsc(Image.ReferenceType.BOARD, boardId) + .stream().map(Image::getImageUrl).toList(); + } + + public Map> urlsByBoards(Collection boardIds) { + if (boardIds.isEmpty()) return Map.of(); + List images = imageRepository + .findByReferenceTypeAndReferenceIdInOrderBySortOrderAsc(Image.ReferenceType.BOARD, boardIds); + Map> result = new HashMap<>(); + images.forEach(img -> result + .computeIfAbsent(img.getReferenceId(), k -> new ArrayList<>()) + .add(img.getImageUrl())); + return result; + } + + @Transactional + public void deleteImage(Long imageId, Long userId) { + Image image = imageRepository.findById(imageId) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + if (image.getReferenceType() == Image.ReferenceType.BOARD) { + Board board = boardRepository.findById(image.getReferenceId()) + .orElseThrow(() -> new CustomException(BoardErrorCode.BOARD_NOT_FOUND)); + if (!board.getUserId().equals(userId)) { + throw new CustomException(BoardErrorCode.BOARD_ACCESS_DENIED); + } + } + imageRepository.delete(image); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/dao/ImageRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dao/ImageRepository.java new file mode 100644 index 0000000..d02c677 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dao/ImageRepository.java @@ -0,0 +1,20 @@ +package com.chaewookim.accountbookformoms.domain.image.dao; + +import com.chaewookim.accountbookformoms.domain.image.entity.Image; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface ImageRepository extends JpaRepository { + + List findByReferenceTypeAndReferenceIdOrderBySortOrderAsc( + Image.ReferenceType referenceType, Long referenceId); + + List findByReferenceTypeAndReferenceIdInOrderBySortOrderAsc( + Image.ReferenceType referenceType, Collection referenceIds); + + void deleteByReferenceTypeAndReferenceId(Image.ReferenceType referenceType, Long referenceId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/request/AttachImageRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/request/AttachImageRequest.java new file mode 100644 index 0000000..9ddb068 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/request/AttachImageRequest.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.image.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AttachImageRequest( + @NotBlank @Size(max = 500) String imageUrl, + @Size(max = 500) String s3Key +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/response/ImageResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/response/ImageResponse.java new file mode 100644 index 0000000..a98a39b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/dto/response/ImageResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.image.dto.response; + +import com.chaewookim.accountbookformoms.domain.image.entity.Image; + +public record ImageResponse( + Long id, + String imageUrl, + int sortOrder +) { + public static ImageResponse from(Image image) { + return new ImageResponse(image.getId(), image.getImageUrl(), image.getSortOrder()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/image/entity/Image.java b/src/main/java/com/chaewookim/accountbookformoms/domain/image/entity/Image.java new file mode 100644 index 0000000..4622f5a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/image/entity/Image.java @@ -0,0 +1,57 @@ +package com.chaewookim.accountbookformoms.domain.image.entity; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "image", + indexes = @Index(name = "idx_image_reference", columnList = "reference_id, reference_type") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Image extends BaseEntity { + + public enum ReferenceType {BOARD, GROUP_PURCHASE} + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "s3_key", length = 500) + private String s3Key; + + @Enumerated(EnumType.STRING) + @Column(name = "reference_type", nullable = false, length = 20) + private ReferenceType referenceType; + + @Column(name = "reference_id", nullable = false) + private Long referenceId; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Builder + public Image(String imageUrl, String s3Key, ReferenceType referenceType, Long referenceId, int sortOrder) { + this.imageUrl = imageUrl; + this.s3Key = s3Key; + this.referenceType = referenceType; + this.referenceId = referenceId; + this.sortOrder = sortOrder; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/api/IncomeCompareController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/api/IncomeCompareController.java new file mode 100644 index 0000000..d07b861 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/api/IncomeCompareController.java @@ -0,0 +1,93 @@ +package com.chaewookim.accountbookformoms.domain.income.api; + +import com.chaewookim.accountbookformoms.domain.income.application.IncomeCompareService; +import com.chaewookim.accountbookformoms.domain.income.dto.request.IncomeCompareRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.request.PublicIncomeFilterRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.response.IncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.MyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PairIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PublicMonthlyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "수입 비교(Income Compare)", description = "내 수입 조회 및 공개 사용자 수입 비교 API (고정/변동 구분)") +@RestController +@RequestMapping("/api/v1/incomes") +@RequiredArgsConstructor +public class IncomeCompareController { + + private final IncomeCompareService incomeCompareService; + + @Operation(summary = "내 월별 수입 조회", description = "월 총 수입과 고정/변동별 카테고리 합계") + @GetMapping("/me") + public ResponseEntity> getMyMonthlyIncome( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + incomeCompareService.getMyMonthlyIncome(principal.getUserId(), yearMonth))); + } + + @Operation(summary = "공개 사용자 월별 총 수입 목록", + description = "포트폴리오 공개 사용자들의 월별 총 수입. 연/월/금액 구간 필터 지원") + @GetMapping("/compare/users") + public ResponseEntity>> getPublicMonthlyIncomes( + @ParameterObject @Valid @ModelAttribute PublicIncomeFilterRequest filter + ) { + return ResponseEntity.ok(ApiResponse.success(incomeCompareService.getPublicMonthlyIncomes(filter))); + } + + @Operation(summary = "그룹 평균과 내 수입 비교", + description = "type: AGE(본인 나이대) / AMOUNT(금액 구간) / CATEGORY(특정 카테고리). 고정/변동 분리 반환") + @GetMapping("/compare") + public ResponseEntity> compare( + @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject @Valid @ModelAttribute IncomeCompareRequest request + ) { + return ResponseEntity.ok(ApiResponse.success( + incomeCompareService.compareWithGroup(principal.getUserId(), request))); + } + + @Operation(summary = "선택 사용자와 본인 수입 세부 조회", + description = "본인과 선택한 공개 사용자의 월 수입(고정/변동/카테고리별)을 함께 반환") + @GetMapping("/compare/users/{targetUserId}/details") + public ResponseEntity> getPairIncomeDetails( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + incomeCompareService.getPairIncomeDetails(principal.getUserId(), targetUserId, yearMonth))); + } + + @Operation(summary = "선택 사용자와 본인 수입 비교", + description = "type: AGE / AMOUNT / CATEGORY. CATEGORY는 categoryId 필수. 고정/변동 분리 반환") + @GetMapping("/compare/users/{targetUserId}") + public ResponseEntity> compareWithUser( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam IncomeCompareType type, + @RequestParam String yearMonth, + @RequestParam(required = false) Long categoryId + ) { + return ResponseEntity.ok(ApiResponse.success( + incomeCompareService.compareWithUser(principal.getUserId(), targetUserId, type, yearMonth, categoryId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeCompareService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeCompareService.java new file mode 100644 index 0000000..9f16312 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeCompareService.java @@ -0,0 +1,428 @@ +package com.chaewookim.accountbookformoms.domain.income.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.income.dto.request.IncomeCompareRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.request.PublicIncomeFilterRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.response.CategoryIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.IncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.MyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PairIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PublicMonthlyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; +import com.chaewookim.accountbookformoms.domain.income.error.IncomeErrorCode; +import com.chaewookim.accountbookformoms.domain.user.application.UserLocationService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class IncomeCompareService { + + private static final TransactionType TYPE = TransactionType.INCOME; + private static final double MIN_RADIUS_KM = 0.1; + private static final double MAX_RADIUS_KM = 50.0; + + private final TransactionRepository transactionRepository; + private final FixedTransactionRepository fixedTransactionRepository; + private final UserRepository userRepository; + private final UserLocationService userLocationService; + private final IncomeGroupCacheService groupCache; + + public MyIncomeResponse getMyMonthlyIncome(Long userId, String yearMonth) { + + YearMonth ym = parseYearMonth(yearMonth); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + List fixedCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(userId, TYPE, true, startDate, endDate)); + List variableCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(userId, TYPE, false, startDate, endDate)); + + BigDecimal fixedTotal = sumCategoryAmount(fixedCategories); + BigDecimal variableTotal = sumCategoryAmount(variableCategories); + + return new MyIncomeResponse( + yearMonth, + fixedTotal.add(variableTotal), + fixedTotal, + variableTotal, + fixedCategories, + variableCategories); + } + + public List getPublicMonthlyIncomes(PublicIncomeFilterRequest filter) { + + validateAmountRange(filter.minAmount(), filter.maxAmount()); + + YearMonth ym = buildYearMonth(filter.year(), filter.month()); + String yearMonth = ym.toString(); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + List fixedRows = fixedTransactionRepository.sumPublicMonthly(TYPE, startDate, endDate); + List variableRows = transactionRepository.sumPublicMonthly(TYPE, startDate, endDate); + + Map fixedSums = toUserSumMap(fixedRows); + Map variableSums = toUserSumMap(variableRows); + Map usernames = collectUsernames(fixedRows, variableRows); + + return usernames.entrySet().stream() + .map(e -> { + Long userId = e.getKey(); + BigDecimal fixed = fixedSums.getOrDefault(userId, BigDecimal.ZERO); + BigDecimal variable = variableSums.getOrDefault(userId, BigDecimal.ZERO); + return new PublicMonthlyIncomeResponse( + userId, e.getValue(), yearMonth, + fixed.add(variable), fixed, variable); + }) + .filter(r -> withinRange(r.totalIncome(), filter.minAmount(), filter.maxAmount())) + .sorted((a, b) -> b.totalIncome().compareTo(a.totalIncome())) + .toList(); + } + + public PairIncomeDetailResponse getPairIncomeDetails(Long myUserId, Long targetUserId, String yearMonth) { + + YearMonth ym = parseYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(IncomeErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return new PairIncomeDetailResponse( + toUserIncomeDetail(me, ym, yearMonth), + toUserIncomeDetail(target, ym, yearMonth)); + } + + public UserIncomeCompareResponse compareWithUser(Long myUserId, Long targetUserId, + IncomeCompareType type, String yearMonth, Long categoryId) { + + YearMonth ym = parseYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(IncomeErrorCode.CANNOT_COMPARE_SELF); + } + + User me = userRepository.findById(myUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User target = loadPublicTarget(targetUserId); + + return switch (type) { + case AGE -> compareUsersByAge(me, target, ym, yearMonth); + case AMOUNT -> compareUsersByAmount(me, target, ym, yearMonth); + case CATEGORY -> compareUsersByCategory(me, target, ym, yearMonth, categoryId); + case LOCATION -> throw new CustomException(IncomeErrorCode.LOCATION_PAIR_NOT_SUPPORTED); + }; + } + + public IncomeCompareResponse compareWithGroup(Long userId, IncomeCompareRequest request) { + + YearMonth ym = parseYearMonth(request.yearMonth()); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + return switch (request.type()) { + case AGE -> compareByAge(user, ym, request.yearMonth()); + case AMOUNT -> compareByAmount(user, ym, request.yearMonth(), request.minAmount(), request.maxAmount()); + case CATEGORY -> compareByCategory(user, ym, request.yearMonth(), request.categoryId()); + case LOCATION -> compareByLocation(user, ym, request.yearMonth(), request.radiusKm()); + }; + } + + private IncomeCompareResponse compareByLocation(User user, YearMonth ym, String yearMonth, Double radiusKm) { + + if (radiusKm == null) { + throw new CustomException(IncomeErrorCode.RADIUS_REQUIRED); + } + if (radiusKm < MIN_RADIUS_KM || radiusKm > MAX_RADIUS_KM) { + throw new CustomException(IncomeErrorCode.INVALID_RADIUS); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Set nearbyUserIds = userLocationService.findNearbyUserIds(user.getId(), radiusKm); + + Map fixedSums = nearbyUserIds.isEmpty() + ? Map.of() + : toUserSumMap(fixedTransactionRepository.sumPublicByUserIds(TYPE, startDate, endDate, nearbyUserIds)); + Map variableSums = nearbyUserIds.isEmpty() + ? Map.of() + : toUserSumMap(transactionRepository.sumPublicByUserIds(TYPE, startDate, endDate, nearbyUserIds)); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, IncomeCompareType.LOCATION); + } + + private UserIncomeCompareResponse compareUsersByAge(User me, User target, YearMonth ym, String yearMonth) { + if (me.getBirthDate() == null || target.getBirthDate() == null) { + throw new CustomException(IncomeErrorCode.BIRTH_DATE_REQUIRED); + } + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + return UserIncomeCompareResponse.of( + IncomeCompareType.AGE, yearMonth, + me.getId(), decadeLabel(me.getBirthDate()), myFixed, myVariable, + target.getId(), decadeLabel(target.getBirthDate()), targetFixed, targetVariable); + } + + private UserIncomeCompareResponse compareUsersByAmount(User me, User target, YearMonth ym, String yearMonth) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(me.getId(), TYPE, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndType(target.getId(), TYPE, startDate, endDate)); + return UserIncomeCompareResponse.of( + IncomeCompareType.AMOUNT, yearMonth, + me.getId(), "월 총 수입", myFixed, myVariable, + target.getId(), "월 총 수입", targetFixed, targetVariable); + } + + private UserIncomeCompareResponse compareUsersByCategory(User me, User target, YearMonth ym, + String yearMonth, Long categoryId) { + if (categoryId == null) { + throw new CustomException(IncomeErrorCode.CATEGORY_ID_REQUIRED); + } + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + me.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + me.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal targetFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + target.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal targetVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + target.getId(), TYPE, categoryId, startDate, endDate)); + String categoryLabel = "카테고리 #" + categoryId; + return UserIncomeCompareResponse.of( + IncomeCompareType.CATEGORY, yearMonth, + me.getId(), categoryLabel, myFixed, myVariable, + target.getId(), categoryLabel, targetFixed, targetVariable); + } + + private IncomeCompareResponse compareByAge(User user, YearMonth ym, String yearMonth) { + + if (user.getBirthDate() == null) { + throw new CustomException(IncomeErrorCode.BIRTH_DATE_REQUIRED); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + int currentYear = LocalDate.now().getYear(); + int age = currentYear - user.getBirthDate().getYear(); + int decadeStart = (age / 10) * 10; + LocalDate birthFrom = LocalDate.of(currentYear - (decadeStart + 9), 1, 1); + LocalDate birthTo = LocalDate.of(currentYear - decadeStart, 12, 31); + + Map fixedSums = groupCache.getAgeFixedSums(startDate, endDate, birthFrom, birthTo); + Map variableSums = groupCache.getAgeVariableSums(startDate, endDate, birthFrom, birthTo); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, IncomeCompareType.AGE); + } + + private IncomeCompareResponse compareByAmount(User user, YearMonth ym, String yearMonth, + BigDecimal minAmount, BigDecimal maxAmount) { + + validateAmountRange(minAmount, maxAmount); + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Map fixedSums = groupCache.getAmountFixedSums(startDate, endDate, minAmount, maxAmount); + Map variableSums = groupCache.getAmountVariableSums(startDate, endDate, minAmount, maxAmount); + + return buildGroupCompare(user, ym, yearMonth, fixedSums, variableSums, IncomeCompareType.AMOUNT); + } + + private IncomeCompareResponse compareByCategory(User user, YearMonth ym, String yearMonth, Long categoryId) { + + if (categoryId == null) { + throw new CustomException(IncomeErrorCode.CATEGORY_ID_REQUIRED); + } + + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + Map fixedSums = groupCache.getCategoryFixedSums(startDate, endDate, categoryId); + Map variableSums = groupCache.getCategoryVariableSums(startDate, endDate, categoryId); + + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndTypeAndCategory( + user.getId(), TYPE, categoryId, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndTypeAndCategory( + user.getId(), TYPE, categoryId, startDate, endDate)); + + return buildAverage(user.getId(), yearMonth, IncomeCompareType.CATEGORY, + myFixed, myVariable, fixedSums, variableSums); + } + + private IncomeCompareResponse buildGroupCompare(User user, YearMonth ym, String yearMonth, + Map fixedSums, + Map variableSums, + IncomeCompareType type) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + BigDecimal myFixed = nullToZero(fixedTransactionRepository.sumByUserAndType(user.getId(), TYPE, startDate, endDate)); + BigDecimal myVariable = nullToZero(transactionRepository.sumByUserAndType(user.getId(), TYPE, startDate, endDate)); + return buildAverage(user.getId(), yearMonth, type, myFixed, myVariable, fixedSums, variableSums); + } + + private IncomeCompareResponse buildAverage(Long myUserId, String yearMonth, IncomeCompareType type, + BigDecimal myFixed, BigDecimal myVariable, + Map fixedSums, + Map variableSums) { + Map othersFixed = new HashMap<>(fixedSums); + othersFixed.remove(myUserId); + Map othersVariable = new HashMap<>(variableSums); + othersVariable.remove(myUserId); + + Map merged = new HashMap<>(othersFixed); + othersVariable.forEach((k, v) -> merged.merge(k, v, BigDecimal::add)); + long sampleSize = merged.size(); + + BigDecimal averageFixed = average(othersFixed, sampleSize); + BigDecimal averageVariable = average(othersVariable, sampleSize); + + return IncomeCompareResponse.of(type, yearMonth, myFixed, myVariable, averageFixed, averageVariable, sampleSize); + } + + private BigDecimal average(Map sums, long sampleSize) { + if (sampleSize <= 0) { + return BigDecimal.ZERO; + } + BigDecimal total = sums.values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add); + return total.divide(BigDecimal.valueOf(sampleSize), 2, RoundingMode.HALF_UP); + } + + private UserIncomeDetailResponse toUserIncomeDetail(User user, YearMonth ym, String yearMonth) { + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + List fixedCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(user.getId(), TYPE, true, startDate, endDate)); + List variableCategories = toCategoryResponses( + transactionRepository.sumByUserCategoryAndGenerated(user.getId(), TYPE, false, startDate, endDate)); + BigDecimal fixedTotal = sumCategoryAmount(fixedCategories); + BigDecimal variableTotal = sumCategoryAmount(variableCategories); + return new UserIncomeDetailResponse( + user.getId(), user.getUsername(), yearMonth, + fixedTotal.add(variableTotal), fixedTotal, variableTotal, + fixedCategories, variableCategories); + } + + private User loadPublicTarget(Long targetUserId) { + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (target.getUserSetting() == null || !Boolean.TRUE.equals(target.getUserSetting().getIsPortfolioPublic())) { + throw new CustomException(IncomeErrorCode.TARGET_NOT_PUBLIC); + } + return target; + } + + private List toCategoryResponses(List rows) { + return rows.stream() + .map(r -> new CategoryIncomeResponse( + (Long) r[0], + (String) r[1], + toBigDecimal(r[2]))) + .toList(); + } + + private Map toUserSumMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put((Long) row[0], toBigDecimal(row[row.length - 1])); + } + return map; + } + + private Map collectUsernames(List rowsA, List rowsB) { + Map map = new HashMap<>(); + for (Object[] row : rowsA) { + map.putIfAbsent((Long) row[0], (String) row[1]); + } + for (Object[] row : rowsB) { + map.putIfAbsent((Long) row[0], (String) row[1]); + } + return map; + } + + private BigDecimal sumCategoryAmount(List categories) { + return categories.stream() + .map(CategoryIncomeResponse::amount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private boolean withinRange(BigDecimal value, BigDecimal min, BigDecimal max) { + if (min != null && value.compareTo(min) < 0) return false; + if (max != null && value.compareTo(max) > 0) return false; + return true; + } + + private String decadeLabel(LocalDate birthDate) { + int age = LocalDate.now().getYear() - birthDate.getYear(); + return ((age / 10) * 10) + "대"; + } + + private BigDecimal nullToZero(BigDecimal value) { + return value != null ? value : BigDecimal.ZERO; + } + + private BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } + + private YearMonth parseYearMonth(String yearMonth) { + try { + return YearMonth.parse(yearMonth); + } catch (DateTimeParseException | NullPointerException e) { + throw new CustomException(IncomeErrorCode.INVALID_YEAR_MONTH); + } + } + + private YearMonth buildYearMonth(Integer year, Integer month) { + if (year == null || month == null) { + throw new CustomException(IncomeErrorCode.INVALID_YEAR_MONTH); + } + try { + return YearMonth.of(year, month); + } catch (Exception e) { + throw new CustomException(IncomeErrorCode.INVALID_YEAR_MONTH); + } + } + + private void validateAmountRange(BigDecimal minAmount, BigDecimal maxAmount) { + if (minAmount != null && maxAmount != null && minAmount.compareTo(maxAmount) > 0) { + throw new CustomException(IncomeErrorCode.INVALID_AMOUNT_RANGE); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeGroupCacheService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeGroupCacheService.java new file mode 100644 index 0000000..624177c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/application/IncomeGroupCacheService.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.income.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.global.config.RedisConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 수입 그룹 평균 캐싱 wrapper. fixed/variable 각각 별도 캐시. + */ +@Service +@RequiredArgsConstructor +public class IncomeGroupCacheService { + + private static final Long NO_EXCLUDE = -1L; + private static final TransactionType TYPE = TransactionType.INCOME; + + private final FixedTransactionRepository fixedTransactionRepository; + private final TransactionRepository transactionRepository; + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_AGE_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #birthFrom.toString(), #birthTo.toString())") + public Map getAgeFixedSums(LocalDate startDate, LocalDate endDate, LocalDate birthFrom, LocalDate birthTo) { + return toUserSumMap(fixedTransactionRepository.sumPublicByAgeRange(TYPE, startDate, endDate, birthFrom, birthTo, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_AGE_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #birthFrom.toString(), #birthTo.toString())") + public Map getAgeVariableSums(LocalDate startDate, LocalDate endDate, LocalDate birthFrom, LocalDate birthTo) { + return toUserSumMap(transactionRepository.sumPublicByAgeRange(TYPE, startDate, endDate, birthFrom, birthTo, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_AMOUNT_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #minAmount, #maxAmount)") + public Map getAmountFixedSums(LocalDate startDate, LocalDate endDate, BigDecimal minAmount, BigDecimal maxAmount) { + return toUserSumMap(fixedTransactionRepository.sumPublicByAmountRange(TYPE, startDate, endDate, minAmount, maxAmount, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_AMOUNT_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #minAmount, #maxAmount)") + public Map getAmountVariableSums(LocalDate startDate, LocalDate endDate, BigDecimal minAmount, BigDecimal maxAmount) { + return toUserSumMap(transactionRepository.sumPublicByAmountRange(TYPE, startDate, endDate, minAmount, maxAmount, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_CATEGORY_FIXED, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #categoryId)") + public Map getCategoryFixedSums(LocalDate startDate, LocalDate endDate, Long categoryId) { + return toUserSumMap(fixedTransactionRepository.sumPublicCategoryByUser(TYPE, categoryId, startDate, endDate, NO_EXCLUDE)); + } + + @Cacheable( + cacheNames = RedisConfig.CACHE_GROUP_INCOME_CATEGORY_VARIABLE, + key = "T(java.util.Objects).hash(#startDate.toString(), #endDate.toString(), #categoryId)") + public Map getCategoryVariableSums(LocalDate startDate, LocalDate endDate, Long categoryId) { + return toUserSumMap(transactionRepository.sumPublicCategoryByUser(TYPE, categoryId, startDate, endDate, NO_EXCLUDE)); + } + + private static Map toUserSumMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(((Number) row[0]).longValue(), toBigDecimal(row[1])); + } + return map; + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/IncomeCompareRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/IncomeCompareRequest.java new file mode 100644 index 0000000..00f78cd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/IncomeCompareRequest.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.request; + +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.math.BigDecimal; + +public record IncomeCompareRequest( + + @NotNull(message = "비교 기준은 필수입니다.") + IncomeCompareType type, + + @NotBlank(message = "연/월은 필수입니다.") + @Pattern(regexp = "^\\d{4}-(0[1-9]|1[0-2])$", message = "연/월은 YYYY-MM 형식이어야 합니다.") + String yearMonth, + + BigDecimal minAmount, + BigDecimal maxAmount, + Long categoryId, + + @DecimalMin(value = "0.1", message = "반경은 0.1km 이상이어야 합니다.") + @DecimalMax(value = "50.0", message = "반경은 50km 이하여야 합니다.") + Double radiusKm +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/PublicIncomeFilterRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/PublicIncomeFilterRequest.java new file mode 100644 index 0000000..82f6b7b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/request/PublicIncomeFilterRequest.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.math.BigDecimal; + +public record PublicIncomeFilterRequest( + + @Min(1900) @Max(2100) + Integer year, + + @Min(1) @Max(12) + Integer month, + + BigDecimal minAmount, + BigDecimal maxAmount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/CategoryIncomeResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/CategoryIncomeResponse.java new file mode 100644 index 0000000..0e80728 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/CategoryIncomeResponse.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import java.math.BigDecimal; + +public record CategoryIncomeResponse( + Long categoryId, + String categoryName, + BigDecimal amount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/IncomeCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/IncomeCompareResponse.java new file mode 100644 index 0000000..59918d3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/IncomeCompareResponse.java @@ -0,0 +1,32 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; + +import java.math.BigDecimal; + +public record IncomeCompareResponse( + IncomeCompareType type, + String yearMonth, + BigDecimal myAmount, + BigDecimal myFixedAmount, + BigDecimal myVariableAmount, + BigDecimal averageAmount, + BigDecimal averageFixedAmount, + BigDecimal averageVariableAmount, + long sampleSize, + BigDecimal difference +) { + public static IncomeCompareResponse of(IncomeCompareType type, String yearMonth, + BigDecimal myFixed, BigDecimal myVariable, + BigDecimal averageFixed, BigDecimal averageVariable, + long sampleSize) { + BigDecimal myAmount = myFixed.add(myVariable); + BigDecimal averageAmount = averageFixed.add(averageVariable); + return new IncomeCompareResponse( + type, yearMonth, + myAmount, myFixed, myVariable, + averageAmount, averageFixed, averageVariable, + sampleSize, + myAmount.subtract(averageAmount)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/MyIncomeResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/MyIncomeResponse.java new file mode 100644 index 0000000..b4fad55 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/MyIncomeResponse.java @@ -0,0 +1,14 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record MyIncomeResponse( + String yearMonth, + BigDecimal totalIncome, + BigDecimal fixedIncome, + BigDecimal variableIncome, + List fixedCategoryIncomes, + List variableCategoryIncomes +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PairIncomeDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PairIncomeDetailResponse.java new file mode 100644 index 0000000..3bbfff4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PairIncomeDetailResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +public record PairIncomeDetailResponse( + UserIncomeDetailResponse me, + UserIncomeDetailResponse target +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PublicMonthlyIncomeResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PublicMonthlyIncomeResponse.java new file mode 100644 index 0000000..d7c14e8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/PublicMonthlyIncomeResponse.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import java.math.BigDecimal; + +public record PublicMonthlyIncomeResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalIncome, + BigDecimal fixedIncome, + BigDecimal variableIncome +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeCompareResponse.java new file mode 100644 index 0000000..63d031c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeCompareResponse.java @@ -0,0 +1,35 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; + +import java.math.BigDecimal; + +public record UserIncomeCompareResponse( + IncomeCompareType type, + String yearMonth, + Long myUserId, + String myLabel, + BigDecimal myAmount, + BigDecimal myFixedAmount, + BigDecimal myVariableAmount, + Long targetUserId, + String targetLabel, + BigDecimal targetAmount, + BigDecimal targetFixedAmount, + BigDecimal targetVariableAmount, + BigDecimal difference +) { + public static UserIncomeCompareResponse of(IncomeCompareType type, String yearMonth, + Long myUserId, String myLabel, + BigDecimal myFixed, BigDecimal myVariable, + Long targetUserId, String targetLabel, + BigDecimal targetFixed, BigDecimal targetVariable) { + BigDecimal myAmount = myFixed.add(myVariable); + BigDecimal targetAmount = targetFixed.add(targetVariable); + return new UserIncomeCompareResponse( + type, yearMonth, + myUserId, myLabel, myAmount, myFixed, myVariable, + targetUserId, targetLabel, targetAmount, targetFixed, targetVariable, + myAmount.subtract(targetAmount)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeDetailResponse.java new file mode 100644 index 0000000..8f9644f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/dto/response/UserIncomeDetailResponse.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.income.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +public record UserIncomeDetailResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalIncome, + BigDecimal fixedIncome, + BigDecimal variableIncome, + List fixedCategoryIncomes, + List variableCategoryIncomes +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/enums/IncomeCompareType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/enums/IncomeCompareType.java new file mode 100644 index 0000000..03432e5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/enums/IncomeCompareType.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.income.enums; + +public enum IncomeCompareType { + AGE, + AMOUNT, + CATEGORY, + LOCATION +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/income/error/IncomeErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/income/error/IncomeErrorCode.java new file mode 100644 index 0000000..5fc033d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/income/error/IncomeErrorCode.java @@ -0,0 +1,24 @@ +package com.chaewookim.accountbookformoms.domain.income.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum IncomeErrorCode implements BaseErrorCode { + + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "연/월 값이 올바르지 않습니다."), + INVALID_AMOUNT_RANGE(HttpStatus.BAD_REQUEST, "금액 구간이 올바르지 않습니다."), + BIRTH_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "나이대 비교를 위해 생년월일이 필요합니다."), + CATEGORY_ID_REQUIRED(HttpStatus.BAD_REQUEST, "카테고리 비교를 위해 카테고리 ID가 필요합니다."), + RADIUS_REQUIRED(HttpStatus.BAD_REQUEST, "위치 비교를 위해 반경(km)이 필요합니다."), + INVALID_RADIUS(HttpStatus.BAD_REQUEST, "반경은 0.1km 이상 50km 이하여야 합니다."), + LOCATION_PAIR_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "위치 기반 1:1 비교는 지원하지 않습니다."), + CANNOT_COMPARE_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 비교할 수 없습니다."), + TARGET_NOT_PUBLIC(HttpStatus.FORBIDDEN, "비공개 사용자의 수입은 조회할 수 없습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/AdminLikeController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/AdminLikeController.java new file mode 100644 index 0000000..201e32a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/AdminLikeController.java @@ -0,0 +1,31 @@ +package com.chaewookim.accountbookformoms.domain.like.api; + +import com.chaewookim.accountbookformoms.domain.like.application.LikeCountReconcileService; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Like API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/likes") +public class AdminLikeController { + + private final LikeCountReconcileService reconcileService; + + @Operation(summary = "좋아요 카운터 정합성 수동 실행", + description = "Redis like:count:* 를 SCAN 하며 DB COUNT 와 비교, drift 를 정정합니다. " + + "limit=0 이면 무제한.") + @PostMapping("/reconcile") + public ResponseEntity> reconcile( + @RequestParam(defaultValue = "500") int limit + ) { + return ResponseEntity.ok(ApiResponse.success(reconcileService.reconcile(limit))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/LikeController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/LikeController.java new file mode 100644 index 0000000..d16c4cc --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/api/LikeController.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.like.api; + +import com.chaewookim.accountbookformoms.domain.like.application.PostLikeService; +import com.chaewookim.accountbookformoms.domain.like.dto.response.LikeToggleResponse; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +@Tag(name = "Like API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class LikeController { + + private final PostLikeService likeService; + + @Operation(summary = "게시물 좋아요 토글") + @PostMapping("/boards/{postId}/like") + public ResponseEntity> toggleBoardLike( + @PathVariable Long postId, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + likeService.toggle(LikeTargetType.BOARD, postId, principal.getUserId()))); + } + + @Operation(summary = "댓글 좋아요 토글") + @PostMapping("/comments/{commentId}/like") + public ResponseEntity> toggleCommentLike( + @PathVariable Long commentId, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + likeService.toggle(LikeTargetType.COMMENT, commentId, principal.getUserId()))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheService.java new file mode 100644 index 0000000..10a28a9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheService.java @@ -0,0 +1,106 @@ +package com.chaewookim.accountbookformoms.domain.like.application; + +import com.chaewookim.accountbookformoms.domain.like.dao.PostLikeRepository; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 좋아요 카운터 Redis 캐싱. + *

Cache-aside + INCR/DECR 하이브리드:

+ *
    + *
  • 읽기: Redis GET/MGET → 미스면 DB COUNT 후 SET(TTL 30분)
  • + *
  • 토글 write: DB mutation 이후 캐시가 존재할 때만 INCR/DECR (없으면 다음 읽기가 lazy fill).
  • + *
+ *

키가 없을 때 INCR 을 실행하지 않는 이유: 실제 DB count 가 0 이 아닐 수도 있어 counter 가 어긋남. + * 따라서 존재 여부(EXISTS)를 먼저 확인하고 있을 때만 원자적 증감. 없으면 다음 read 가 DB 정본 기준으로 재적재.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LikeCountCacheService { + + private static final String KEY_PREFIX = "like:count:"; + private static final Duration TTL = Duration.ofMinutes(30); + + private final RedisTemplate redisTemplate; + private final PostLikeRepository likeRepository; + + public long getCount(LikeTargetType type, Long targetId) { + String key = key(type, targetId); + String cached = redisTemplate.opsForValue().get(key); + if (cached != null) { + return parseSafe(cached); + } + long dbCount = likeRepository.countByTargetIdAndTargetType(targetId, type); + redisTemplate.opsForValue().set(key, String.valueOf(dbCount), TTL); + return dbCount; + } + + public Map getCounts(LikeTargetType type, Collection targetIds) { + if (targetIds == null || targetIds.isEmpty()) return Map.of(); + List ordered = targetIds.stream().distinct().toList(); + + List keys = ordered.stream().map(id -> key(type, id)).toList(); + List cached = redisTemplate.opsForValue().multiGet(keys); + + Map result = new HashMap<>(); + List missing = new ArrayList<>(); + for (int i = 0; i < ordered.size(); i++) { + String v = cached != null && i < cached.size() ? cached.get(i) : null; + if (v == null) { + missing.add(ordered.get(i)); + } else { + result.put(ordered.get(i), parseSafe(v)); + } + } + + if (!missing.isEmpty()) { + Map loaded = new HashMap<>(); + likeRepository.countByTargets(type, missing) + .forEach(row -> loaded.put(row.getTargetId(), row.getCnt())); + for (Long id : missing) { + long cnt = loaded.getOrDefault(id, 0L); + result.put(id, cnt); + redisTemplate.opsForValue().set(key(type, id), String.valueOf(cnt), TTL); + } + } + return result; + } + + /** DB 토글 이후 캐시 반영. 캐시가 있을 때만 원자적 증감. */ + public void applyDelta(LikeTargetType type, Long targetId, long delta) { + String key = key(type, targetId); + Boolean exists = redisTemplate.hasKey(key); + if (Boolean.TRUE.equals(exists)) { + redisTemplate.opsForValue().increment(key, delta); + } + } + + /** 게시물/댓글 삭제 시 명시적 무효화. */ + public void evict(LikeTargetType type, Long targetId) { + redisTemplate.delete(key(type, targetId)); + } + + private String key(LikeTargetType type, Long targetId) { + return KEY_PREFIX + type.name() + ":" + targetId; + } + + private long parseSafe(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + log.warn("like count cache corrupted value: {}", value); + return 0L; + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountReconcileService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountReconcileService.java new file mode 100644 index 0000000..918d0f5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountReconcileService.java @@ -0,0 +1,124 @@ +package com.chaewookim.accountbookformoms.domain.like.application; + +import com.chaewookim.accountbookformoms.domain.like.dao.PostLikeRepository; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 좋아요 카운터 캐시 정합성 검증 배치. + *

Redis 의 {@code like:count:*} 키를 SCAN 순회하며 캐시 값과 DB 실제 COUNT 를 비교, drift 를 로그로 남기고 + * DB 정본으로 캐시 값을 덮어쓴다. Cache-aside 패턴은 캐시 hit 상태에서의 drift 를 자가 감지할 수 없어 + * 주기적 검증이 필요.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LikeCountReconcileService { + + private static final String KEY_PATTERN = "like:count:*"; + private static final int SCAN_BATCH = 200; + + private final RedisTemplate redisTemplate; + private final PostLikeRepository likeRepository; + + public record ReconcileReport(int scanned, int mismatched, int corrected) { + } + + /** + * 캐시된 좋아요 카운터를 최대 {@code maxKeys} 개까지 검증한다. + * + * @param maxKeys 이번 실행에서 검사할 키의 상한. 0 이하이면 무제한. + */ + public ReconcileReport reconcile(int maxKeys) { + long start = System.currentTimeMillis(); + Map> cachedByType = new EnumMap<>(LikeTargetType.class); + int scanned = 0; + + ScanOptions options = ScanOptions.scanOptions().match(KEY_PATTERN).count(SCAN_BATCH).build(); + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + if (maxKeys > 0 && scanned >= maxKeys) break; + String key = cursor.next(); + ParsedKey parsed = parse(key); + if (parsed == null) continue; + String v = redisTemplate.opsForValue().get(key); + if (v == null) continue; // 이번 사이클 동안 만료됐거나 evict — 무시 + long cached; + try { + cached = Long.parseLong(v); + } catch (NumberFormatException e) { + log.warn("like count key {} 손상됨: value={}", key, v); + redisTemplate.delete(key); + continue; + } + cachedByType + .computeIfAbsent(parsed.type, t -> new HashMap<>()) + .put(parsed.id, cached); + scanned++; + } + } + + int mismatched = 0; + int corrected = 0; + for (Map.Entry> entry : cachedByType.entrySet()) { + LikeTargetType type = entry.getKey(); + Map cached = entry.getValue(); + if (cached.isEmpty()) continue; + + // 벌크 DB COUNT + Map dbCounts = new HashMap<>(); + likeRepository.countByTargets(type, new ArrayList<>(cached.keySet())) + .forEach(row -> dbCounts.put(row.getTargetId(), row.getCnt())); + + for (Map.Entry e : cached.entrySet()) { + Long id = e.getKey(); + long cachedValue = e.getValue(); + long dbValue = dbCounts.getOrDefault(id, 0L); + if (cachedValue != dbValue) { + mismatched++; + log.warn("like count drift 감지: type={}, id={}, cache={}, db={} → db 값으로 정정", + type, id, cachedValue, dbValue); + // 재적재 (SET). TTL 은 LikeCountCacheService.TTL(30분)에 맞추지 않고 default 로 두어도 + // 다음 read 가 TTL 재갱신하니 큰 문제 없음. + redisTemplate.opsForValue().set(cacheKey(type, id), String.valueOf(dbValue)); + corrected++; + } + } + } + + log.info("like count reconcile: scanned={}, mismatched={}, corrected={}, {}ms", + scanned, mismatched, corrected, System.currentTimeMillis() - start); + return new ReconcileReport(scanned, mismatched, corrected); + } + + private ParsedKey parse(String key) { + // like:count:{TYPE}:{ID} + String[] parts = key.split(":"); + if (parts.length != 4) return null; + try { + LikeTargetType type = LikeTargetType.valueOf(parts[2]); + long id = Long.parseLong(parts[3]); + return new ParsedKey(type, id); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String cacheKey(LikeTargetType type, Long id) { + return "like:count:" + type.name() + ":" + id; + } + + private record ParsedKey(LikeTargetType type, long id) { + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeService.java new file mode 100644 index 0000000..fe6d896 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeService.java @@ -0,0 +1,94 @@ +package com.chaewookim.accountbookformoms.domain.like.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.domain.like.dao.PostLikeRepository; +import com.chaewookim.accountbookformoms.domain.like.dto.response.LikeToggleResponse; +import com.chaewookim.accountbookformoms.domain.like.entity.PostLike; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostLikeService { + + private final PostLikeRepository likeRepository; + private final BoardRepository boardRepository; + private final CommentRepository commentRepository; + private final LikeCountCacheService likeCountCache; + + @Transactional + public LikeToggleResponse toggle(LikeTargetType type, Long targetId, Long userId) { + validateTargetExists(type, targetId); + Optional existing = likeRepository + .findByUserIdAndTargetIdAndTargetType(userId, targetId, type); + boolean nowLiked; + long delta; + if (existing.isPresent()) { + likeRepository.delete(existing.get()); + nowLiked = false; + delta = -1L; + } else { + likeRepository.save(PostLike.builder() + .userId(userId) + .targetId(targetId) + .targetType(type) + .build()); + nowLiked = true; + delta = 1L; + } + // 캐시가 있을 때만 원자적 증감. 미스면 다음 read 가 DB 정본 기준으로 재적재. + likeCountCache.applyDelta(type, targetId, delta); + long count = likeCountCache.getCount(type, targetId); + return new LikeToggleResponse(nowLiked, count); + } + + public Map countByTargets(LikeTargetType type, Collection ids) { + return likeCountCache.getCounts(type, ids); + } + + public long count(LikeTargetType type, Long targetId) { + return likeCountCache.getCount(type, targetId); + } + + /** 게시물/댓글 삭제 시 좋아요 카운터 캐시 정리. */ + public void evictCount(LikeTargetType type, Long targetId) { + likeCountCache.evict(type, targetId); + } + + public Set likedTargets(LikeTargetType type, Collection ids, Long userId) { + if (userId == null || ids.isEmpty()) return Collections.emptySet(); + return likeRepository.findLikedTargets(userId, type, ids).stream() + .collect(Collectors.toSet()); + } + + public boolean isLiked(LikeTargetType type, Long targetId, Long userId) { + if (userId == null) return false; + return likeRepository.findByUserIdAndTargetIdAndTargetType(userId, targetId, type).isPresent(); + } + + private void validateTargetExists(LikeTargetType type, Long targetId) { + if (type == LikeTargetType.BOARD) { + if (!boardRepository.existsById(targetId)) { + throw new CustomException(BoardErrorCode.BOARD_NOT_FOUND); + } + } else if (type == LikeTargetType.COMMENT) { + if (!commentRepository.existsById(targetId)) { + throw new CustomException(CommentErrorCode.COMMENT_NOT_FOUND); + } + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/dao/PostLikeRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/dao/PostLikeRepository.java new file mode 100644 index 0000000..ada5793 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/dao/PostLikeRepository.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.like.dao; + +import com.chaewookim.accountbookformoms.domain.like.entity.PostLike; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostLikeRepository extends JpaRepository { + + Optional findByUserIdAndTargetIdAndTargetType(Long userId, Long targetId, LikeTargetType targetType); + + long countByTargetIdAndTargetType(Long targetId, LikeTargetType targetType); + + @Query("SELECT l.targetId AS targetId, COUNT(l) AS cnt " + + "FROM PostLike l " + + "WHERE l.targetType = :type AND l.targetId IN :ids " + + "GROUP BY l.targetId") + List countByTargets(@Param("type") LikeTargetType type, @Param("ids") Collection ids); + + @Query("SELECT l.targetId FROM PostLike l " + + "WHERE l.userId = :userId AND l.targetType = :type AND l.targetId IN :ids") + List findLikedTargets(@Param("userId") Long userId, + @Param("type") LikeTargetType type, + @Param("ids") Collection ids); + + interface TargetCount { + Long getTargetId(); + Long getCnt(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/dto/response/LikeToggleResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/dto/response/LikeToggleResponse.java new file mode 100644 index 0000000..896f274 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/dto/response/LikeToggleResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.like.dto.response; + +public record LikeToggleResponse( + boolean liked, + long likeCount +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/entity/PostLike.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/entity/PostLike.java new file mode 100644 index 0000000..d073ea1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/entity/PostLike.java @@ -0,0 +1,56 @@ +package com.chaewookim.accountbookformoms.domain.like.entity; + +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "post_like", + uniqueConstraints = @UniqueConstraint( + name = "uq_like_user_target", + columnNames = {"user_id", "target_id", "target_type"} + ), + indexes = { + @Index(name = "idx_like_target", columnList = "target_id, target_type"), + @Index(name = "idx_like_user", columnList = "user_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostLike extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private LikeTargetType targetType; + + @Builder + public PostLike(Long userId, Long targetId, LikeTargetType targetType) { + this.userId = userId; + this.targetId = targetId; + this.targetType = targetType; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/enums/LikeTargetType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/enums/LikeTargetType.java new file mode 100644 index 0000000..ddc28b3 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/enums/LikeTargetType.java @@ -0,0 +1,6 @@ +package com.chaewookim.accountbookformoms.domain.like.enums; + +public enum LikeTargetType { + BOARD, + COMMENT +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/like/scheduler/LikeCountReconcileScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/domain/like/scheduler/LikeCountReconcileScheduler.java new file mode 100644 index 0000000..b56916b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/like/scheduler/LikeCountReconcileScheduler.java @@ -0,0 +1,34 @@ +package com.chaewookim.accountbookformoms.domain.like.scheduler; + +import com.chaewookim.accountbookformoms.domain.like.application.LikeCountReconcileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 좋아요 카운터 캐시 정합성 배치. + *

매시 7분 실행 (트래픽 하락 시점 근처). ShedLock 으로 다중 인스턴스 중복 실행 차단.

+ *

1회 실행당 최대 {@value #MAX_KEYS_PER_RUN} 개 키를 검증. Redis SCAN 은 랜덤 순서라 + * 게시물 수가 늘어나도 시간이 지나면서 골고루 커버된다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.like.reconcile.enabled", + havingValue = "true", matchIfMissing = true) +public class LikeCountReconcileScheduler { + + private static final int MAX_KEYS_PER_RUN = 500; + + private final LikeCountReconcileService reconcileService; + + @Scheduled(cron = "0 7 * * * *") + @SchedulerLock(name = "LikeCountReconcileScheduler_reconcile", + lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") + public void reconcile() { + reconcileService.reconcile(MAX_KEYS_PER_RUN); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/NotificationController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/NotificationController.java new file mode 100644 index 0000000..bfa6920 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/NotificationController.java @@ -0,0 +1,75 @@ +package com.chaewookim.accountbookformoms.domain.notification.api; + +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.dto.response.NotificationResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "알림(Notification)", description = "알림 관리 API") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록을 최신순으로 페이징 조회") + @GetMapping + public ResponseEntity>> getNotifications( + @AuthenticationPrincipal UserPrincipal principal, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.ok(ApiResponse.success(notificationService.getNotifications(principal.getUser(), pageable))); + } + + @Operation(summary = "읽지 않은 알림 개수 조회", description = "읽지 않은 알림의 총 개수 조회") + @GetMapping("/unread-count") + public ResponseEntity> getUnreadCount( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success(notificationService.getUnreadCount(principal.getUser()))); + } + + @Operation(summary = "개별 알림 읽음 처리", description = "특정 알림을 읽음 상태로 변경") + @PatchMapping("/{id}/read") + public ResponseEntity> markAsRead( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success(notificationService.markAsRead(id, principal.getUserId()))); + } + + @Operation(summary = "전체 알림 읽음 처리", description = "모든 알림을 읽음 상태로 변경") + @PatchMapping("/read/all") + public ResponseEntity> markAllAsRead( + @AuthenticationPrincipal UserPrincipal principal + ) { + notificationService.markAllAsRead(principal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "알림 삭제", description = "특정 알림 삭제") + @DeleteMapping("/{id}") + public ResponseEntity> deleteNotification( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal principal + ) { + notificationService.deleteNotification(id, principal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/UserDeviceController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/UserDeviceController.java new file mode 100644 index 0000000..dd24302 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/api/UserDeviceController.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.notification.api; + +import com.chaewookim.accountbookformoms.domain.notification.application.UserDeviceService; +import com.chaewookim.accountbookformoms.domain.notification.dto.request.UserDeviceRequest; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +@Tag(name = "알림(Notification)", description = "기기 관리 API") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class UserDeviceController { + + private final UserDeviceService userDeviceService; + + @Operation(summary = "FCM 토큰 등록 및 수정", description = "로그인한 사용자의 FCM 기기 토큰 저장") + @PostMapping("/device-token") + public ResponseEntity> registerToken( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid UserDeviceRequest request + ) { + userDeviceService.registerToken(principal.getUser(), request.fcmToken()); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "FCM 토큰 삭제", description = "로그인한 사용자의 FCM 기기 토큰을 삭제합니다") + @DeleteMapping("/device-token") + public ResponseEntity> unregisterToken( + @AuthenticationPrincipal UserPrincipal principal + ) { + userDeviceService.removeToken(principal.getUser()); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmService.java new file mode 100644 index 0000000..0912521 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmService.java @@ -0,0 +1,41 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Slf4j +@Service +public class FcmService { + + public void sendNotification(String token, String title, String body, Map data) { + if (FirebaseApp.getApps().isEmpty()) { + log.warn("Firebase가 초기화되지 않아 FCM 발송을 건너뜁니다. title={}", title); + return; + } + + Message message = Message.builder() + .setToken(token) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .putAllData(data) + .build(); + + try { + FirebaseMessaging.getInstance().send(message); + log.info("FCM 발송 성공 - title={}", title); + } catch (FirebaseMessagingException e) { + log.error("FCM 전송 실패: {}", e.getMessage()); + } catch (RuntimeException e) { + log.error("FCM 전송 중 예외 발생: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationService.java new file mode 100644 index 0000000..29d1190 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationService.java @@ -0,0 +1,105 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.chaewookim.accountbookformoms.domain.notification.dao.NotificationRepository; +import com.chaewookim.accountbookformoms.domain.notification.dao.UserDeviceRepository; +import com.chaewookim.accountbookformoms.domain.notification.dto.response.NotificationResponse; +import com.chaewookim.accountbookformoms.domain.notification.entity.Notification; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.notification.error.NotificationErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final UserDeviceRepository userDeviceRepository; + private final FcmService fcmService; + + @Transactional + public void createNotification(User user, NotificationType type, String title, String message, String redirectUrl, Long referenceId) { + + Notification notification = Notification.builder() + .user(user) + .type(type) + .title(title) + .message(message) + .redirectUrl(redirectUrl) + .referenceId(referenceId) + .build(); + + notificationRepository.save(notification); + sendPushSafely(user, title, message, redirectUrl, referenceId); + } + + private void sendPushSafely(User user, String title, String message, String redirectUrl, Long referenceId) { + userDeviceRepository.findByUserId(user.getId()).ifPresent(device -> { + try { + Map data = Map.of( + "redirectUrl", redirectUrl != null ? redirectUrl : "", + "referenceId", String.valueOf(referenceId != null ? referenceId : "") + ); + fcmService.sendNotification(device.getFcmToken(), title, message, data); + } catch (RuntimeException e) { + log.warn("FCM 발송 실패 — DB 알림은 유지됩니다. userId={}, title={}", user.getId(), title, e); + } + }); + } + + public Page getNotifications(User user, Pageable pageable) { + return notificationRepository.findByUser(user, pageable) + .map(NotificationResponse::from); + } + + public Long getUnreadCount(User user) { + return notificationRepository.countByUserAndIsReadFalse(user); + } + + @Transactional + public NotificationResponse markAsRead(Long notificationId, Long userId) { + + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + + if (!notification.getUser().getId().equals(userId)) { + throw new CustomException(NotificationErrorCode.NOTIFICATION_ACCESS_DENIED); + } + + notification.markAsRead(); + return NotificationResponse.from(notification); + } + + @Transactional + public void markAllAsRead(Long userId) { + notificationRepository.markAllAsReadByUserId(userId); + } + + @Transactional + public void deleteNotification(Long notificationId, Long userId) { + + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + + if (!notification.getUser().getId().equals(userId)) { + throw new CustomException(NotificationErrorCode.NOTIFICATION_ACCESS_DENIED); + } + + notificationRepository.delete(notification); + } + + @Transactional + public void deleteAllByUserId(Long userId) { + notificationRepository.softDeleteByUserId(userId); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceService.java new file mode 100644 index 0000000..e52e307 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceService.java @@ -0,0 +1,48 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.chaewookim.accountbookformoms.domain.notification.dao.UserDeviceRepository; +import com.chaewookim.accountbookformoms.domain.notification.entity.UserDevice; +import com.chaewookim.accountbookformoms.domain.notification.error.NotificationErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserDeviceService { + + private static final int FCM_TOKEN_MIN_LENGTH = 100; + private static final int FCM_TOKEN_MAX_LENGTH = 250; + private static final Pattern FCM_TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9_:\\-]+$"); + + private final UserDeviceRepository userDeviceRepository; + + @Transactional + public void registerToken(User user, String fcmToken) { + validateFcmToken(fcmToken); + userDeviceRepository.findByUserId(user.getId()) + .ifPresentOrElse( + device -> device.updateToken(fcmToken), + () -> userDeviceRepository.save(new UserDevice(user, fcmToken)) + ); + } + + @Transactional + public void removeToken(User user) { + userDeviceRepository.findByUserId(user.getId()).ifPresent(userDeviceRepository::delete); + } + + private void validateFcmToken(String token) { + if (token == null + || token.length() < FCM_TOKEN_MIN_LENGTH + || token.length() > FCM_TOKEN_MAX_LENGTH + || !FCM_TOKEN_PATTERN.matcher(token).matches()) { + throw new CustomException(NotificationErrorCode.INVALID_FCM_TOKEN); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/NotificationRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/NotificationRepository.java new file mode 100644 index 0000000..e12f93b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/NotificationRepository.java @@ -0,0 +1,28 @@ +package com.chaewookim.accountbookformoms.domain.notification.dao; + +import com.chaewookim.accountbookformoms.domain.notification.entity.Notification; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationRepository extends JpaRepository { + + Page findByUser(User user, Pageable pageable); + + Long countByUserAndIsReadFalse(User user); + + @Modifying + @Query("UPDATE Notification n SET n.isRead = true WHERE n.user.id = :userId AND n.isRead = false") + void markAllAsReadByUserId(@Param("userId") Long userId); + + @Modifying + @Query("UPDATE Notification n SET n.deletedAt = CURRENT_TIMESTAMP " + + "WHERE n.user.id = :userId AND n.deletedAt IS NULL") + int softDeleteByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/UserDeviceRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/UserDeviceRepository.java new file mode 100644 index 0000000..0e0ee8a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dao/UserDeviceRepository.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.notification.dao; + +import com.chaewookim.accountbookformoms.domain.notification.entity.UserDevice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserDeviceRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/request/UserDeviceRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/request/UserDeviceRequest.java new file mode 100644 index 0000000..9a56211 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/request/UserDeviceRequest.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.domain.notification.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record UserDeviceRequest( + + @NotBlank + String fcmToken +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..fee0656 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.notification.dto.response; + +import com.chaewookim.accountbookformoms.domain.notification.entity.Notification; + +import java.time.LocalDateTime; + +public record NotificationResponse( + Long id, + String title, + String message, + String redirectUrl, + Long referenceId, + String type, + Boolean isRead, + LocalDateTime createdAt +) { + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getTitle(), + notification.getMessage(), + notification.getRedirectUrl(), + notification.getReferenceId(), + notification.getType().name(), + notification.getIsRead(), + notification.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/Notification.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/Notification.java new file mode 100644 index 0000000..7929bf9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/Notification.java @@ -0,0 +1,73 @@ +package com.chaewookim.accountbookformoms.domain.notification.entity; + +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "notification", + indexes = @Index(name = "idx_notification_user", columnList = "user_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE notification SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 1000) + private String message; + + private Long referenceId; // 데이터의 pk + + private String redirectUrl; // 이동 경로 + + @Column(nullable = false) + private Boolean isRead = false; + + @Builder + public Notification(User user, NotificationType type, String title, String message, String redirectUrl, Long referenceId) { + this.user = user; + this.type = type; + this.title = title; + this.message = message; + this.redirectUrl = redirectUrl; + this.referenceId = referenceId; + this.isRead = false; + } + + public void markAsRead() { + this.isRead = true; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/UserDevice.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/UserDevice.java new file mode 100644 index 0000000..d1898d1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/entity/UserDevice.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.notification.entity; + +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "user_device", + indexes = @Index(name = "idx_userdevice_user", columnList = "user_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDevice { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String fcmToken; + + @Builder + public UserDevice(User user, String fcmToken) { + this.user = user; + this.fcmToken = fcmToken; + } + + public void updateToken(String fcmToken) { + this.fcmToken = fcmToken; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/enums/NotificationType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/enums/NotificationType.java new file mode 100644 index 0000000..7635d3e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/enums/NotificationType.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.notification.enums; + +public enum NotificationType { + + BUDGET, + INTEREST_CATEGORY, + GOAL, + SYSTEM +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/error/NotificationErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/error/NotificationErrorCode.java new file mode 100644 index 0000000..fd5a75f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/error/NotificationErrorCode.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.notification.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorCode implements BaseErrorCode { + + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."), + NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 알림에 대한 접근 권한이 없습니다."), + INVALID_FCM_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 FCM 토큰입니다."); + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/BudgetEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/BudgetEventListener.java new file mode 100644 index 0000000..460e151 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/BudgetEventListener.java @@ -0,0 +1,78 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetService; +import com.chaewookim.accountbookformoms.domain.budget.event.BudgetExceededCheckEvent; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.math.BigDecimal; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BudgetEventListener { + + private final BudgetService budgetService; + private final UserRepository userRepository; + private final TransactionCategoryRepository categoryRepository; + private final ApplicationEventPublisher eventPublisher; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBudgetCheckEvent(BudgetExceededCheckEvent event) { + try { + userRepository.findByIdWithNotificationAndSettings(event.userId()).ifPresent(user -> { + if (user.getUserNotificationSetting() == null + || !Boolean.TRUE.equals(user.getUserNotificationSetting().getIsBudgetAlertEnabled())) { + return; + } + + if (event.yearMonth().equals(user.getLastBudgetAlertMonth())) { + return; + } + + if (user.getUserSetting() == null) { + return; + } + + BigDecimal progress = budgetService.getCategoryProgress( + event.userId(), event.yearMonth(), event.categoryId()); + int alertThreshold = user.getUserSetting().getBudgetAlertThreshold(); + + if (progress.compareTo(new BigDecimal(alertThreshold)) >= 0) { + String categoryName = categoryRepository.findById(event.categoryId()) + .map(TransactionCategory::getName).orElse("해당 카테고리"); + + String message = String.format("이번 달 [%s] 예산 사용률이 %s%%에 도달했습니다.", + categoryName, progress.toBigInteger()); + + eventPublisher.publishEvent(new NotificationEvent( + user, + NotificationType.BUDGET, + "예산 알림", + message, + "budget", + null + )); + + user.updateLastBudgetAlertMonth(event.yearMonth()); + userRepository.save(user); + } + }); + } catch (RuntimeException e) { + log.error("예산 알림 처리 실패 userId={}, categoryId={}", event.userId(), event.categoryId(), e); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/FixedTransactionEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/FixedTransactionEventListener.java new file mode 100644 index 0000000..0c805e4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/FixedTransactionEventListener.java @@ -0,0 +1,58 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; +import com.chaewookim.accountbookformoms.domain.asset.event.FixedTransactionExecutionFailedEvent; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FixedTransactionEventListener { + + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleExecutionFailed(FixedTransactionExecutionFailedEvent event) { + try { + userRepository.findByIdWithNotificationAndSettings(event.userId()).ifPresent(user -> { + if (user.getUserNotificationSetting() == null + || !Boolean.TRUE.equals(user.getUserNotificationSetting().getIsSystemAlertEnabled())) { + return; + } + + String reasonText = event.failureReason() == FixedTransactionExecutionFailure.CREDIT_LIMIT_EXCEEDED + ? "신용카드 한도가 부족합니다" + : "잔액이 부족합니다"; + String description = (event.description() == null || event.description().isBlank()) + ? "고정 거래" + : event.description(); + String message = String.format("[%s] %s 실행에 실패했습니다. %s.", + event.accountName(), description, reasonText); + + eventPublisher.publishEvent(new NotificationEvent( + user, + NotificationType.SYSTEM, + "고정 거래 실행 실패", + message, + "history/fixed", + event.fixedTransactionId() + )); + }); + } catch (RuntimeException e) { + log.error("고정 거래 실패 알림 처리 실패 fixedTransactionId={}", event.fixedTransactionId(), e); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GoalEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GoalEventListener.java new file mode 100644 index 0000000..8887a34 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GoalEventListener.java @@ -0,0 +1,67 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.event.GoalAchievedCheckEvent; +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoalEventListener { + + private final UserRepository userRepository; + private final AccountRepository accountRepository; + private final NotificationService notificationService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleGoalAchievedCheckEvent(GoalAchievedCheckEvent event) { + try { + process(event); + } catch (Exception ex) { + log.error("목표 달성 알림 처리 실패 userId={}, accountId={}", event.userId(), event.accountId(), ex); + } + } + + private void process(GoalAchievedCheckEvent event) { + Account account = accountRepository.findByIdAndUserId(event.accountId(), event.userId()) + .filter(a -> a.getGoalAmount() != null && a.isGoalAchieved()) + .orElse(null); + if (account == null) { + return; + } + + User user = userRepository.findByIdWithNotificationAndSettings(event.userId()) + .filter(User::isGoalAlertEnabled) + .orElse(null); + if (user == null) { + return; + } + + if (accountRepository.claimGoalAchievedNotification(event.accountId(), event.userId()) != 1) { + return; + } + + String message = String.format("[%s] 계좌 목표 금액에 도달했습니다!", account.getAccountName()); + + notificationService.createNotification( + user, + NotificationType.GOAL, + "목표 달성 알림", + message, + "goals", + account.getId() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GroupPurchaseEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GroupPurchaseEventListener.java new file mode 100644 index 0000000..f96b936 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/GroupPurchaseEventListener.java @@ -0,0 +1,52 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.grouppruchase.event.GroupPurchaseCreatedEvent; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.InterestCategoryRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.entity.UserNotificationSetting; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class GroupPurchaseEventListener { + + private final InterestCategoryRepository interestCategoryRepository; + private final GroupPurchaseCategoryRepository groupPurchaseCategoryRepository; + private final ApplicationEventPublisher eventPublisher; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleGroupPurchaseCreatedEvent(GroupPurchaseCreatedEvent event) { + + Category category = groupPurchaseCategoryRepository.findById(event.categoryId()) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + var subscribers = interestCategoryRepository.findSubscribersWithNotificationSettings(category); + + subscribers.forEach(sub -> { + User user = sub.getUser(); + UserNotificationSetting setting = user.getUserNotificationSetting(); + + if (setting != null && Boolean.TRUE.equals(setting.getIsInterestCategoryEnabled()) && sub.isAlarmEnabled()) { + eventPublisher.publishEvent(new NotificationEvent( + user, + NotificationType.INTEREST_CATEGORY, + "새 공동구매 알림", + event.title(), + "groupbuy", + event.groupPurchaseId() + )); + } + }); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEvent.java new file mode 100644 index 0000000..4982e52 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEvent.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; + +public record NotificationEvent( + + User user, + NotificationType type, + String title, + String message, + String redirectUrl, + Long referenceId +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEventListener.java b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEventListener.java new file mode 100644 index 0000000..22e16ad --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/notification/event/NotificationEventListener.java @@ -0,0 +1,58 @@ +package com.chaewookim.accountbookformoms.domain.notification.event; + +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.domain.user.event.UserSignedUpEvent; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final UserRepository userRepository; + private final NotificationService notificationService; + private final ApplicationEventPublisher eventPublisher; + + @Async + @EventListener + public void handleNotificationEvent(NotificationEvent event) { + + userRepository.findById(event.user().getId()).ifPresent(user -> + notificationService.createNotification( + user, + event.type(), + event.title(), + event.message(), + event.redirectUrl(), + event.referenceId() + ) + ); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleUserSignedUpEvent(UserSignedUpEvent event) { + + User user = userRepository.findById(event.userId()) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + eventPublisher.publishEvent(new NotificationEvent( + user, + NotificationType.SYSTEM, + "회원가입을 축하합니다!", + "가계부 서비스에 오신 것을 환영해요.", + null, + null + )); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/api/PortfolioController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/api/PortfolioController.java new file mode 100644 index 0000000..91bf8a9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/api/PortfolioController.java @@ -0,0 +1,97 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.api; + +import com.chaewookim.accountbookformoms.domain.portfolio.application.PortfolioService; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioCompareRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioFilterRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.MyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PairPortfolioDetailResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PublicMonthlyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.UserPortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "포트폴리오 비교(Portfolio Compare)", + description = "내 포트폴리오(수입/지출/예산) 조회 및 공개 사용자와의 비교 API") +@RestController +@RequestMapping("/api/v1/portfolios") +@RequiredArgsConstructor +public class PortfolioController { + + private final PortfolioService portfolioService; + + @Operation(summary = "내 월별 포트폴리오 조회", + description = "월 총 수입/지출/예산, 잔액, 카테고리별 수입/지출/예산을 함께 반환") + @GetMapping("/me") + public ResponseEntity> getMyPortfolio( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + portfolioService.getMyPortfolio(principal.getUserId(), yearMonth))); + } + + @Operation(summary = "공개 사용자 월별 포트폴리오 목록", + description = "포트폴리오 공개 사용자들의 월 총 수입/지출/예산/잔액. 연/월/수입/지출/예산 금액 구간 필터 지원") + @GetMapping("/compare/users") + public ResponseEntity>> getPublicPortfolios( + @ParameterObject @Valid @ModelAttribute PortfolioFilterRequest filter + ) { + return ResponseEntity.ok(ApiResponse.success(portfolioService.getPublicPortfolios(filter))); + } + + @Operation(summary = "그룹 평균과 내 포트폴리오 비교", + description = "탭(type): AGE(본인 나이대) / AMOUNT(금액 구간) / CATEGORY(카테고리). " + + "선택된 탭의 평균 수입/지출/예산과 본인 값을 비교해 반환") + @GetMapping("/compare") + public ResponseEntity> compare( + @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject @Valid @ModelAttribute PortfolioCompareRequest request + ) { + return ResponseEntity.ok(ApiResponse.success( + portfolioService.compareWithGroup(principal.getUserId(), request))); + } + + @Operation(summary = "선택 사용자와 본인 포트폴리오 세부 조회", + description = "본인과 선택한 공개 사용자의 월 총/카테고리별 수입·지출·예산 및 잔액을 반환") + @GetMapping("/compare/users/{targetUserId}/details") + public ResponseEntity> getPairPortfolioDetails( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam String yearMonth + ) { + return ResponseEntity.ok(ApiResponse.success( + portfolioService.getPairPortfolioDetails(principal.getUserId(), targetUserId, yearMonth))); + } + + @Operation(summary = "선택 사용자와 본인 포트폴리오 비교", + description = "탭(type): AGE / AMOUNT / CATEGORY. CATEGORY는 categoryId 필수. " + + "선택된 탭에 대해 본인과 선택 사용자의 수입/지출/예산을 비교해 반환") + @GetMapping("/compare/users/{targetUserId}") + public ResponseEntity> compareWithUser( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long targetUserId, + @RequestParam PortfolioCompareType type, + @RequestParam String yearMonth, + @RequestParam(required = false) Long categoryId + ) { + return ResponseEntity.ok(ApiResponse.success( + portfolioService.compareWithUser(principal.getUserId(), targetUserId, type, yearMonth, categoryId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioService.java new file mode 100644 index 0000000..46a146f --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioService.java @@ -0,0 +1,182 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.application; + +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetCompareService; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetCompareRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PairBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import com.chaewookim.accountbookformoms.domain.expense.application.ExpenseCompareService; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.ExpenseCompareRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.MyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PairExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; +import com.chaewookim.accountbookformoms.domain.income.application.IncomeCompareService; +import com.chaewookim.accountbookformoms.domain.income.dto.request.IncomeCompareRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.response.MyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PairIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; +import com.chaewookim.accountbookformoms.domain.portfolio.dao.PortfolioAggregationRepository; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioCompareRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioFilterRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.MyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PairPortfolioDetailResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PublicMonthlyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.UserPortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.UserPortfolioDetailResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; +import com.chaewookim.accountbookformoms.domain.portfolio.error.PortfolioErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PortfolioService { + + private final IncomeCompareService incomeCompareService; + private final ExpenseCompareService expenseCompareService; + private final BudgetCompareService budgetCompareService; + private final PortfolioAggregationRepository portfolioAggregationRepository; + + public MyPortfolioResponse getMyPortfolio(Long userId, String yearMonth) { + + validateYearMonth(yearMonth); + + MyIncomeResponse income = incomeCompareService.getMyMonthlyIncome(userId, yearMonth); + MyExpenseResponse expense = expenseCompareService.getMyMonthlyExpense(userId, yearMonth); + MyBudgetResponse budget = budgetCompareService.getMyMonthlyBudget(userId, yearMonth); + + return MyPortfolioResponse.of(yearMonth, income, expense, budget); + } + + public List getPublicPortfolios(PortfolioFilterRequest filter) { + + validateAmountRange(filter.minIncome(), filter.maxIncome()); + validateAmountRange(filter.minExpense(), filter.maxExpense()); + validateAmountRange(filter.minBudget(), filter.maxBudget()); + + if (filter.year() == null || filter.month() == null) { + throw new CustomException(PortfolioErrorCode.INVALID_YEAR_MONTH); + } + + YearMonth ym = YearMonth.of(filter.year(), filter.month()); + String yearMonth = ym.toString(); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + + return portfolioAggregationRepository.findPublicPortfolios( + startDate, endDate, yearMonth, + filter.minIncome(), filter.maxIncome(), + filter.minExpense(), filter.maxExpense(), + filter.minBudget(), filter.maxBudget()) + .stream() + .map(row -> PublicMonthlyPortfolioResponse.of( + row.userId(), row.username(), yearMonth, + row.totalIncome(), row.totalExpense(), row.totalBudget())) + .toList(); + } + + public PortfolioCompareResponse compareWithGroup(Long userId, PortfolioCompareRequest request) { + + validateAmountRange(request.minIncome(), request.maxIncome()); + validateAmountRange(request.minExpense(), request.maxExpense()); + validateAmountRange(request.minBudget(), request.maxBudget()); + + PortfolioCompareType type = request.type(); + String ym = request.yearMonth(); + + IncomeCompareRequest incomeReq = new IncomeCompareRequest( + toIncomeType(type), ym, request.minIncome(), request.maxIncome(), request.categoryId(), request.radiusKm()); + ExpenseCompareRequest expenseReq = new ExpenseCompareRequest( + toExpenseType(type), ym, request.minExpense(), request.maxExpense(), request.categoryId(), request.radiusKm()); + BudgetCompareRequest budgetReq = new BudgetCompareRequest( + toBudgetType(type), ym, request.minBudget(), request.maxBudget(), request.categoryId(), request.radiusKm()); + + return new PortfolioCompareResponse( + type, ym, + incomeCompareService.compareWithGroup(userId, incomeReq), + expenseCompareService.compareWithGroup(userId, expenseReq), + budgetCompareService.compareWithGroup(userId, budgetReq)); + } + + public PairPortfolioDetailResponse getPairPortfolioDetails(Long myUserId, Long targetUserId, String yearMonth) { + + validateYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(PortfolioErrorCode.CANNOT_COMPARE_SELF); + } + + PairIncomeDetailResponse income = incomeCompareService.getPairIncomeDetails(myUserId, targetUserId, yearMonth); + PairExpenseDetailResponse expense = expenseCompareService.getPairExpenseDetails(myUserId, targetUserId, yearMonth); + PairBudgetDetailResponse budget = budgetCompareService.getPairBudgetDetails(myUserId, targetUserId, yearMonth); + + return new PairPortfolioDetailResponse( + UserPortfolioDetailResponse.of(income.me(), expense.me(), budget.me()), + UserPortfolioDetailResponse.of(income.target(), expense.target(), budget.target())); + } + + public UserPortfolioCompareResponse compareWithUser(Long myUserId, Long targetUserId, + PortfolioCompareType type, String yearMonth, Long categoryId) { + + validateYearMonth(yearMonth); + if (myUserId.equals(targetUserId)) { + throw new CustomException(PortfolioErrorCode.CANNOT_COMPARE_SELF); + } + + return new UserPortfolioCompareResponse( + type, yearMonth, myUserId, targetUserId, + incomeCompareService.compareWithUser(myUserId, targetUserId, toIncomeType(type), yearMonth, categoryId), + expenseCompareService.compareWithUser(myUserId, targetUserId, toExpenseType(type), yearMonth, categoryId), + budgetCompareService.compareWithUser(myUserId, targetUserId, toBudgetType(type), yearMonth, categoryId)); + } + + private IncomeCompareType toIncomeType(PortfolioCompareType type) { + return switch (type) { + case AGE -> IncomeCompareType.AGE; + case AMOUNT -> IncomeCompareType.AMOUNT; + case CATEGORY -> IncomeCompareType.CATEGORY; + case LOCATION -> IncomeCompareType.LOCATION; + }; + } + + private ExpenseCompareType toExpenseType(PortfolioCompareType type) { + return switch (type) { + case AGE -> ExpenseCompareType.AGE; + case AMOUNT -> ExpenseCompareType.AMOUNT; + case CATEGORY -> ExpenseCompareType.CATEGORY; + case LOCATION -> ExpenseCompareType.LOCATION; + }; + } + + private BudgetCompareType toBudgetType(PortfolioCompareType type) { + return switch (type) { + case AGE -> BudgetCompareType.AGE; + case AMOUNT -> BudgetCompareType.AMOUNT; + case CATEGORY -> BudgetCompareType.CATEGORY; + case LOCATION -> BudgetCompareType.LOCATION; + }; + } + + private void validateYearMonth(String yearMonth) { + try { + YearMonth.parse(yearMonth); + } catch (DateTimeParseException | NullPointerException e) { + throw new CustomException(PortfolioErrorCode.INVALID_YEAR_MONTH); + } + } + + private void validateAmountRange(BigDecimal minAmount, BigDecimal maxAmount) { + if (minAmount != null && maxAmount != null && minAmount.compareTo(maxAmount) > 0) { + throw new CustomException(PortfolioErrorCode.INVALID_AMOUNT_RANGE); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dao/PortfolioAggregationRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dao/PortfolioAggregationRepository.java new file mode 100644 index 0000000..d0be315 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dao/PortfolioAggregationRepository.java @@ -0,0 +1,110 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dao; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PortfolioAggregationRepository { + + private final EntityManager em; + + public record PublicPortfolioRow( + Long userId, + String username, + BigDecimal totalIncome, + BigDecimal totalExpense, + BigDecimal totalBudget + ) {} + + private static final String AGGREGATE_SQL = """ + SELECT + u.id AS user_id, + u.username AS username, + COALESCE(SUM(CASE WHEN agg.src IN ('IF','IV') THEN agg.amt END), 0) AS total_income, + COALESCE(SUM(CASE WHEN agg.src IN ('EF','EV') THEN agg.amt END), 0) AS total_expense, + COALESCE(SUM(CASE WHEN agg.src = 'B' THEN agg.amt END), 0) AS total_budget + FROM ( + SELECT user_id, amount AS amt, 'IF' AS src + FROM fixed_transaction + WHERE type = 'INCOME' AND is_active = TRUE AND deleted_at IS NULL + AND start_date <= :endDate + AND (end_date IS NULL OR end_date >= :startDate) + UNION ALL + SELECT user_id, amount, 'IV' + FROM `transaction` + WHERE type = 'INCOME' AND deleted_at IS NULL + AND transaction_date BETWEEN :startDate AND :endDate + UNION ALL + SELECT user_id, amount, 'EF' + FROM fixed_transaction + WHERE type = 'EXPENSE' AND is_active = TRUE AND deleted_at IS NULL + AND start_date <= :endDate + AND (end_date IS NULL OR end_date >= :startDate) + UNION ALL + SELECT user_id, amount, 'EV' + FROM `transaction` + WHERE type = 'EXPENSE' AND deleted_at IS NULL + AND transaction_date BETWEEN :startDate AND :endDate + UNION ALL + SELECT user_id, total_budget AS amt, 'B' + FROM budget + WHERE deleted_at IS NULL + AND `year_month` = :yearMonth + ) agg + JOIN `user` u ON u.id = agg.user_id AND u.deleted_at IS NULL + JOIN user_setting s ON s.user_id = u.id AND s.is_portfolio_public = TRUE + GROUP BY u.id, u.username + HAVING (:minIncome IS NULL OR COALESCE(SUM(CASE WHEN agg.src IN ('IF','IV') THEN agg.amt END), 0) >= :minIncome) + AND (:maxIncome IS NULL OR COALESCE(SUM(CASE WHEN agg.src IN ('IF','IV') THEN agg.amt END), 0) <= :maxIncome) + AND (:minExpense IS NULL OR COALESCE(SUM(CASE WHEN agg.src IN ('EF','EV') THEN agg.amt END), 0) >= :minExpense) + AND (:maxExpense IS NULL OR COALESCE(SUM(CASE WHEN agg.src IN ('EF','EV') THEN agg.amt END), 0) <= :maxExpense) + AND (:minBudget IS NULL OR COALESCE(SUM(CASE WHEN agg.src = 'B' THEN agg.amt END), 0) >= :minBudget) + AND (:maxBudget IS NULL OR COALESCE(SUM(CASE WHEN agg.src = 'B' THEN agg.amt END), 0) <= :maxBudget) + ORDER BY + (COALESCE(SUM(CASE WHEN agg.src IN ('IF','IV') THEN agg.amt END), 0) + - COALESCE(SUM(CASE WHEN agg.src IN ('EF','EV') THEN agg.amt END), 0)) DESC + """; + + @SuppressWarnings("unchecked") + public List findPublicPortfolios( + LocalDate startDate, + LocalDate endDate, + String yearMonth, + BigDecimal minIncome, BigDecimal maxIncome, + BigDecimal minExpense, BigDecimal maxExpense, + BigDecimal minBudget, BigDecimal maxBudget + ) { + List rows = em.createNativeQuery(AGGREGATE_SQL) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate) + .setParameter("yearMonth", yearMonth) + .setParameter("minIncome", minIncome) + .setParameter("maxIncome", maxIncome) + .setParameter("minExpense", minExpense) + .setParameter("maxExpense", maxExpense) + .setParameter("minBudget", minBudget) + .setParameter("maxBudget", maxBudget) + .getResultList(); + + return rows.stream() + .map(r -> new PublicPortfolioRow( + ((Number) r[0]).longValue(), + (String) r[1], + toBigDecimal(r[2]), + toBigDecimal(r[3]), + toBigDecimal(r[4]))) + .toList(); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal bd) return bd; + return new BigDecimal(value.toString()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioCompareRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioCompareRequest.java new file mode 100644 index 0000000..d44a14c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioCompareRequest.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.request; + +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.math.BigDecimal; + +public record PortfolioCompareRequest( + + @NotNull(message = "비교 기준은 필수입니다.") + PortfolioCompareType type, + + @NotBlank(message = "연/월은 필수입니다.") + @Pattern(regexp = "^\\d{4}-(0[1-9]|1[0-2])$", message = "연/월은 YYYY-MM 형식이어야 합니다.") + String yearMonth, + + BigDecimal minIncome, + BigDecimal maxIncome, + BigDecimal minExpense, + BigDecimal maxExpense, + BigDecimal minBudget, + BigDecimal maxBudget, + Long categoryId, + + @DecimalMin(value = "0.1", message = "반경은 0.1km 이상이어야 합니다.") + @DecimalMax(value = "50.0", message = "반경은 50km 이하여야 합니다.") + Double radiusKm +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioFilterRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioFilterRequest.java new file mode 100644 index 0000000..aa54d32 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/request/PortfolioFilterRequest.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.math.BigDecimal; + +public record PortfolioFilterRequest( + + @Min(1900) @Max(2100) + Integer year, + + @Min(1) @Max(12) + Integer month, + + BigDecimal minIncome, + BigDecimal maxIncome, + BigDecimal minExpense, + BigDecimal maxExpense, + BigDecimal minBudget, + BigDecimal maxBudget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/MyPortfolioResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/MyPortfolioResponse.java new file mode 100644 index 0000000..a3acfe5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/MyPortfolioResponse.java @@ -0,0 +1,34 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.MyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.MyIncomeResponse; + +import java.math.BigDecimal; + +public record MyPortfolioResponse( + String yearMonth, + BigDecimal totalIncome, + BigDecimal totalExpense, + BigDecimal totalBudget, + BigDecimal balance, + MyIncomeResponse income, + MyExpenseResponse expense, + MyBudgetResponse budget +) { + public static MyPortfolioResponse of(String yearMonth, + MyIncomeResponse income, + MyExpenseResponse expense, + MyBudgetResponse budget) { + BigDecimal totalIncome = income.totalIncome(); + BigDecimal totalExpense = expense.totalExpense(); + BigDecimal totalBudget = budget.totalBudget(); + return new MyPortfolioResponse( + yearMonth, + totalIncome, + totalExpense, + totalBudget, + totalIncome.subtract(totalExpense), + income, expense, budget); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PairPortfolioDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PairPortfolioDetailResponse.java new file mode 100644 index 0000000..52efbfd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PairPortfolioDetailResponse.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +public record PairPortfolioDetailResponse( + UserPortfolioDetailResponse me, + UserPortfolioDetailResponse target +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PortfolioCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PortfolioCompareResponse.java new file mode 100644 index 0000000..553714b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PortfolioCompareResponse.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.ExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.IncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; + +public record PortfolioCompareResponse( + PortfolioCompareType type, + String yearMonth, + IncomeCompareResponse income, + ExpenseCompareResponse expense, + BudgetCompareResponse budget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PublicMonthlyPortfolioResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PublicMonthlyPortfolioResponse.java new file mode 100644 index 0000000..5737c6b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/PublicMonthlyPortfolioResponse.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +import java.math.BigDecimal; + +public record PublicMonthlyPortfolioResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalIncome, + BigDecimal totalExpense, + BigDecimal totalBudget, + BigDecimal balance +) { + public static PublicMonthlyPortfolioResponse of(Long userId, String username, String yearMonth, + BigDecimal totalIncome, + BigDecimal totalExpense, + BigDecimal totalBudget) { + return new PublicMonthlyPortfolioResponse( + userId, username, yearMonth, + totalIncome, totalExpense, totalBudget, + totalIncome.subtract(totalExpense)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioCompareResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioCompareResponse.java new file mode 100644 index 0000000..1962fcf --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioCompareResponse.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; + +public record UserPortfolioCompareResponse( + PortfolioCompareType type, + String yearMonth, + Long myUserId, + Long targetUserId, + UserIncomeCompareResponse income, + UserExpenseCompareResponse expense, + UserBudgetCompareResponse budget +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioDetailResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioDetailResponse.java new file mode 100644 index 0000000..e0b1b72 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/dto/response/UserPortfolioDetailResponse.java @@ -0,0 +1,37 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.dto.response; + +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeDetailResponse; + +import java.math.BigDecimal; + +public record UserPortfolioDetailResponse( + Long userId, + String username, + String yearMonth, + BigDecimal totalIncome, + BigDecimal totalExpense, + BigDecimal totalBudget, + BigDecimal balance, + UserIncomeDetailResponse income, + UserExpenseDetailResponse expense, + UserBudgetDetailResponse budget +) { + public static UserPortfolioDetailResponse of(UserIncomeDetailResponse income, + UserExpenseDetailResponse expense, + UserBudgetDetailResponse budget) { + BigDecimal totalIncome = income.totalIncome(); + BigDecimal totalExpense = expense.totalExpense(); + BigDecimal totalBudget = budget.totalBudget(); + return new UserPortfolioDetailResponse( + income.userId(), + income.username(), + income.yearMonth(), + totalIncome, + totalExpense, + totalBudget, + totalIncome.subtract(totalExpense), + income, expense, budget); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/enums/PortfolioCompareType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/enums/PortfolioCompareType.java new file mode 100644 index 0000000..9e1ea82 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/enums/PortfolioCompareType.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.enums; + +public enum PortfolioCompareType { + AGE, + AMOUNT, + CATEGORY, + LOCATION +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/error/PortfolioErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/error/PortfolioErrorCode.java new file mode 100644 index 0000000..3197858 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/portfolio/error/PortfolioErrorCode.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum PortfolioErrorCode implements BaseErrorCode { + + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "연/월 값이 올바르지 않습니다."), + INVALID_AMOUNT_RANGE(HttpStatus.BAD_REQUEST, "금액 구간이 올바르지 않습니다."), + CANNOT_COMPARE_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 비교할 수 없습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/api/TagController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/api/TagController.java new file mode 100644 index 0000000..4f6369e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/api/TagController.java @@ -0,0 +1,29 @@ +package com.chaewookim.accountbookformoms.domain.tag.api; + +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import com.chaewookim.accountbookformoms.domain.tag.dto.response.TagResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Tag API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/tags") +public class TagController { + + private final TagService tagService; + + @Operation(summary = "전체 태그 조회") + @GetMapping + public ResponseEntity>> list() { + return ResponseEntity.ok(ApiResponse.success(tagService.listAll())); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/application/TagService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/application/TagService.java new file mode 100644 index 0000000..8a3ca76 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/application/TagService.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.tag.application; + +import com.chaewookim.accountbookformoms.domain.tag.dao.BoardTagRepository; +import com.chaewookim.accountbookformoms.domain.tag.dao.TagRepository; +import com.chaewookim.accountbookformoms.domain.tag.dto.response.TagResponse; +import com.chaewookim.accountbookformoms.domain.tag.entity.BoardTag; +import com.chaewookim.accountbookformoms.domain.tag.entity.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TagService { + + private final TagRepository tagRepository; + private final BoardTagRepository boardTagRepository; + + public List listAll() { + return tagRepository.findAll().stream().map(TagResponse::from).toList(); + } + + public List tagsOfBoard(Long boardId) { + List links = boardTagRepository.findByBoardId(boardId); + if (links.isEmpty()) return List.of(); + List tagIds = links.stream().map(BoardTag::getTagId).toList(); + Map byId = tagRepository.findAllById(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, Tag::getName, (a, b) -> a)); + return tagIds.stream().map(byId::get).filter(java.util.Objects::nonNull).toList(); + } + + public Map> tagsByBoards(Collection boardIds) { + if (boardIds.isEmpty()) return Map.of(); + List links = boardTagRepository.findByBoardIdIn(boardIds); + if (links.isEmpty()) return Map.of(); + Set tagIds = new HashSet<>(); + links.forEach(l -> tagIds.add(l.getTagId())); + Map tagNameById = tagRepository.findAllById(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, Tag::getName, (a, b) -> a)); + Map> result = new HashMap<>(); + links.forEach(l -> result + .computeIfAbsent(l.getBoardId(), k -> new ArrayList<>()) + .add(tagNameById.get(l.getTagId()))); + return result; + } + + @Transactional + public void setTagsForBoard(Long boardId, List tagNames) { + boardTagRepository.deleteByBoardId(boardId); + if (tagNames == null || tagNames.isEmpty()) return; + List cleaned = tagNames.stream() + .map(s -> s == null ? null : s.trim()) + .filter(s -> s != null && !s.isEmpty()) + .distinct() + .toList(); + if (cleaned.isEmpty()) return; + + Map existing = tagRepository.findByNameIn(cleaned).stream() + .collect(Collectors.toMap(Tag::getName, t -> t)); + for (String name : cleaned) { + Tag tag = existing.computeIfAbsent(name, + n -> tagRepository.save(Tag.builder().name(n).build())); + boardTagRepository.save(BoardTag.builder() + .boardId(boardId) + .tagId(tag.getId()) + .build()); + } + } + + public List boardIdsWithTag(String tagName) { + return tagRepository.findByName(tagName) + .map(tag -> boardTagRepository.findBoardIdsByTagId(tag.getId())) + .orElse(List.of()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/BoardTagRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/BoardTagRepository.java new file mode 100644 index 0000000..82d318b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/BoardTagRepository.java @@ -0,0 +1,26 @@ +package com.chaewookim.accountbookformoms.domain.tag.dao; + +import com.chaewookim.accountbookformoms.domain.tag.entity.BoardTag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface BoardTagRepository extends JpaRepository { + + List findByBoardId(Long boardId); + + List findByBoardIdIn(Collection boardIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM BoardTag bt WHERE bt.boardId = :boardId") + void deleteByBoardId(@Param("boardId") Long boardId); + + @Query("SELECT bt.boardId FROM BoardTag bt WHERE bt.tagId = :tagId") + List findBoardIdsByTagId(@Param("tagId") Long tagId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/TagRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/TagRepository.java new file mode 100644 index 0000000..99202df --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dao/TagRepository.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.tag.dao; + +import com.chaewookim.accountbookformoms.domain.tag.entity.Tag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TagRepository extends JpaRepository { + + Optional findByName(String name); + + List findByNameIn(List names); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dto/response/TagResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dto/response/TagResponse.java new file mode 100644 index 0000000..a697317 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/dto/response/TagResponse.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.tag.dto.response; + +import com.chaewookim.accountbookformoms.domain.tag.entity.Tag; + +public record TagResponse( + Long id, + String name +) { + public static TagResponse from(Tag t) { + return new TagResponse(t.getId(), t.getName()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/BoardTag.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/BoardTag.java new file mode 100644 index 0000000..11ee4ad --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/BoardTag.java @@ -0,0 +1,48 @@ +package com.chaewookim.accountbookformoms.domain.tag.entity; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "board_tag", + uniqueConstraints = @UniqueConstraint( + name = "uq_board_tag_board_tag", + columnNames = {"board_id", "tag_id"} + ), + indexes = { + @Index(name = "idx_board_tag_board", columnList = "board_id"), + @Index(name = "idx_board_tag_tag", columnList = "tag_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardTag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "board_id", nullable = false) + private Long boardId; + + @Column(name = "tag_id", nullable = false) + private Long tagId; + + @Builder + public BoardTag(Long boardId, Long tagId) { + this.boardId = boardId; + this.tagId = tagId; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/Tag.java b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/Tag.java new file mode 100644 index 0000000..d08e349 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/tag/entity/Tag.java @@ -0,0 +1,33 @@ +package com.chaewookim.accountbookformoms.domain.tag.entity; + +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "tag", uniqueConstraints = @UniqueConstraint(name = "uq_tag_name", columnNames = "name")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Builder + public Tag(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/AuthController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/AuthController.java index 336f365..55bc477 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/AuthController.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/AuthController.java @@ -1,22 +1,36 @@ package com.chaewookim.accountbookformoms.domain.user.api; import com.chaewookim.accountbookformoms.domain.user.application.AuthService; +import com.chaewookim.accountbookformoms.domain.user.application.EmailVerificationService; +import com.chaewookim.accountbookformoms.domain.user.dto.request.EmailRequest; import com.chaewookim.accountbookformoms.domain.user.dto.request.LoginRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.LogoutRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.TokenReissueRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.response.LoginResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.request.ReissueRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.ResetPasswordRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.VerifyRequest; import com.chaewookim.accountbookformoms.domain.user.dto.response.TokenResponse; import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import com.chaewookim.accountbookformoms.global.util.ClientIpUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @Tag(name = "인증(Auth)", description = "로그인/로그아웃/토큰 재발급 API") @RestController @RequestMapping("/api/v1/auth") @@ -24,27 +38,109 @@ public class AuthController { private final AuthService authService; + private final EmailVerificationService emailVerificationService; + + private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(14 * 24 * 60 * 60) + .sameSite("None") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } - @Operation(summary = "로그인", description = "username과 비밀번호로 로그인하고 토큰 발급") + @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인하고 토큰 발급") @PostMapping("/login") - public ResponseEntity> login(@RequestBody @Valid LoginRequest request) { + public ResponseEntity> login( + @RequestBody @Valid LoginRequest request, + HttpServletResponse response + ) { + TokenResponse tokenResponse = authService.login(request); + setRefreshTokenCookie(response, tokenResponse.refreshToken()); + return ResponseEntity.ok(ApiResponse.success(tokenResponse)); + } - return ResponseEntity.ok(ApiResponse.success(authService.login(request))); + @Operation(summary = "소셜 로그인 토큰 교환", description = "OAuth 리다이렉트의 one-time code를 Access Token으로 교환") + @PostMapping("/oauth2/token") + public ResponseEntity> exchangeOAuth2Token( + @RequestBody Map body, + HttpServletResponse response + ) { + TokenResponse tokenResponse = authService.exchangeOAuthCode(body.get("code")); + setRefreshTokenCookie(response, tokenResponse.refreshToken()); + return ResponseEntity.ok(ApiResponse.success(tokenResponse)); } - @Operation(summary = "로그아웃", description = "로그아웃 진행") - @PostMapping("/logout") - public ResponseEntity> logout(@RequestBody @Valid LogoutRequest request) { + @Operation(summary = "토큰 재발급", description = "RefreshToken을 이용해 AccessToken 재발급") + @PostMapping("/reissue") + public ResponseEntity> refreshToken( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken == null) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + ReissueRequest request = new ReissueRequest(refreshToken); + TokenResponse tokenResponse = authService.reissue(request); + setRefreshTokenCookie(response, tokenResponse.refreshToken()); + return ResponseEntity.ok(ApiResponse.success(tokenResponse)); + } - authService.logout(request); + @Operation(summary = "회원가입용 인증번호 발송", description = "회원가입을 위한 이메일 인증") + @PostMapping("/email/send/signup") + public ResponseEntity> sendSignupCode( + @RequestBody @Valid EmailRequest request, + HttpServletRequest httpRequest + ) { + authService.sendSignupVerificationCode(request.email(), ClientIpUtils.extract(httpRequest)); + return ResponseEntity.ok(ApiResponse.success(null)); + } - return ResponseEntity.ok(ApiResponse.success("로그아웃 성공")); + @Operation(summary = "비밀번호 재설정용 인증번호 발송", description = "비밀번호 찾기를 위한 이메일 인증") + @PostMapping("/email/send/password") + public ResponseEntity> sendPasswordCode( + @RequestBody @Valid EmailRequest request, + HttpServletRequest httpRequest + ) { + authService.requestPasswordReset(request.email(), ClientIpUtils.extract(httpRequest)); + return ResponseEntity.ok(ApiResponse.success(null)); } - @Operation(summary = "토큰 재발급", description = "RefreshToken을 이용해 AccessToken 재발급") - @PostMapping("/reissue") - public ResponseEntity> refreshToken(@RequestBody @Valid TokenReissueRequest request) { + @Operation(summary = "인증번호 검증", description = "회원가입/비밀번호 재설정 인증번호 확인") + @PostMapping("/email/verify") + public ResponseEntity> verifyCode( + @RequestBody VerifyRequest request + ) { + boolean isSuccess = emailVerificationService.verifyCode(request.email(), request.code(), request.type()); + return ResponseEntity.ok(ApiResponse.success(isSuccess)); + } - return ResponseEntity.ok(ApiResponse.success(authService.reissue(request))); + @Operation(summary = "비밀번호 재설정", description = "인증번호 검증 후 비밀번호 변경") + @PostMapping("/password/reset") + public ResponseEntity> resetPassword( + @RequestBody @Valid ResetPasswordRequest request + ) { + authService.resetPassword(request.email(), request.code(), request.newPassword()); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "로그아웃", description = "토큰 삭제 후 로그아웃") + @PostMapping("/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal UserPrincipal principal, + HttpServletResponse response + ) { + authService.logout(principal.getUsername()); + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + return ResponseEntity.ok(ApiResponse.success(null)); } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/InterestCategoryController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/InterestCategoryController.java new file mode 100644 index 0000000..1800c03 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/InterestCategoryController.java @@ -0,0 +1,63 @@ +package com.chaewookim.accountbookformoms.domain.user.api; + +import com.chaewookim.accountbookformoms.domain.user.application.InterestCategoryService; +import com.chaewookim.accountbookformoms.domain.user.dto.response.InterestCategoryResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "관심 카테고리(Interest Category)", description = "사용자 관심 카테고리 관리 API") +@RestController +@RequestMapping("/api/v1/interest-categories") +@RequiredArgsConstructor +public class InterestCategoryController { + + private final InterestCategoryService interestCategoryService; + + @Operation(summary = "관심 카테고리 등록", description = "특정 공동구매 카테고리를 관심 카테고리로 등록") + @PostMapping("/{categoryId}") + public ResponseEntity> register( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long categoryId + ) { + return ResponseEntity.ok(ApiResponse.success( + interestCategoryService.registerCategory(principal.getUserId(), categoryId))); + } + + @Operation(summary = "관심 카테고리 목록 조회", description = "사용자가 등록한 모든 관심 카테고리 조회") + @GetMapping + public ResponseEntity>> getMyCategories( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success( + interestCategoryService.getUserCategories(principal.getUserId()))); + } + + @Operation(summary = "알림 설정 변경", description = "특정 관심 카테고리의 알림 ON/OFF 변경") + @PatchMapping("/{id}/alarm") + public ResponseEntity> updateAlarm( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id, + @RequestParam boolean isAlarmEnabled + ) { + interestCategoryService.updateAlarmStatus(id, principal.getUserId(), isAlarmEnabled); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @Operation(summary = "관심 카테고리 삭제", description = "관심 카테고리 목록에서 제거") + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + interestCategoryService.deleteCategory(id, principal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserController.java index f6a44f8..32e6c59 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserController.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserController.java @@ -1,12 +1,13 @@ package com.chaewookim.accountbookformoms.domain.user.api; import com.chaewookim.accountbookformoms.domain.user.application.UserService; -import com.chaewookim.accountbookformoms.domain.user.domain.CustomUserDetails; -import com.chaewookim.accountbookformoms.domain.user.dto.request.SignUpRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.WithdrawRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.response.UserResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.request.SignupRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdatePasswordRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateProfileRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.SignupResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.UserProfileResponse; import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -31,41 +32,46 @@ public class UserController { @Operation(summary = "회원가입", description = "새로운 회원 등록") @PostMapping("/signup") - public ResponseEntity> signup(@RequestBody @Valid SignUpRequest request) { - + public ResponseEntity> signup( + @RequestBody @Valid SignupRequest request + ) { return ResponseEntity.ok(ApiResponse.success(userService.signUp(request))); } - @Operation(summary = "내 정보 조회", description = "로그인한 사용자의 정보 조회") + @Operation(summary = "내 프로필 조회", description = "로그인한 사용자의 프로필 및 설정 조회") @GetMapping("/me") - public ResponseEntity> getMyInfo( - @AuthenticationPrincipal CustomUserDetails userDetails + public ResponseEntity> getMyProfile( + @AuthenticationPrincipal UserPrincipal principal ) { - - return ResponseEntity.ok(ApiResponse.success(UserResponse - .from(userService.getUserByUsername(userDetails.getUsername())))); + return ResponseEntity.ok(ApiResponse.success(userService.getMyProfile(principal.getUserId()))); } - - @Operation(summary = "정보 수정", description = "회원의 정보 수정") - @PatchMapping("/update") - public ResponseEntity> update( - @RequestBody @Valid UpdateRequest request, - @AuthenticationPrincipal CustomUserDetails userDetails + @Operation(summary = "프로필 및 설정 수정", description = "사용자의 정보와 알림/포트폴리오 설정 수정") + @PatchMapping("/me") + public ResponseEntity> updateMyProfile( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid UpdateProfileRequest request ) { + userService.updateMyProfile(principal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(null)); + } - return ResponseEntity.ok(ApiResponse.success(userService.updateUser(userDetails.getUsername(), request))); + @Operation(summary = "비밀번호 설정/변경", description = "비밀번호가 없으면 새로 설정하고, 있으면 기존 비밀번호 확인 후 변경") + @PatchMapping("/password") + public ResponseEntity> updatePassword( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid UpdatePasswordRequest request + ) { + userService.updatePassword(principal.getUserId(), request); + return ResponseEntity.ok(ApiResponse.success(null)); } - @Operation(summary = "회원 탈퇴", description = "비밀번호 확인 후 회원 탈퇴(Soft Delete) 처리") + @Operation(summary = "회원 탈퇴", description = "사용자 계정 삭제") @DeleteMapping("/withdraw") - public ResponseEntity> withdraw( - @RequestBody @Valid WithdrawRequest request, - @AuthenticationPrincipal CustomUserDetails userDetails + public ResponseEntity> withdraw( + @AuthenticationPrincipal UserPrincipal principal ) { - - userService.withdrawUser(userDetails, request); - - return ResponseEntity.ok(ApiResponse.success("회원 탈퇴가 완료되었습니다.")); + userService.withdraw(principal.getUserId()); + return ResponseEntity.ok(ApiResponse.success(null)); } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserLocationController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserLocationController.java new file mode 100644 index 0000000..55dcb07 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/api/UserLocationController.java @@ -0,0 +1,57 @@ +package com.chaewookim.accountbookformoms.domain.user.api; + +import com.chaewookim.accountbookformoms.domain.user.application.UserLocationService; +import com.chaewookim.accountbookformoms.domain.user.dto.request.LocationUpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.LocationResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.NearbyUserResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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 java.util.List; + +@Tag(name = "회원 위치(UserLocation)", description = "Redis GEO 기반 위치 등록/조회 API") +@RestController +@RequestMapping("/api/v1/users/me/location") +@RequiredArgsConstructor +public class UserLocationController { + + private final UserLocationService userLocationService; + + @Operation(summary = "내 위치 등록/수정", description = "현재 사용자의 위경도를 DB와 Redis GEO에 저장") + @PutMapping + public ResponseEntity> updateLocation( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid LocationUpdateRequest request + ) { + return ResponseEntity.ok(ApiResponse.success(userLocationService.updateLocation(principal.getUserId(), request))); + } + + @Operation(summary = "내 위치 조회", description = "Redis GEO에 저장된 현재 사용자의 위경도 조회") + @GetMapping + public ResponseEntity> getMyLocation( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(ApiResponse.success(userLocationService.getMyLocation(principal.getUserId()))); + } + + @Operation(summary = "근처 사용자 조회", description = "Redis GEORADIUSBYMEMBER로 일정 반경 내 사용자 목록 조회 (단위: km)") + @GetMapping("/nearby") + public ResponseEntity>> findNearbyUsers( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "3.0") double radiusKm + ) { + return ResponseEntity.ok(ApiResponse.success(userLocationService.findNearbyUsers(principal.getUserId(), radiusKm))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/AuthService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/AuthService.java index 8e2c371..270e4e7 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/AuthService.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/AuthService.java @@ -2,135 +2,137 @@ import com.chaewookim.accountbookformoms.domain.user.dao.RefreshTokenRepository; import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; -import com.chaewookim.accountbookformoms.domain.user.domain.CustomUserDetails; -import com.chaewookim.accountbookformoms.domain.user.domain.RefreshToken; -import com.chaewookim.accountbookformoms.domain.user.domain.User; import com.chaewookim.accountbookformoms.domain.user.dto.request.LoginRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.LogoutRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.TokenReissueRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.response.LoginResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.request.ReissueRequest; import com.chaewookim.accountbookformoms.domain.user.dto.response.TokenResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.RefreshToken; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -import com.chaewookim.accountbookformoms.global.jwt.JwtTokenProvider; +import com.chaewookim.accountbookformoms.global.security.jwt.JwtTokenProvider; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.Date; - @Slf4j @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class AuthService { + private static final String OAUTH_CODE_KEY_PREFIX = "OAUTH2:CODE:"; + + private final EmailVerificationService emailVerificationService; private final RefreshTokenRepository refreshTokenRepository; private final UserRepository userRepository; private final PasswordEncoder encoder; private final JwtTokenProvider jwtTokenProvider; - - private static final long REFRESH_TOKEN_VALID_TIME = 14 * 24 * 60 * 60 * 1000L; + private final RedisTemplate redisTemplate; @Transactional - public LoginResponse login(LoginRequest request) { + public TokenResponse login(LoginRequest request) { - User user = userRepository.findByUsername(request.username()) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); if (!encoder.matches(request.password(), user.getPassword())) { - throw new CustomException(ErrorCode.USER_NOT_FOUND); + throw new CustomException(UserErrorCode.USER_NOT_FOUND); } - CustomUserDetails customUserDetails = new CustomUserDetails( - user.getId(), - user.getUsername(), - user.getPassword(), - user.getEmail(), - user.getRole() - ); + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole().name()); + String refreshTokenValue = jwtTokenProvider.createRefreshToken(user.getEmail()); - Authentication authentication = new UsernamePasswordAuthenticationToken( - customUserDetails, - user.getPassword(), - Collections.singleton(new SimpleGrantedAuthority(user.getRole().getKey())) - ); + RefreshToken refreshToken = new RefreshToken(user.getEmail(), refreshTokenValue); + refreshTokenRepository.save(refreshToken); - String accessToken = jwtTokenProvider.createAccessToken(authentication); - String refreshToken = jwtTokenProvider.createRefreshToken(authentication); + log.info("Redis에 리프레시 토큰 저장 성공 - 유저: {}", user.getEmail()); + return new TokenResponse(accessToken, refreshTokenValue); + } - refreshTokenRepository.deleteByUserId(user.getId()); + @Transactional + public TokenResponse exchangeOAuthCode(String code) { + if (code == null || code.isBlank()) { + throw new CustomException(UserErrorCode.INVALID_OAUTH_CODE); + } - refreshTokenRepository.flush(); + String value = redisTemplate.opsForValue().getAndDelete(OAUTH_CODE_KEY_PREFIX + code); + if (value == null || value.isBlank()) { + throw new CustomException(UserErrorCode.INVALID_OAUTH_CODE); + } - RefreshToken tokenEntity = new RefreshToken(refreshToken, user.getId(), new Date(new Date().getTime() + REFRESH_TOKEN_VALID_TIME)); - refreshTokenRepository.save(tokenEntity); - log.info("tokenEntity = {}", tokenEntity.getToken()); + String[] parts = value.split("\n", 3); + if (parts.length != 3) { + throw new CustomException(UserErrorCode.INVALID_OAUTH_CODE); + } - return new LoginResponse(accessToken, refreshToken, user.getUsername()); + return new TokenResponse(parts[0], parts[1], Boolean.parseBoolean(parts[2])); } @Transactional - public TokenResponse reissue(@Valid TokenReissueRequest request) { + public TokenResponse reissue(@Valid ReissueRequest request) { + + String refreshTokenValue = request.refreshToken(); - // refresh token 자체를 검증 if (!jwtTokenProvider.validateToken(request.refreshToken())) { - throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + throw new CustomException(UserErrorCode.INVALID_REFRESH_TOKEN); } - // 이메일 추출 - String email = jwtTokenProvider.getSubject(request.refreshToken()); + RefreshToken savedToken = refreshTokenRepository.findByToken(refreshTokenValue) + .orElseThrow(() -> new CustomException(UserErrorCode.INVALID_REFRESH_TOKEN)); - // DB에 저장된 refresh token 가져오기 - RefreshToken savedToken = refreshTokenRepository.findByUserId(getUserIdByEmail(email)) - .orElseThrow(() -> new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + User user = userRepository.findByEmail(savedToken.getEmail()) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - // 요청 토큰과 db 토큰이 같은지 확인 - if (!savedToken.getToken().equals(request.refreshToken())) { - throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); - } + String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole().name()); + String newRefreshTokenValue = jwtTokenProvider.createRefreshToken(user.getEmail()); - // 추출한 이메일로 User 객체 생성 - User user = userRepository.findByEmail(email).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + refreshTokenRepository.delete(savedToken); + RefreshToken newRefreshToken = new RefreshToken(user.getEmail(), newRefreshTokenValue); + refreshTokenRepository.save(newRefreshToken); - // authentication 생성 - Authentication authentication = new UsernamePasswordAuthenticationToken(user - .getEmail(), - null, - Collections.singleton(new SimpleGrantedAuthority(user.getRole().getKey())) - ); + return new TokenResponse(newAccessToken, newRefreshTokenValue); + } - // 보안을 위해 토큰 둘 다 새로 발급 - String newAccessToken = jwtTokenProvider.createAccessToken(authentication); - String newRefreshToken = jwtTokenProvider.createRefreshToken(authentication); + @Transactional + public void sendSignupVerificationCode(String email, String clientIp) { - // 기존 것 지우고 저장 - savedToken.updateToken(newRefreshToken); + if (userRepository.findByEmail(email).isPresent()) { + throw new CustomException(UserErrorCode.DUPLICATE_EMAIL); + } - return new TokenResponse(newAccessToken, newRefreshToken); + emailVerificationService.sendVerificationCode(email, VerificationType.SIGNUP, clientIp); } - private Long getUserIdByEmail(String email) { - - return userRepository.findByEmail(email).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)) - .getId(); + @Transactional + public void requestPasswordReset(String email, String clientIp) { + userRepository.findByEmail(email).orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + emailVerificationService.sendVerificationCode(email, VerificationType.RESET, clientIp); } @Transactional - public Void logout(LogoutRequest request) { + public void resetPassword(String email, String code, String newPassword) { - if (!jwtTokenProvider.validateToken(request.refreshToken())) { - throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + boolean verified = emailVerificationService.isVerified(email, VerificationType.RESET) + || emailVerificationService.verifyCode(email, code, VerificationType.RESET); + + if (!verified) { + throw new CustomException(UserErrorCode.INVALID_VERIFICATION_CODE); } - refreshTokenRepository.deleteByToken(request.refreshToken()); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + user.updatePassword(encoder.encode(newPassword)); - return null; + emailVerificationService.deleteVerification(email, VerificationType.RESET); + } + + @Transactional + public void logout(String email) { + refreshTokenRepository.deleteById(email); } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationService.java new file mode 100644 index 0000000..7581b2d --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationService.java @@ -0,0 +1,154 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.mail.AsyncVerificationEmailSender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + private static final Duration COOLDOWN = Duration.ofMinutes(1); + private static final Duration CODE_TTL = Duration.ofMinutes(3); + private static final Duration VERIFIED_TTL = Duration.ofMinutes(10); + private static final int MAX_PER_EMAIL_PER_DAY = 5; + private static final int MAX_PER_IP_PER_DAY = 20; + private static final int MAX_VERIFY_ATTEMPTS = 5; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final RedisTemplate redisTemplate; + private final AsyncVerificationEmailSender asyncVerificationEmailSender; + + @Value("${app.mail.dev-log-code:false}") + private boolean devLogCode; + + public void sendVerificationCode(String email, VerificationType type, String clientIp) { + String day = LocalDate.now(KST).format(DAY_FORMAT); + String lockKey = lockKey(type, email); + String emailCountKey = emailCountKey(type, email, day); + String ipCountKey = ipCountKey(type, clientIp, day); + + if (redisTemplate.hasKey(lockKey)) { + throw new CustomException(UserErrorCode.TOO_MANY_REQUESTS); + } + if (currentCount(emailCountKey) >= MAX_PER_EMAIL_PER_DAY) { + throw new CustomException(UserErrorCode.TOO_MANY_REQUESTS); + } + if (currentCount(ipCountKey) >= MAX_PER_IP_PER_DAY) { + throw new CustomException(UserErrorCode.TOO_MANY_REQUESTS); + } + + String code = generateRandomCode(); + redisTemplate.delete(attemptKey(type, email)); + redisTemplate.opsForValue().set(verifyKey(type, email), code, CODE_TTL); + redisTemplate.opsForValue().set(lockKey, "locked", COOLDOWN); + incrementDailyCount(emailCountKey); + incrementDailyCount(ipCountKey); + + if (devLogCode) { + log.warn("[DEV] 인증번호 - email: {}, type: {}, code: {}", email, type, code); + } + + asyncVerificationEmailSender.send(email, code, type); + log.info("인증 메일 발송 요청 - email: {}, type: {}, ip: {}", email, type, clientIp); + } + + public boolean verifyCode(String email, String code, VerificationType type) { + String key = verifyKey(type, email); + String savedCode = redisTemplate.opsForValue().get(key); + + if (savedCode == null) { + return false; + } + + if (savedCode.equals(code)) { + redisTemplate.opsForValue().set(verifiedKey(type, email), "true", VERIFIED_TTL); + redisTemplate.delete(key); + redisTemplate.delete(attemptKey(type, email)); + return true; + } + + Long attempts = redisTemplate.opsForValue().increment(attemptKey(type, email)); + if (attempts != null && attempts == 1L) { + redisTemplate.expire(attemptKey(type, email), CODE_TTL); + } + + if (attempts != null && attempts >= MAX_VERIFY_ATTEMPTS) { + redisTemplate.delete(key); + redisTemplate.delete(attemptKey(type, email)); + throw new CustomException(UserErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED); + } + + return false; + } + + public boolean isVerified(String email, VerificationType type) { + return redisTemplate.hasKey(verifiedKey(type, email)); + } + + public void deleteVerification(String email, VerificationType type) { + redisTemplate.delete(verifiedKey(type, email)); + } + + private long currentCount(String key) { + String value = redisTemplate.opsForValue().get(key); + if (value == null || value.isBlank()) { + return 0; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return 0; + } + } + + private void incrementDailyCount(String key) { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, Duration.ofDays(2)); + } + } + + private static String lockKey(VerificationType type, String email) { + return "LOCK:" + type + ":" + email; + } + + private static String verifyKey(VerificationType type, String email) { + return "VERIFY:" + type + ":" + email; + } + + private static String verifiedKey(VerificationType type, String email) { + return "VERIFIED:" + type + ":" + email; + } + + private static String attemptKey(VerificationType type, String email) { + return "VERIFY_ATTEMPT:" + type + ":" + email; + } + + private static String emailCountKey(VerificationType type, String email, String day) { + return "RL:EMAIL:" + type + ":" + email + ":" + day; + } + + private static String ipCountKey(VerificationType type, String ip, String day) { + return "RL:IP:" + type + ":" + ip + ":" + day; + } + + private String generateRandomCode() { + return String.valueOf(SECURE_RANDOM.nextInt(900000) + 100000); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryService.java new file mode 100644 index 0000000..5e345f5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryService.java @@ -0,0 +1,73 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.user.dao.InterestCategoryRepository; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.dto.response.InterestCategoryResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.InterestCategory; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InterestCategoryService { + + private final InterestCategoryRepository repository; + private final UserRepository userRepository; + private final GroupPurchaseCategoryRepository categoryRepository; + + @Transactional + public InterestCategoryResponse registerCategory(Long userId, Long categoryId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + if (repository.existsByUserAndCategory(user, category)) { + throw new CustomException(UserErrorCode.ALREADY_EXIST_CATEGORY); + } + + InterestCategory interest = InterestCategory.builder() + .user(user) + .category(category) + .isAlarmEnabled(true) + .build(); + return InterestCategoryResponse.from(repository.save(interest)); + } + + public List getUserCategories(Long userId) { + return repository.findByUserId(userId).stream() + .map(InterestCategoryResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public void updateAlarmStatus(Long id, Long userId, boolean isAlarmEnabled) { + InterestCategory category = repository.findByIdAndUserId(id, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + category.updateAlarmStatus(isAlarmEnabled); + } + + @Transactional + public void deleteCategory(Long id, Long userId) { + InterestCategory category = repository.findByIdAndUserId(id, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + repository.delete(category); + } + + @Transactional + public void deleteAllByUserId(Long userId) { + repository.softDeleteByUserId(userId); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/KakaoGeocodingClient.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/KakaoGeocodingClient.java new file mode 100644 index 0000000..a4526f0 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/KakaoGeocodingClient.java @@ -0,0 +1,146 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +public class KakaoGeocodingClient { + + private static final String BASE_URL = "https://dapi.kakao.com"; + private static final String SEARCH_PATH = "/v2/local/search/address.json"; + private static final String CACHE_KEY_PREFIX = "GEOCODE:"; + private static final Duration CACHE_TTL = Duration.ofDays(30); + + private final RestClient restClient; + private final String restApiKey; + private final RedisTemplate redisTemplate; + + public KakaoGeocodingClient( + @Value("${kakao.rest-api-key:}") String restApiKey, + RedisTemplate redisTemplate + ) { + this.restApiKey = restApiKey; + this.redisTemplate = redisTemplate; + this.restClient = RestClient.builder() + .baseUrl(BASE_URL) + .requestFactory(defaultFactory()) + .build(); + } + + private static org.springframework.http.client.ClientHttpRequestFactory defaultFactory() { + var factory = new org.springframework.http.client.SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(3).toMillis()); + factory.setReadTimeout((int) Duration.ofSeconds(5).toMillis()); + return factory; + } + + /** + * 주소 문자열을 위경도로 변환. 실패/미매칭 시 {@link Optional#empty()} 반환. + * 실패해도 예외를 던지지 않음 — 회원가입/프로필 수정 흐름을 깨지 않기 위함. + */ + public Optional geocode(String rawAddress) { + if (rawAddress == null) { + return Optional.empty(); + } + + String query = normalizeAddress(rawAddress); + if (query.isEmpty()) { + return Optional.empty(); + } + + Optional cached = getFromCache(query); + if (cached.isPresent()) { + return cached; + } + + if (restApiKey == null || restApiKey.isBlank()) { + log.warn("Kakao REST API key가 설정되지 않아 지오코딩을 건너뜁니다."); + return Optional.empty(); + } + + try { + KakaoAddressSearchResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder.path(SEARCH_PATH) + .queryParam("query", query) + .queryParam("size", 1) + .build()) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + restApiKey) + .retrieve() + .body(KakaoAddressSearchResponse.class); + + if (response == null || response.documents() == null || response.documents().isEmpty()) { + log.info("Kakao 지오코딩 결과 없음 - query: {}", query); + return Optional.empty(); + } + + KakaoAddressDocument top = response.documents().get(0); + double longitude = Double.parseDouble(top.x()); + double latitude = Double.parseDouble(top.y()); + Coordinates coordinates = new Coordinates(latitude, longitude); + putToCache(query, coordinates); + return Optional.of(coordinates); + } catch (Exception e) { + log.warn("Kakao 지오코딩 호출 실패 - query: {}, message: {}", query, e.getMessage()); + return Optional.empty(); + } + } + + private Optional getFromCache(String query) { + try { + String cached = redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + query); + if (cached == null || cached.isBlank()) { + return Optional.empty(); + } + String[] parts = cached.split(",", 2); + if (parts.length != 2) { + return Optional.empty(); + } + return Optional.of(new Coordinates( + Double.parseDouble(parts[0]), + Double.parseDouble(parts[1]) + )); + } catch (Exception e) { + log.warn("지오코딩 캐시 조회 실패 - query: {}, message: {}", query, e.getMessage()); + return Optional.empty(); + } + } + + private void putToCache(String query, Coordinates coordinates) { + try { + String value = coordinates.latitude() + "," + coordinates.longitude(); + redisTemplate.opsForValue().set(CACHE_KEY_PREFIX + query, value, CACHE_TTL); + } catch (Exception e) { + log.warn("지오코딩 캐시 저장 실패 - query: {}, message: {}", query, e.getMessage()); + } + } + + static String normalizeAddress(String rawAddress) { + return rawAddress.replaceFirst("^\\[\\d+\\]\\s*", "").trim(); + } + + public record Coordinates(double latitude, double longitude) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record KakaoAddressSearchResponse(List documents) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record KakaoAddressDocument( + @JsonProperty("address_name") String addressName, + @JsonProperty("x") String x, + @JsonProperty("y") String y + ) { + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonService.java new file mode 100644 index 0000000..8cce693 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonService.java @@ -0,0 +1,78 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.entity.UserNotificationSetting; +import com.chaewookim.accountbookformoms.domain.user.entity.UserSetting; +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.domain.user.event.UserSignedUpEvent; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserCommonService { + + private final UserRepository userRepository; + private final InterestCategoryService interestCategoryService; + private final ApplicationEventPublisher eventPublisher; + + // 회원 저장 + private User createBaseUser(String email, String password, String username, SocialProvider provider, LocalDate birthDate, String address) { + User user = User.builder() + .email(email) + .password(password) + .username(username) + .provider(provider) + .birthDate(birthDate) + .address(address) + .build(); + + user.setSettings(UserSetting.builder().user(user).build(), + UserNotificationSetting.builder().user(user).build()); + + return userRepository.save(user); + } + + // 일반 회원가입 + public User saveLocalUser(String email, String password, String username, SocialProvider provider, LocalDate birthDate, String address) { + User savedUser = createBaseUser(email, password, username, provider, birthDate, address); + eventPublisher.publishEvent(new UserSignedUpEvent(savedUser.getId())); + return savedUser; + } + + // 소셜 로그인 + public User saveSocialUser(String email, String username, SocialProvider provider) { + User savedUser = createBaseUser(email, null, username, provider, null, null); + eventPublisher.publishEvent(new UserSignedUpEvent(savedUser.getId())); + return savedUser; + } + + // 탈퇴 후 재가입 + public User restoreUser(User user, String username, String password, LocalDate birthDate, String address) { + User managedUser = userRepository.findByEmailIncludingDeleted(user.getEmail()) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + managedUser.restore(username, password, birthDate, address); + interestCategoryService.deleteAllByUserId(managedUser.getId()); + + if (managedUser.getUserSetting() == null) { + managedUser.setSettings( + UserSetting.builder().user(managedUser).build(), + UserNotificationSetting.builder().user(managedUser).build() + ); + } else if (managedUser.getUserNotificationSetting() != null) { + managedUser.getUserNotificationSetting().resetToDefaults(); + } + + eventPublisher.publishEvent(new UserSignedUpEvent(managedUser.getId())); + return userRepository.save(managedUser); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationService.java new file mode 100644 index 0000000..95df7f6 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationService.java @@ -0,0 +1,148 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.dto.request.LocationUpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.LocationResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.NearbyUserResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.GeoResult; +import org.springframework.data.geo.GeoResults; +import org.springframework.data.geo.Metrics; +import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.RedisGeoCommands; +import org.springframework.data.redis.core.GeoOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserLocationService { + + public static final String USER_GEO_KEY = "user:geo"; + + private final UserRepository userRepository; + private final RedisTemplate redisTemplate; + + @Transactional + public LocationResponse updateLocation(Long userId, LocationUpdateRequest request) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + user.updateLocation(request.latitude(), request.longitude()); + + GeoOperations geoOps = redisTemplate.opsForGeo(); + geoOps.add(USER_GEO_KEY, new Point(request.longitude(), request.latitude()), String.valueOf(userId)); + + log.info("Redis GEO에 사용자 위치 저장 - userId: {}, lat: {}, lon: {}", userId, request.latitude(), request.longitude()); + return new LocationResponse(userId, request.latitude(), request.longitude()); + } + + public LocationResponse getMyLocation(Long userId) { + + if (!userRepository.existsById(userId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + List positions = redisTemplate.opsForGeo().position(USER_GEO_KEY, String.valueOf(userId)); + if (positions == null || positions.isEmpty() || positions.get(0) == null) { + throw new CustomException(UserErrorCode.LOCATION_NOT_REGISTERED); + } + + Point point = positions.get(0); + return new LocationResponse(userId, point.getY(), point.getX()); + } + + public List findNearbyUsers(Long userId, double radiusKm) { + + if (!userRepository.existsById(userId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + GeoOperations geoOps = redisTemplate.opsForGeo(); + Distance distance = new Distance(radiusKm, Metrics.KILOMETERS); + + RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs + .newGeoRadiusArgs() + .includeCoordinates() + .includeDistance() + .sortAscending(); + + GeoResults> results = geoOps.radius( + USER_GEO_KEY, + String.valueOf(userId), + distance, + args + ); + + if (results == null) { + return Collections.emptyList(); + } + + String me = String.valueOf(userId); + return results.getContent().stream() + .filter(r -> !me.equals(r.getContent().getName())) + .map(this::toNearbyUserResponse) + .collect(Collectors.toList()); + } + + /** + * 반경 내 사용자 ID 집합 (자기 자신 제외). + * Redis GEO 만 사용 — `isPortfolioPublic` 등 추가 필터는 호출자(Repository) 에서 처리. + * 위치 미등록 사용자가 호출하면 {@link UserErrorCode#LOCATION_NOT_REGISTERED} 발생. + */ + public Set findNearbyUserIds(Long userId, double radiusKm) { + + if (!userRepository.existsById(userId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + GeoOperations geoOps = redisTemplate.opsForGeo(); + List positions = geoOps.position(USER_GEO_KEY, String.valueOf(userId)); + if (positions == null || positions.isEmpty() || positions.get(0) == null) { + throw new CustomException(UserErrorCode.LOCATION_NOT_REGISTERED); + } + + Distance distance = new Distance(radiusKm, Metrics.KILOMETERS); + GeoResults> results = geoOps.radius( + USER_GEO_KEY, + String.valueOf(userId), + distance, + RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()); + + if (results == null) { + return Collections.emptySet(); + } + + String me = String.valueOf(userId); + return results.getContent().stream() + .map(r -> r.getContent().getName()) + .filter(name -> !me.equals(name)) + .map(Long::valueOf) + .collect(Collectors.toSet()); + } + + private NearbyUserResponse toNearbyUserResponse(GeoResult> result) { + RedisGeoCommands.GeoLocation location = result.getContent(); + Point point = location.getPoint(); + return new NearbyUserResponse( + Long.valueOf(location.getName()), + point.getY(), + point.getX(), + result.getDistance().getValue() + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserService.java index 13f41cf..03d265f 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserService.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/application/UserService.java @@ -1,87 +1,165 @@ package com.chaewookim.accountbookformoms.domain.user.application; -import com.chaewookim.accountbookformoms.domain.user.dao.RefreshTokenRepository; +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.application.UserDeviceService; import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import com.chaewookim.accountbookformoms.domain.user.dto.request.SignUpRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.WithdrawRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.LocationUpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.SignupRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdatePasswordRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateProfileRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.SignupResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.UserProfileResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.entity.UserNotificationSetting; +import com.chaewookim.accountbookformoms.domain.user.entity.UserSetting; +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -import com.chaewookim.accountbookformoms.global.event.UserSignedUpEvent; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.security.core.userdetails.UserDetails; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; +import java.util.Optional; + +@Slf4j @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class UserService { + private final UserCommonService userCommonService; + private final InterestCategoryService interestCategoryService; + private final EmailVerificationService emailVerificationService; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; - private final RefreshTokenRepository refreshTokenRepository; - - private final ApplicationEventPublisher eventPublisher; + private final KakaoGeocodingClient kakaoGeocodingClient; + private final UserLocationService userLocationService; + private final NotificationService notificationService; + private final UserDeviceService userDeviceService; @Transactional - public Long signUp(SignUpRequest request) { + public SignupResponse signUp(SignupRequest request) { - if (userRepository.existsByEmail(request.email())) { - throw new CustomException(ErrorCode.DUPLICATE_EMAIL); + if (!emailVerificationService.isVerified(request.email(), VerificationType.SIGNUP)) { + throw new CustomException(UserErrorCode.EMAIL_NOT_VERIFIED); } String encoded = passwordEncoder.encode(request.password()); - User user = User.builder() - .email(request.email()) - .username(request.username()) - .password(encoded) - .birthDate(request.birthDate()) - .address(request.address()) - .build(); - - User savedUser = userRepository.save(user); - - eventPublisher.publishEvent(new UserSignedUpEvent(savedUser.getId())); - - return userRepository.findByEmail(request.email()).orElseThrow( - () -> new CustomException(ErrorCode.USER_NOT_FOUND)).getId(); + SignupResponse response = userRepository.findByEmailIncludingDeleted(request.email()) + .map(user -> { + if (user.getDeletedAt() == null) { + throw new CustomException(UserErrorCode.DUPLICATE_EMAIL); + } + userCommonService.restoreUser(user, request.username(), encoded, request.birthDate(), request.address()); + return new SignupResponse(user.getId(), user.getEmail(), user.getUsername()); + }) + .orElseGet(() -> { + User savedUser = userCommonService.saveLocalUser(request.email(), encoded, request.username(), SocialProvider.LOCAL, request.birthDate(), request.address()); + return new SignupResponse(savedUser.getId(), savedUser.getEmail(), savedUser.getUsername()); + }); + + emailVerificationService.deleteVerification(request.email(), VerificationType.SIGNUP); + geocodeAndPersistLocation(response.userId(), request.address()); + return response; } - public User getUserByUsername(String username) { - - return userRepository.findByUsername(username).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + public UserProfileResponse getMyProfile(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + UserSetting settings = user.getUserSetting(); + UserNotificationSetting notificationSetting = user.getUserNotificationSetting(); + + return new UserProfileResponse( + user.getId(), user.getEmail(), user.getUsername(), user.getRole().name(), + user.getBirthDate(), user.getAddress(), + user.getPassword() != null, + settings.getBudgetAlertThreshold(), settings.getIsPortfolioPublic(), + notificationSetting.getIsBudgetAlertEnabled(), notificationSetting.getIsInterestCategoryEnabled(), + notificationSetting.isGoalAlertEnabledOrDefault(), notificationSetting.getIsSystemAlertEnabled() + ); } @Transactional - public Long updateUser(String username, @Valid UpdateRequest request) { + public void updateMyProfile(Long userId, UpdateProfileRequest request) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + String previousAddress = user.getAddress(); + boolean shouldGeocode = request.address() != null + && (!Objects.equals(previousAddress, request.address()) || user.getLatitude() == null || user.getLongitude() == null); + + user.updateProfile(request.username(), request.birthDate(), request.address()); + user.getUserSetting().updateSettings(request.budgetAlertThreshold(), request.isPortfolioPublic()); + user.getUserNotificationSetting().updateNotificationSettings( + request.isBudgetAlertEnabled(), + request.isInterestCategoryEnabled(), + request.isGoalAlertEnabled(), + request.isSystemAlertEnabled() + ); + user.updateLastBudgetAlertMonth(null); + + if (shouldGeocode) { + geocodeAndPersistLocation(userId, request.address()); + } + } - return userRepository.findByUsername(username).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)) - .updateUser(request) - .getId(); + /** + * 주소 문자열을 Kakao 지오코딩으로 위경도 변환 후 DB + Redis GEO 동기화. + * 지오코딩 실패는 회원가입/프로필 저장 흐름을 깨지 않도록 로그만 남기고 무시. + */ + private void geocodeAndPersistLocation(Long userId, String address) { + if (address == null || address.isBlank()) return; + + Optional coords = kakaoGeocodingClient.geocode(address); + if (coords.isEmpty()) { + log.info("주소 지오코딩 실패로 위치 저장 스킵 - userId: {}, address: {}", userId, address); + return; + } + + try { + userLocationService.updateLocation( + userId, + new LocationUpdateRequest(coords.get().latitude(), coords.get().longitude()) + ); + } catch (Exception e) { + log.warn("위치 저장 실패 - userId: {}, message: {}", userId, e.getMessage()); + } } @Transactional - public void withdrawUser(UserDetails userDetails, @Valid WithdrawRequest request) { + public void updatePassword(Long userId, UpdatePasswordRequest request) { - // 현재 로그인된 유저 찾기 - String username = userDetails.getUsername(); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - // 비밀번호 검증 - if (!passwordEncoder.matches(request.password(), user.getPassword())) { - throw new CustomException(ErrorCode.PASSWORD_NOT_MATCH); + if (user.getPassword() != null) { + if (request.currentPassword() == null || + !passwordEncoder.matches(request.currentPassword(), user.getPassword())) { + throw new CustomException(UserErrorCode.PASSWORD_NOT_MATCH); + } } - // 리프레시 토큰 정리 - refreshTokenRepository.deleteByUserId(user.getId()); + String newEncodedPassword = passwordEncoder.encode(request.newPassword()); + user.updatePassword(newEncodedPassword); + } + + @Transactional + public void withdraw(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - // 삭제 진행 + notificationService.deleteAllByUserId(userId); + user.getUserNotificationSetting().resetToDefaults(); + userDeviceService.removeToken(user); + interestCategoryService.deleteAllByUserId(userId); userRepository.delete(user); } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/InterestCategoryRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/InterestCategoryRepository.java new file mode 100644 index 0000000..fb7d051 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/InterestCategoryRepository.java @@ -0,0 +1,36 @@ +package com.chaewookim.accountbookformoms.domain.user.dao; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.user.entity.InterestCategory; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface InterestCategoryRepository extends JpaRepository { + + List findByCategoryAndIsAlarmEnabledTrue(Category category); + + @Query(""" + SELECT ic FROM InterestCategory ic + JOIN FETCH ic.user u + LEFT JOIN FETCH u.userNotificationSetting + WHERE ic.category = :category AND ic.isAlarmEnabled = true + """) + List findSubscribersWithNotificationSettings(@Param("category") Category category); + + List findByUserId(Long userId); + + boolean existsByUserAndCategory(User user, Category category); + + Optional findByIdAndUserId(Long id, Long userId); + + @Modifying + @Query("UPDATE InterestCategory ic SET ic.deletedAt = CURRENT_TIMESTAMP " + + "WHERE ic.user.id = :userId AND ic.deletedAt IS NULL") + int softDeleteByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/RefreshTokenRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/RefreshTokenRepository.java index 5c2c72f..78ff1b2 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/RefreshTokenRepository.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/RefreshTokenRepository.java @@ -1,23 +1,11 @@ package com.chaewookim.accountbookformoms.domain.user.dao; -import com.chaewookim.accountbookformoms.domain.user.domain.RefreshToken; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; +import com.chaewookim.accountbookformoms.domain.user.entity.RefreshToken; +import org.springframework.data.repository.CrudRepository; import java.util.Optional; -@Repository -public interface RefreshTokenRepository extends JpaRepository { +public interface RefreshTokenRepository extends CrudRepository { - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("DELETE FROM RefreshToken r WHERE r.userId = :userId") - void deleteByUserId(@Param("userId") Long userId); - - Optional findByUserId(Long userId); - - void deleteByToken(String token); + Optional findByToken(String token); } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/UserRepository.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/UserRepository.java index 7450bdf..33c8783 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/UserRepository.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dao/UserRepository.java @@ -1,8 +1,9 @@ package com.chaewookim.accountbookformoms.domain.user.dao; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import jakarta.validation.constraints.NotBlank; +import com.chaewookim.accountbookformoms.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -10,8 +11,16 @@ @Repository public interface UserRepository extends JpaRepository { - boolean existsByEmail(String email); Optional findByEmail(String email); - Optional findByUsername(@NotBlank String username); + @Query(value = "SELECT * FROM user WHERE email = :email", nativeQuery = true) + Optional findByEmailIncludingDeleted(@Param("email") String email); + + @Query(""" + SELECT u FROM User u + LEFT JOIN FETCH u.userNotificationSetting + LEFT JOIN FETCH u.userSetting + WHERE u.id = :id + """) + Optional findByIdWithNotificationAndSettings(@Param("id") Long id); } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/CustomUserDetails.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/CustomUserDetails.java deleted file mode 100644 index c0e8870..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/CustomUserDetails.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.domain; - -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collection; -import java.util.List; - -@Getter -public class CustomUserDetails implements UserDetails { - private final Long userId; - private final String username; - private final String password; - private final String email; - private final UserRole role; - - public CustomUserDetails(User user) { - this.userId = user.getId(); - this.username = user.getUsername(); - this.password = user.getPassword(); - this.email = user.getEmail(); - this.role = user.getRole(); - } - - public CustomUserDetails(Long userId, String username, String password, String email, UserRole role) { - this.userId = userId; - this.username = username; - this.password = password; - this.email = email; - this.role = role; - } - - @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority(role.getKey())); - } - - @Override - public String getPassword() { - return password; - } - - @Override - public String getUsername() { - return username; - } - - @Override - public boolean isAccountNonExpired() { - return false; - } - - @Override - public boolean isAccountNonLocked() { - return false; - } - - @Override - public boolean isCredentialsNonExpired() { - return false; - } - - @Override - public boolean isEnabled() { - return false; - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/RefreshToken.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/RefreshToken.java deleted file mode 100644 index b204758..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/RefreshToken.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.domain; - -import com.chaewookim.accountbookformoms.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Date; - -@Entity -@Getter -@NoArgsConstructor -public class RefreshToken extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String token; - - @Column(nullable = false) - private Long userId; - - @Column(nullable = false) - private Date expireTime; - - @Builder - public RefreshToken(String token, Long userId, Date expireTime) { - this.token = token; - this.userId = userId; - this.expireTime = expireTime; - } - - public void updateToken(String newRefreshToken) { - - this.token = newRefreshToken; - } - - @Builder(builderMethodName = "forTestBuilder") - public RefreshToken(String token, Long userId) { - this.token = token; - this.userId = userId; - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/User.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/User.java deleted file mode 100644 index 587c357..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/User.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.domain; - -import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateRequest; -import com.chaewookim.accountbookformoms.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.validation.Valid; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor -@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?") -@SQLRestriction("deleted_at IS NULL") -public class User extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String username; - - @Column(nullable = false, unique = true) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false) - private boolean isAdmin = false; - - @Column(nullable = false) - private LocalDate birthDate; - - @Column(nullable = false) - @Enumerated(EnumType.STRING) - private UserRole role = UserRole.USER; - - @Column(nullable = false) - private String address; - - @Column - private LocalDateTime deletedAt; - - @Builder - public User(String username, String email, String password, LocalDate birthDate, String address) { - this.username = username; - this.email = email; - this.password = password; - this.birthDate = birthDate; - this.address = address; - } - - public User updateUser(@Valid UpdateRequest request) { - - this.username = request.username(); - this.email = request.email(); - this.address = request.address(); - - return this; - } - - @Builder(builderMethodName = "forTestBuilder") - public User(Long id, String username, String email, String password, boolean isAdmin, String address) { - this.id = id; - this.username = username; - this.email = email; - this.password = password; - this.isAdmin = isAdmin; - this.address = address; - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/UserRole.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/UserRole.java deleted file mode 100644 index 88e75a2..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/domain/UserRole.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.domain; - -import lombok.Getter; - -import java.util.Arrays; - -@Getter -public enum UserRole { - - USER("ROLE_USER", "일반 사용자"), - ADMIN("ROLE_ADMIN", "관리자"); - - private final String key; - private final String title; - - UserRole(String key, String title) { - this.key = key; - this.title = title; - } - - public static UserRole findByKey(String key) { - return Arrays.stream(UserRole.values()) - .filter(role -> role.getKey().equals(key)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("해당 권한을 찾을 수 없습니다: " + key)); - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/EmailRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/EmailRequest.java new file mode 100644 index 0000000..c4753a8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/EmailRequest.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LocationUpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LocationUpdateRequest.java new file mode 100644 index 0000000..71bab6b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LocationUpdateRequest.java @@ -0,0 +1,19 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +public record LocationUpdateRequest( + + @NotNull(message = "위도는 필수 입력 값입니다.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.") + Double latitude, + + @NotNull(message = "경도는 필수 입력 값입니다.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.") + Double longitude +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LoginRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LoginRequest.java index 4b69832..444895f 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LoginRequest.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LoginRequest.java @@ -5,14 +5,11 @@ public record LoginRequest ( - @NotBlank - String username, + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, - @NotBlank(message = "비밀번호는 필수 입력값입니다.") - String password + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + String password ) { - public LoginRequest(String username, String password) { - this.username = username; - this.password = password; - } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LogoutRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LogoutRequest.java deleted file mode 100644 index 05fe669..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/LogoutRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.dto.request; - -public record LogoutRequest( - - String refreshToken -) { -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/TokenReissueRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ReissueRequest.java similarity index 86% rename from src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/TokenReissueRequest.java rename to src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ReissueRequest.java index 1e7e620..8dda36c 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/TokenReissueRequest.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ReissueRequest.java @@ -2,7 +2,7 @@ import jakarta.validation.constraints.NotBlank; -public record TokenReissueRequest ( +public record ReissueRequest ( @NotBlank(message = "토큰은 비어있을 수 없습니다.") String refreshToken diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ResetPasswordRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ResetPasswordRequest.java new file mode 100644 index 0000000..5e1fbbf --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/ResetPasswordRequest.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ResetPasswordRequest( + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "인증번호는 필수 입력 값입니다.") + String code, + + @NotBlank(message = "새 비밀번호는 필수 입력값입니다.") + @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", + message = "비밀번호는 8~16자 영문, 숫자, 특수문자 조합이어야 합니다.") + String newPassword +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignupRequest.java similarity index 53% rename from src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignUpRequest.java rename to src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignupRequest.java index 2e54e29..2fb00c3 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignUpRequest.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/SignupRequest.java @@ -1,29 +1,27 @@ package com.chaewookim.accountbookformoms.domain.user.dto.request; -import com.chaewookim.accountbookformoms.domain.user.domain.UserRole; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import java.time.LocalDate; -public record SignUpRequest( - - @NotBlank - String username, +public record SignupRequest( + @NotBlank(message = "이메일은 필수 입력 값입니다.") @Email(message = "이메일 형식이 올바르지 않습니다.") String email, @NotBlank(message = "비밀번호는 필수 입력값입니다.") + @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", + message = "비밀번호는 8~16자 영문, 숫자, 특수문자 조합이어야 합니다.") String password, - @NotNull(message = "생년월일은 필수 입력값입니다.") - LocalDate birthDate, + @NotBlank(message = "닉네임은 필수 입력 값입니다.") + String username, - UserRole role, + LocalDate birthDate, - @NotBlank(message = "주소는 필수 입력 값입니다.") String address ) { } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdatePasswordRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdatePasswordRequest.java new file mode 100644 index 0000000..5963407 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdatePasswordRequest.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UpdatePasswordRequest( + + String currentPassword, + + @NotBlank(message = "새로운 비밀번호는 필수 입력값입니다.") + @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", + message = "비밀번호는 8~16자 영문, 숫자, 특수문자 조합이어야 합니다.") + String newPassword +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateProfileRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..84ec0f8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateProfileRequest.java @@ -0,0 +1,39 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record UpdateProfileRequest( + + @NotBlank(message = "닉네임은 필수 입력 값입니다.") + String username, + + LocalDate birthDate, + + String address, + + @Min(0) + @Max(100) + @NotNull(message = "예산 대비 지출 알림 기준은 필수 입력 값입니다.") + Integer budgetAlertThreshold, + + @NotNull(message = "프로필 공개 여부는 필수 입력 값입니다.") + Boolean isPortfolioPublic, + + @NotNull(message = "예산 알림 여부는 필수 입력 값입니다.") + Boolean isBudgetAlertEnabled, + + @NotNull(message = "관심 카테고리 알림 여부는 필수 입력 값입니다.") + Boolean isInterestCategoryEnabled, + + @NotNull(message = "계좌 목표 달성 알림 여부는 필수 입력 값입니다.") + Boolean isGoalAlertEnabled, + + @NotNull(message = "보안 알림 여부는 필수 입력 값입니다.") + Boolean isSystemAlertEnabled +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateRequest.java deleted file mode 100644 index a59067c..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/UpdateRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.dto.request; - -public record UpdateRequest( - String username, - String email, - String address -) { -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/VerifyRequest.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/VerifyRequest.java new file mode 100644 index 0000000..af1fad7 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/request/VerifyRequest.java @@ -0,0 +1,18 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.request; + +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record VerifyRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "인증번호는 필수입니다.") + String code, + + VerificationType type +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/InterestCategoryResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/InterestCategoryResponse.java new file mode 100644 index 0000000..88a4643 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/InterestCategoryResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.response; + +import com.chaewookim.accountbookformoms.domain.user.entity.InterestCategory; +import lombok.Builder; + +@Builder +public record InterestCategoryResponse( + Long id, + Long categoryId, + String categoryName, + boolean isAlarmEnabled +) { + public static InterestCategoryResponse from(InterestCategory entity) { + return InterestCategoryResponse.builder() + .id(entity.getId()) + .categoryId(entity.getCategory().getId()) + .categoryName(entity.getCategory().getName()) + .isAlarmEnabled(entity.isAlarmEnabled()) + .build(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LocationResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LocationResponse.java new file mode 100644 index 0000000..cecc0dd --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LocationResponse.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.response; + +public record LocationResponse( + Long userId, + Double latitude, + Double longitude +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LoginResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LoginResponse.java deleted file mode 100644 index 142323b..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/LoginResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.dto.response; - -public record LoginResponse ( - - String accessToken, - String refreshToken, - String username -) { -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/NearbyUserResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/NearbyUserResponse.java new file mode 100644 index 0000000..c2c8f77 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/NearbyUserResponse.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.response; + +public record NearbyUserResponse( + Long userId, + Double latitude, + Double longitude, + Double distanceKm +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/SignupResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/SignupResponse.java new file mode 100644 index 0000000..bf6c8c9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/SignupResponse.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.response; + +public record SignupResponse( + + Long userId, + String email, + String username +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/TokenResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/TokenResponse.java index 53c5d6d..0708869 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/TokenResponse.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/TokenResponse.java @@ -1,8 +1,17 @@ package com.chaewookim.accountbookformoms.domain.user.dto.response; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + public record TokenResponse( String accessToken, - String refreshToken + @JsonIgnore + String refreshToken, + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean isNewUser ) { + public TokenResponse(String accessToken, String refreshToken) { + this(accessToken, refreshToken, null); + } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserProfileResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserProfileResponse.java new file mode 100644 index 0000000..333964a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserProfileResponse.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.domain.user.dto.response; + +import java.time.LocalDate; + +public record UserProfileResponse( + + Long id, + String email, + String username, + String role, + LocalDate birthDate, + String address, + Boolean hasPassword, + Integer budgetAlertThreshold, + Boolean isPortfolioPublic, + Boolean isBudgetAlertEnabled, + Boolean isInterestCategoryEnabled, + Boolean isGoalAlertEnabled, + Boolean isSystemAlertEnabled +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserResponse.java deleted file mode 100644 index 3bb9229..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/domain/user/dto/response/UserResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.user.dto.response; - -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import com.chaewookim.accountbookformoms.domain.user.domain.UserRole; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.LocalDate; - -public record UserResponse( - Long id, - String email, - String username, - String address, - LocalDate birthDate, - UserRole role -) { - public static UserResponse from(User user) { - return new UserResponse( - user.getId(), - user.getEmail(), - user.getUsername(), - user.getAddress(), - user.getBirthDate(), - user.getRole() - ); - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/InterestCategory.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/InterestCategory.java new file mode 100644 index 0000000..7424ecc --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/InterestCategory.java @@ -0,0 +1,56 @@ +package com.chaewookim.accountbookformoms.domain.user.entity; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "interest_category", + indexes = @Index(name = "idx_interest_user", columnList = "user_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE interest_category SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class InterestCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private Category category; + + @Column(nullable = false) + private boolean isAlarmEnabled; + + @Builder + public InterestCategory(User user, Category category, boolean isAlarmEnabled) { + this.user = user; + this.category = category; + this.isAlarmEnabled = isAlarmEnabled; + } + + public void updateAlarmStatus(boolean isAlarmEnabled) { + this.isAlarmEnabled = isAlarmEnabled; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/RefreshToken.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/RefreshToken.java new file mode 100644 index 0000000..b246d36 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/RefreshToken.java @@ -0,0 +1,25 @@ +package com.chaewookim.accountbookformoms.domain.user.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 1209600) +public class RefreshToken { + + @Id + private String email; + + @Indexed + private String token; + + public void updateToken(String newToken) { + this.token = newToken; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/User.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/User.java new file mode 100644 index 0000000..77a0b87 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/User.java @@ -0,0 +1,141 @@ +package com.chaewookim.accountbookformoms.domain.user.entity; + +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String password; + + @Column(nullable = false) + private String username; + + private LocalDate birthDate; + + private String address; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private UserRole role; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private SocialProvider provider; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserSetting userSetting; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserNotificationSetting userNotificationSetting; + + @Column(nullable = true) + private Double latitude; + + @Column(nullable = true) + private Double longitude; + + @Column + private String lastBudgetAlertMonth; + + @Builder + public User(String email, String password, String username, LocalDate birthDate, String address, UserRole role, SocialProvider provider, Double latitude, Double longitude) { + this.email = email; + this.password = password; + this.username = username; + this.birthDate = birthDate; + this.address = address; + this.role = (role != null) ? role : UserRole.ROLE_USER; + this.provider = (provider != null) ? provider : SocialProvider.LOCAL; + this.latitude = latitude; + this.longitude = longitude; + } + + public void setSettings(UserSetting userSetting, UserNotificationSetting notificationSetting) { + this.userSetting = userSetting; + this.userNotificationSetting = notificationSetting; + } + + public User update(String username) { + this.username = username; + return this; + } + + public void updateRole(UserRole role) { + this.role = role; + } + + public void updateProfile(String username, LocalDate birthDate, String address) { + this.username = username; + this.birthDate = birthDate; + this.address = address; + } + + public void updatePassword(String password) { + this.password = password; + } + + public void updateLocation(Double latitude, Double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + public void restore(String username, String password, LocalDate birthDate, String address) { + super.restore(); + this.password = password; + this.username = username; + this.birthDate = birthDate; + this.address = address; + } + + @Builder(builderMethodName = "forTestBuilder") + public User(Long id, String email, String password, String username, LocalDate birthDate, String address, UserRole role, SocialProvider provider, Double latitude, Double longitude) { + this.id = id; + this.email = email; + this.password = password; + this.username = username; + this.birthDate = birthDate; + this.address = address; + this.role = (role != null) ? role : UserRole.ROLE_USER; + this.provider = (provider != null) ? provider : SocialProvider.LOCAL; + this.latitude = latitude; + this.longitude = longitude; + } + + public void updateLastBudgetAlertMonth(String month) { + this.lastBudgetAlertMonth = month; + } + + public boolean isGoalAlertEnabled() { + return userNotificationSetting != null && userNotificationSetting.isGoalAlertEnabledOrDefault(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserNotificationSetting.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserNotificationSetting.java new file mode 100644 index 0000000..cdad62e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserNotificationSetting.java @@ -0,0 +1,68 @@ +package com.chaewookim.accountbookformoms.domain.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserNotificationSetting { + + @Id + private Long userId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private Boolean isBudgetAlertEnabled; // 예산 알림 + + @Column(nullable = false) + private Boolean isInterestCategoryEnabled; // 관심 카테고리 알림 + + @Column(nullable = false) + private Boolean isGoalAlertEnabled; // 계좌 목표 달성 알림 + + @Column(nullable = false) + private Boolean isSystemAlertEnabled; // 시스템 알림 + + @Builder + public UserNotificationSetting(User user, Boolean isBudgetAlertEnabled, Boolean isInterestCategoryEnabled, + Boolean isGoalAlertEnabled, Boolean isSystemAlertEnabled) { + this.user = user; + this.isBudgetAlertEnabled = (isBudgetAlertEnabled != null) ? isBudgetAlertEnabled : true; + this.isInterestCategoryEnabled = (isInterestCategoryEnabled != null) ? isInterestCategoryEnabled : true; + this.isGoalAlertEnabled = (isGoalAlertEnabled != null) ? isGoalAlertEnabled : true; + this.isSystemAlertEnabled = (isSystemAlertEnabled != null) ? isSystemAlertEnabled : true; + } + + public void updateNotificationSettings(Boolean isBudgetAlertEnabled, Boolean isInterestCategoryEnabled, + Boolean isGoalAlertEnabled, Boolean isSystemAlertEnabled) { + if (isBudgetAlertEnabled != null) this.isBudgetAlertEnabled = isBudgetAlertEnabled; + if (isInterestCategoryEnabled != null) this.isInterestCategoryEnabled = isInterestCategoryEnabled; + if (isGoalAlertEnabled != null) this.isGoalAlertEnabled = isGoalAlertEnabled; + if (isSystemAlertEnabled != null) this.isSystemAlertEnabled = isSystemAlertEnabled; + } + + public boolean isGoalAlertEnabledOrDefault() { + return !Boolean.FALSE.equals(isGoalAlertEnabled); + } + + public void resetToDefaults() { + this.isBudgetAlertEnabled = true; + this.isInterestCategoryEnabled = true; + this.isGoalAlertEnabled = true; + this.isSystemAlertEnabled = true; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserSetting.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserSetting.java new file mode 100644 index 0000000..f110899 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/entity/UserSetting.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.domain.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSetting { + + @Id + private Long userId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private Integer budgetAlertThreshold; // 예산 대비 지출 알림 기준 조건 + + @Column(nullable = false) + private Boolean isPortfolioPublic; + + @Builder + public UserSetting(User user, Integer budgetAlertThreshold, Boolean isPortfolioPublic) { + this.user = user; + this.budgetAlertThreshold = (budgetAlertThreshold != null) ? budgetAlertThreshold : 80; + this.isPortfolioPublic = (isPortfolioPublic != null) ? isPortfolioPublic : false; + } + + public void updateSettings(Integer budgetAlertThreshold, Boolean isPortfolioPublic) { + if (budgetAlertThreshold != null) this.budgetAlertThreshold = budgetAlertThreshold; + if (isPortfolioPublic != null) this.isPortfolioPublic = isPortfolioPublic; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/SocialProvider.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/SocialProvider.java new file mode 100644 index 0000000..8ae8e91 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/SocialProvider.java @@ -0,0 +1,13 @@ +package com.chaewookim.accountbookformoms.domain.user.enums; + +public enum SocialProvider { + + LOCAL, + GOOGLE, + KAKAO, + NAVER; + + public static SocialProvider from(String provider) { + return SocialProvider.valueOf(provider.toUpperCase()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/UserRole.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/UserRole.java new file mode 100644 index 0000000..1228c57 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/UserRole.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.domain.user.enums; + +import lombok.Getter; + +@Getter +public enum UserRole { + + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String title; + + UserRole(String title) { + this.title = title; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/VerificationType.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/VerificationType.java new file mode 100644 index 0000000..e064b52 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/enums/VerificationType.java @@ -0,0 +1,7 @@ +package com.chaewookim.accountbookformoms.domain.user.enums; + +public enum VerificationType { + + SIGNUP, + RESET +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/user/error/UserErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/error/UserErrorCode.java new file mode 100644 index 0000000..d49ac88 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/error/UserErrorCode.java @@ -0,0 +1,30 @@ +package com.chaewookim.accountbookformoms.domain.user.error; + +import com.chaewookim.accountbookformoms.global.error.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 올바르지 않습니다."), + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + UNSUPPORTED_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인 방식입니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + INVALID_COORDINATES(HttpStatus.BAD_REQUEST, "위경도 좌표가 유효하지 않습니다."), + LOCATION_NOT_REGISTERED(HttpStatus.CONFLICT, "등록된 위치 정보가 없습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "이메일 인증이 완료되지 않았습니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증 번호가 올바르지 않거나 만료되었습니다."), + VERIFICATION_ATTEMPTS_EXCEEDED(HttpStatus.BAD_REQUEST, "인증 횟수를 초과했습니다. 인증번호를 다시 받아주세요."), + INVALID_OAUTH_CODE(HttpStatus.UNAUTHORIZED, "소셜 로그인 인가 코드가 올바르지 않거나 만료되었습니다."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "너무 많은 요청입니다. 잠시 후 다시 시도해주세요."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다. 잠시 후 다시 시도해주세요."), + ALREADY_EXIST_CATEGORY(HttpStatus.CONFLICT, "이미 등록된 관심 카테고리입니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/event/UserSignedUpEvent.java b/src/main/java/com/chaewookim/accountbookformoms/domain/user/event/UserSignedUpEvent.java similarity index 63% rename from src/main/java/com/chaewookim/accountbookformoms/global/event/UserSignedUpEvent.java rename to src/main/java/com/chaewookim/accountbookformoms/domain/user/event/UserSignedUpEvent.java index 80b571a..de8a1bb 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/event/UserSignedUpEvent.java +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/user/event/UserSignedUpEvent.java @@ -1,8 +1,10 @@ -package com.chaewookim.accountbookformoms.global.event; +package com.chaewookim.accountbookformoms.domain.user.event; import jakarta.validation.constraints.NotNull; public record UserSignedUpEvent( + @NotNull Long userId -) {} +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/api/UserStatsController.java b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/api/UserStatsController.java new file mode 100644 index 0000000..ba8c827 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/api/UserStatsController.java @@ -0,0 +1,34 @@ +package com.chaewookim.accountbookformoms.domain.userstats.api; + +import com.chaewookim.accountbookformoms.domain.userstats.application.UserStatsService; +import com.chaewookim.accountbookformoms.domain.userstats.dto.response.UserStatsResponse; +import com.chaewookim.accountbookformoms.global.common.ApiResponse; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "User Stats API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users/{userId}") +public class UserStatsController { + + private final UserStatsService userStatsService; + + @Operation(summary = "사용자 프로필 통계") + @GetMapping("/stats") + public ResponseEntity> stats( + @PathVariable Long userId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long viewerId = principal == null ? null : principal.getUserId(); + return ResponseEntity.ok(ApiResponse.success(userStatsService.getStats(userId, viewerId))); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsService.java b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsService.java new file mode 100644 index 0000000..1251bd9 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsService.java @@ -0,0 +1,39 @@ +package com.chaewookim.accountbookformoms.domain.userstats.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.follow.application.FollowService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.domain.userstats.dto.response.UserStatsResponse; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserStatsService { + + private final UserRepository userRepository; + private final BoardRepository boardRepository; + private final FollowService followService; + + public UserStatsResponse getStats(Long targetUserId, Long viewerId) { + User user = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + long postCount = boardRepository.countByUserId(targetUserId); + long followerCount = followService.followerCount(targetUserId); + long followingCount = followService.followingCount(targetUserId); + boolean following = followService.isFollowing(targetUserId, viewerId); + return new UserStatsResponse( + user.getId(), + user.getUsername(), + postCount, + followerCount, + followingCount, + following + ); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/dto/response/UserStatsResponse.java b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/dto/response/UserStatsResponse.java new file mode 100644 index 0000000..2225f82 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/domain/userstats/dto/response/UserStatsResponse.java @@ -0,0 +1,11 @@ +package com.chaewookim.accountbookformoms.domain.userstats.dto.response; + +public record UserStatsResponse( + Long userId, + String nickname, + long postCount, + long followerCount, + long followingCount, + boolean following +) { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadController.java b/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadController.java new file mode 100644 index 0000000..2c78d15 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadController.java @@ -0,0 +1,31 @@ +package com.chaewookim.accountbookformoms.global.common.file; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/files") +@RequiredArgsConstructor +public class FileUploadController { + + private final FileUploadService fileUploadService; + + @PostMapping("/upload") + public ResponseEntity> uploadFile(@RequestParam("file") MultipartFile file) { + String uploadedUrl = fileUploadService.uploadFile(file); + + Map response = new HashMap<>(); + response.put("url", uploadedUrl); + + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadService.java b/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadService.java new file mode 100644 index 0000000..1317b7c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/common/file/FileUploadService.java @@ -0,0 +1,103 @@ +package com.chaewookim.accountbookformoms.global.common.file; + +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.InputStream; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +@Service +public class FileUploadService { + + static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024; + + private static final Map> ALLOWED_EXTENSION_TO_CONTENT_TYPES = Map.of( + ".jpg", Set.of("image/jpeg", "image/jpg"), + ".jpeg", Set.of("image/jpeg", "image/jpg"), + ".png", Set.of("image/png"), + ".webp", Set.of("image/webp"), + ".gif", Set.of("image/gif") + ); + + private final S3Client s3Client; + + @Value("${aws.s3.bucket}") + private String bucketName; + + @Value("${aws.cdn.base-url:}") + private String cdnBaseUrl; + + public String uploadFile(MultipartFile file) { + validate(file); + + String extension = resolveExtension(file.getOriginalFilename()); + String contentType = normalizeContentType(file.getContentType()); + String uniqueFileName = UUID.randomUUID() + extension; + + try (InputStream inputStream = file.getInputStream()) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(uniqueFileName) + .contentType(contentType) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); + + // CloudFront(커스텀 도메인) URL — S3는 OAC로만 조회 + String base = cdnBaseUrl.endsWith("/") ? cdnBaseUrl.substring(0, cdnBaseUrl.length() - 1) : cdnBaseUrl; + return base + "/" + uniqueFileName; + } catch (Exception e) { + log.error("AWS S3 파일 업로드 실패", e); + throw new RuntimeException("파일 업로드 중 오류가 발생했습니다."); + } + } + + void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(ErrorCode.FILE_EMPTY); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new CustomException(ErrorCode.FILE_TOO_LARGE); + } + + String extension = resolveExtension(file.getOriginalFilename()); + String contentType = normalizeContentType(file.getContentType()); + Set allowedContentTypes = ALLOWED_EXTENSION_TO_CONTENT_TYPES.get(extension); + + if (allowedContentTypes == null || contentType == null || !allowedContentTypes.contains(contentType)) { + throw new CustomException(ErrorCode.INVALID_FILE_TYPE); + } + } + + private static String resolveExtension(String originalFilename) { + if (originalFilename == null || !originalFilename.contains(".")) { + return ""; + } + return originalFilename.substring(originalFilename.lastIndexOf('.')).toLowerCase(Locale.ROOT); + } + + private static String normalizeContentType(String contentType) { + if (contentType == null || contentType.isBlank()) { + return null; + } + String normalized = contentType.toLowerCase(Locale.ROOT).trim(); + int semicolon = normalized.indexOf(';'); + if (semicolon >= 0) { + normalized = normalized.substring(0, semicolon).trim(); + } + return normalized; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/AsyncConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/AsyncConfig.java new file mode 100644 index 0000000..65b4bbb --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.chaewookim.accountbookformoms.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/ElasticsearchConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/ElasticsearchConfig.java new file mode 100644 index 0000000..f772489 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/ElasticsearchConfig.java @@ -0,0 +1,10 @@ +package com.chaewookim.accountbookformoms.global.config; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchRepository; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; + +@Configuration +@EnableElasticsearchRepositories(basePackageClasses = BoardSearchRepository.class) +public class ElasticsearchConfig { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/FirebaseConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/FirebaseConfig.java new file mode 100644 index 0000000..b468c0a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/FirebaseConfig.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +public class FirebaseConfig { + + @Value("${firebase.credentials-path:classpath:jointliving-notification.json}") + private Resource serviceAccount; + + @PostConstruct + public void init() { + if (!FirebaseApp.getApps().isEmpty()) { + return; + } + + try { + if (!serviceAccount.exists()) { + log.warn("Firebase credentials not found at [{}]. Push notifications will be disabled.", serviceAccount); + return; + } + + try (InputStream inputStream = serviceAccount.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(inputStream)) + .build(); + FirebaseApp.initializeApp(options); + log.info("Firebase initialized from [{}]", serviceAccount); + } + } catch (IOException e) { + log.error("Failed to initialize Firebase App from [{}]: {}", serviceAccount, e.getMessage()); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/JpaAuditingConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..e940b66 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.chaewookim.accountbookformoms.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/MockMailConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/MockMailConfig.java new file mode 100644 index 0000000..253cb64 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/MockMailConfig.java @@ -0,0 +1,66 @@ +//package com.chaewookim.accountbookformoms.global.config; +// +//import jakarta.mail.internet.MimeMessage; +//import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.mail.SimpleMailMessage; +//import org.springframework.mail.javamail.JavaMailSender; +//import org.springframework.mail.javamail.MimeMessagePreparator; +// +//import java.io.InputStream; +//import java.util.Properties; +// +//@Configuration +//public class MockMailConfig { +// +// @Bean +// @ConditionalOnMissingBean(JavaMailSender.class) +// public JavaMailSender javaMailSender() { +// return new JavaMailSender() { +// @Override +// public MimeMessage createMimeMessage() { +// return new MimeMessage(jakarta.mail.Session.getInstance(new Properties())); +// } +// +// @Override +// public MimeMessage createMimeMessage(InputStream contentStream) { +// try { +// return new MimeMessage(jakarta.mail.Session.getInstance(new Properties()), contentStream); +// } catch (Exception e) { +// throw new RuntimeException(e); +// } +// } +// +// @Override +// public void send(MimeMessage mimeMessage) { +// // Mock send +// } +// +// @Override +// public void send(MimeMessage... mimeMessages) { +// // Mock send +// } +// +// @Override +// public void send(MimeMessagePreparator mimeMessagePreparator) { +// // Mock send +// } +// +// @Override +// public void send(SimpleMailMessage simpleMessage) { +// // Mock send +// } +// +// @Override +// public void send(SimpleMailMessage... simpleMessages) { +// // Mock send +// } +// +// @Override +// public void send(MimeMessagePreparator... mimeMessagePreparators) { +// // Mock send +// } +// }; +// } +//} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/RedisConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/RedisConfig.java new file mode 100644 index 0000000..65aac8e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/RedisConfig.java @@ -0,0 +1,125 @@ +package com.chaewookim.accountbookformoms.global.config; + +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.DashboardResponse; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisConfig { + + // Board HOT 랭킹 캐시 (type + days + limit 키, cross-user hit) + public static final String CACHE_BOARD_HOT = "board:hot"; + + // 대시보드 (userId + yearMonth 키, 거래·예산 수정 시 CacheEvict) + public static final String CACHE_DASHBOARD = "dashboard"; + + // Phase 2: 그룹 평균 캐시 (userId 미포함 키 → cross-user hit) + public static final String CACHE_GROUP_BUDGET_AGE = "group:budget:age"; + public static final String CACHE_GROUP_BUDGET_AMOUNT = "group:budget:amount"; + public static final String CACHE_GROUP_BUDGET_CATEGORY = "group:budget:category"; + + public static final String CACHE_GROUP_EXPENSE_AGE_FIXED = "group:expense:age:fixed"; + public static final String CACHE_GROUP_EXPENSE_AGE_VARIABLE = "group:expense:age:variable"; + public static final String CACHE_GROUP_EXPENSE_AMOUNT_FIXED = "group:expense:amount:fixed"; + public static final String CACHE_GROUP_EXPENSE_AMOUNT_VARIABLE = "group:expense:amount:variable"; + public static final String CACHE_GROUP_EXPENSE_CATEGORY_FIXED = "group:expense:category:fixed"; + public static final String CACHE_GROUP_EXPENSE_CATEGORY_VARIABLE = "group:expense:category:variable"; + + public static final String CACHE_GROUP_INCOME_AGE_FIXED = "group:income:age:fixed"; + public static final String CACHE_GROUP_INCOME_AGE_VARIABLE = "group:income:age:variable"; + public static final String CACHE_GROUP_INCOME_AMOUNT_FIXED = "group:income:amount:fixed"; + public static final String CACHE_GROUP_INCOME_AMOUNT_VARIABLE = "group:income:amount:variable"; + public static final String CACHE_GROUP_INCOME_CATEGORY_FIXED = "group:income:category:fixed"; + public static final String CACHE_GROUP_INCOME_CATEGORY_VARIABLE = "group:income:category:variable"; + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } + + @Bean + public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY) + .activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.WRAPPER_ARRAY); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)); + + // 그룹 평균 캐시: warm-up 스케줄(30분 주기)과 정합되도록 TTL 30분 + RedisCacheConfiguration groupConfig = defaultConfig.entryTtl(Duration.ofMinutes(30)); + + // Board HOT 랭킹: 좋아요/조회수 변동 대비 5분 TTL + RedisCacheConfiguration hotConfig = defaultConfig.entryTtl(Duration.ofMinutes(5)); + + // 대시보드: record DTO는 default typing과 호환되지 않아 타입 고정 직렬화 사용 + ObjectMapper dashboardMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + Jackson2JsonRedisSerializer dashboardSerializer = + new Jackson2JsonRedisSerializer<>(dashboardMapper, DashboardResponse.class); + RedisCacheConfiguration dashboardConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(dashboardSerializer)); + + List groupCacheNames = List.of( + CACHE_GROUP_BUDGET_AGE, CACHE_GROUP_BUDGET_AMOUNT, CACHE_GROUP_BUDGET_CATEGORY, + CACHE_GROUP_EXPENSE_AGE_FIXED, CACHE_GROUP_EXPENSE_AGE_VARIABLE, + CACHE_GROUP_EXPENSE_AMOUNT_FIXED, CACHE_GROUP_EXPENSE_AMOUNT_VARIABLE, + CACHE_GROUP_EXPENSE_CATEGORY_FIXED, CACHE_GROUP_EXPENSE_CATEGORY_VARIABLE, + CACHE_GROUP_INCOME_AGE_FIXED, CACHE_GROUP_INCOME_AGE_VARIABLE, + CACHE_GROUP_INCOME_AMOUNT_FIXED, CACHE_GROUP_INCOME_AMOUNT_VARIABLE, + CACHE_GROUP_INCOME_CATEGORY_FIXED, CACHE_GROUP_INCOME_CATEGORY_VARIABLE); + + Map perCacheConfig = new java.util.HashMap<>(); + groupCacheNames.forEach(name -> perCacheConfig.put(name, groupConfig)); + perCacheConfig.put(CACHE_BOARD_HOT, hotConfig); + perCacheConfig.put(CACHE_DASHBOARD, dashboardConfig); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(perCacheConfig) + .build(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/S3Config.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/S3Config.java new file mode 100644 index 0000000..2e79ad4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/S3Config.java @@ -0,0 +1,32 @@ +package com.chaewookim.accountbookformoms.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${aws.s3.access-key}") + private String accessKey; + + @Value("${aws.s3.secret-key}") + private String secretKey; + + @Value("${aws.s3.region}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/SchedulerLockConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/SchedulerLockConfig.java new file mode 100644 index 0000000..d2c33d4 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/SchedulerLockConfig.java @@ -0,0 +1,28 @@ +package com.chaewookim.accountbookformoms.global.config; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; + +/** + * 다중 인스턴스 환경에서 {@code @Scheduled} 메서드의 중복 실행을 방지하기 위한 ShedLock 설정. + * 락 상태는 {@code shedlock} 테이블에 저장 ({@link com.chaewookim.accountbookformoms.global.entity.Shedlock}). + */ +@Configuration +@EnableSchedulerLock(defaultLockAtMostFor = "PT30M") +public class SchedulerLockConfig { + + @Bean + public LockProvider lockProvider(DataSource dataSource) { + return new JdbcTemplateLockProvider( + JdbcTemplateLockProvider.Configuration.builder() + .withJdbcTemplate(new JdbcTemplate(dataSource)) + .usingDbTime() + .build()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/SecurityConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/SecurityConfig.java deleted file mode 100644 index 422abda..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/global/config/SecurityConfig.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.chaewookim.accountbookformoms.global.config; - -import com.chaewookim.accountbookformoms.global.jwt.JwtFilter; -import com.chaewookim.accountbookformoms.global.jwt.JwtTokenProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class SecurityConfig { - - private final JwtTokenProvider jwtTokenProvider; - - // 비밀번호 암호화 - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - // 시큐리티 필터 체인 - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - // REST API이기 때문에 CSRF 보안 비활성화 - .csrf(AbstractHttpConfigurer::disable) - - // JWT 사용 예정이기 때문에 폼 로그인 & HTTP Basic 인증 비활성화 - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - - // 세션을 사용하지 않음 - // Stateless로 설정 - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - - // URL 별 권한 설정 - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-ui.html" - ).permitAll() - - // 로그인, 회원가입, 토큰 재발급 API 허용 - .requestMatchers( - HttpMethod.POST, - "/api/v1/auth/login", - "/api/v1/users/signup", - "/api/v1/auth/reissue" - ).permitAll() - - .requestMatchers( - HttpMethod.GET, - // user service 부분 - "/api/v1/users/me", - "/api/v1/auth/logout", - "/api/v1/users/withdraw", - - // account 부분 - "/api/v1/account/**", - - // transaction 부분 - "/api/v1/transactions/**", - - // fixedTransaction 부분 - "/api/v1/fixed-transactions/**" - ).authenticated() - - // 그 외 모든 요청은 인증 필요 - .anyRequest().authenticated() - ) - // JWT 필터 추가 - .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/config/SwaggerConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/config/SwaggerConfig.java index 94b8b0b..3b1d24f 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/config/SwaggerConfig.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/config/SwaggerConfig.java @@ -8,8 +8,10 @@ import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; @Configuration +@Profile("!prod") public class SwaggerConfig { @Bean @@ -61,6 +63,72 @@ public GroupedOpenApi categoryGroup() { .build(); } + @Bean + public GroupedOpenApi boardGroup() { + return GroupedOpenApi.builder() + .group("Board API") + .pathsToMatch( + "/api/v1/boards/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi commentGroup() { + return GroupedOpenApi.builder() + .group("Comment API") + .pathsToMatch( + "/api/v1/comments/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi adminBoardGroup() { + return GroupedOpenApi.builder() + .group("Admin Board API") + .pathsToMatch( + "/api/v1/admin/boards/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi adminCommentGroup() { + return GroupedOpenApi.builder() + .group("Admin Comment API") + .pathsToMatch( + "/api/v1/admin/comments/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi notificationGroup() { + return GroupedOpenApi.builder() + .group("Notification API") + .pathsToMatch( + "/api/v1/notifications/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi groupPurchaseGroup() { + return GroupedOpenApi.builder() + .group("Group Purchase API") + .pathsToMatch( + "/api/v1/group-purchases/**", + "/api/v1/group-purchase-applications/**", + "/api/v1/products/**", + "/api/v1/group-purchase-categories/**", + "/api/v1/reports/**", + "/api/v1/admin/group-purchases/**", + "/api/v1/admin/reports/**" + ) + .build(); + } + @Bean public GroupedOpenApi allGroup() { return GroupedOpenApi.builder() diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/entity/BaseEntity.java b/src/main/java/com/chaewookim/accountbookformoms/global/entity/BaseEntity.java index 608a47f..02a8682 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/entity/BaseEntity.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/entity/BaseEntity.java @@ -1,5 +1,6 @@ package com.chaewookim.accountbookformoms.global.entity; +import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; @@ -15,8 +16,19 @@ public abstract class BaseEntity { @CreatedDate + @Column(updatable = false, nullable = false) private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/entity/Shedlock.java b/src/main/java/com/chaewookim/accountbookformoms/global/entity/Shedlock.java new file mode 100644 index 0000000..d096fec --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/entity/Shedlock.java @@ -0,0 +1,45 @@ +package com.chaewookim.accountbookformoms.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * ShedLock 의 락 상태 저장용 테이블. JPA 엔티티는 오직 ddl-auto: update 가 테이블을 만들기 위한 용도. + *

애플리케이션 코드는 이 엔티티를 참조하지 않으며, ShedLock 라이브러리가 JdbcTemplate 으로 직접 다룬다.

+ *

운영 환경(ddl-auto: validate) 에서는 DDL 을 수동 적용해야 한다:

+ *
+ * CREATE TABLE shedlock(
+ *     name       VARCHAR(64)  NOT NULL,
+ *     lock_until TIMESTAMP(3) NOT NULL,
+ *     locked_at  TIMESTAMP(3) NOT NULL,
+ *     locked_by  VARCHAR(255) NOT NULL,
+ *     PRIMARY KEY (name)
+ * );
+ * 
+ */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "shedlock") +public class Shedlock { + + @Id + @Column(length = 64) + private String name; + + @Column(name = "lock_until", nullable = false) + private LocalDateTime lockUntil; + + @Column(name = "locked_at", nullable = false) + private LocalDateTime lockedAt; + + @Column(name = "locked_by", nullable = false) + private String lockedBy; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/error/BaseErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/global/error/BaseErrorCode.java new file mode 100644 index 0000000..a35faec --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/error/BaseErrorCode.java @@ -0,0 +1,12 @@ +package com.chaewookim.accountbookformoms.global.error; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + HttpStatus getStatus(); + + String getMessage(); + + String name(); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/error/CustomException.java b/src/main/java/com/chaewookim/accountbookformoms/global/error/CustomException.java index a391157..7606c82 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/error/CustomException.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/error/CustomException.java @@ -7,5 +7,5 @@ @RequiredArgsConstructor public class CustomException extends RuntimeException { - private final ErrorCode errorCode; + private final BaseErrorCode errorCode; } diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/error/ErrorCode.java b/src/main/java/com/chaewookim/accountbookformoms/global/error/ErrorCode.java index b7dd005..32bab18 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/error/ErrorCode.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/error/ErrorCode.java @@ -6,10 +6,13 @@ @Getter @RequiredArgsConstructor -public enum ErrorCode { +public enum ErrorCode implements BaseErrorCode { // 400 Bad Request INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "파라미터 값을 확인해주세요."), + FILE_EMPTY(HttpStatus.BAD_REQUEST, "파일이 비어있습니다."), + FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "파일 크기는 5MB 이하여야 합니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "이미지 파일(jpeg, png, webp, gif)만 업로드할 수 있습니다."), // 401 Unauthorized INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), @@ -17,24 +20,9 @@ public enum ErrorCode { // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), - // 409 Conflict - DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), - // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러입니다."), - // 로그인 실패 - LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해주세요."), - - // RefreshToken 불일치 - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 올바르지 않습니다."), - - // RefreshToken 찾지 못 함 - REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "리프레시 토큰이 존재하지 않습니다."), - - // 비밀번호 불일치 - PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), - // 계좌 존재하지 않음 ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 계좌가 존재하지 않습니다."), @@ -51,7 +39,38 @@ public enum ErrorCode { ASSET_NOT_FOUND(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없거나 접근 권한이 없습니다."), // 금액은 NULL이 될 수 없음 - AMOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "금액은 비어있을 수 없습니다."); + AMOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "금액은 비어있을 수 없습니다."), + + // 공동구매 관련 에러 + GROUP_PURCHASE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 공동구매를 찾을 수 없습니다."), + UNAUTHORIZED_GROUP_PURCHASE(HttpStatus.FORBIDDEN, "해당 공동구매에 대한 권한이 없습니다."), + + // 공동구매 신청 관련 에러 + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 공동구매 신청을 찾을 수 없습니다."), + UNAUTHORIZED_APPLICATION(HttpStatus.FORBIDDEN, "해당 신청에 대한 권한이 없습니다."), + + // 상품 관련 에러 + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 상품을 찾을 수 없습니다."), + + // 카테고리 관련 에러 + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."), + + // 신고 관련 에러 + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 신고 내역을 찾을 수 없습니다."), + REPORT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 신고 내역입니다."), + INVALID_REPORT_TARGET(HttpStatus.BAD_REQUEST, "신고 대상이 올바르지 않거나 존재하지 않습니다."), + + // 공동구매 참여 관련 에러 + GROUP_PURCHASE_ALREADY_JOINED(HttpStatus.BAD_REQUEST, "이미 참여 중인 공동구매입니다."), + GROUP_PURCHASE_NOT_JOINED(HttpStatus.BAD_REQUEST, "참여하지 않은 공동구매입니다."), + GROUP_PURCHASE_FULL(HttpStatus.BAD_REQUEST, "모집 인원이 마감되었습니다."), + GROUP_PURCHASE_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "현재 모집 중인 공동구매가 아닙니다."), + GROUP_PURCHASE_DEADLINE_PASSED(HttpStatus.BAD_REQUEST, "마감 기한이 지난 공동구매입니다."), + GROUP_PURCHASE_MIN_NOT_REACHED(HttpStatus.BAD_REQUEST, "최소 모집 인원에 도달하지 못했습니다."), + + // 락 획득 실패 + LOCK_ACQUISITION_FAILED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."); + private final HttpStatus status; private final String message; diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/error/GlobalExceptionHandler.java b/src/main/java/com/chaewookim/accountbookformoms/global/error/GlobalExceptionHandler.java index 641bfde..18667ac 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/error/GlobalExceptionHandler.java @@ -2,8 +2,10 @@ import com.chaewookim.accountbookformoms.global.common.ApiResponse; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -16,6 +18,13 @@ public ResponseEntity> handleCustomException(CustomException e .body(ApiResponse.error(e.getErrorCode().getMessage())); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSizeExceeded(MaxUploadSizeExceededException e) { + return ResponseEntity + .status(ErrorCode.FILE_TOO_LARGE.getStatus()) + .body(ApiResponse.error(ErrorCode.FILE_TOO_LARGE.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { @@ -25,4 +34,20 @@ public ResponseEntity> handleException(Exception e) { .status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()) .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR.getMessage())); } + + @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(org.springframework.security.access.AccessDeniedException e) { + return ResponseEntity + .status(org.springframework.http.HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); + + return ResponseEntity + .badRequest() + .body(ApiResponse.error(errorMessage)); + } } diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/event/BoardChangedEvent.java b/src/main/java/com/chaewookim/accountbookformoms/global/event/BoardChangedEvent.java new file mode 100644 index 0000000..4152a4b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/event/BoardChangedEvent.java @@ -0,0 +1,17 @@ +package com.chaewookim.accountbookformoms.global.event; + +public record BoardChangedEvent(Long boardId, Type type) { + + public enum Type { + UPSERT, + DELETE + } + + public static BoardChangedEvent upsert(Long boardId) { + return new BoardChangedEvent(boardId, Type.UPSERT); + } + + public static BoardChangedEvent delete(Long boardId) { + return new BoardChangedEvent(boardId, Type.DELETE); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtTokenProvider.java b/src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtTokenProvider.java deleted file mode 100644 index 681db01..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.chaewookim.accountbookformoms.global.jwt; - -import com.chaewookim.accountbookformoms.domain.user.domain.CustomUserDetails; -import com.chaewookim.accountbookformoms.domain.user.domain.UserRole; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.UnsupportedJwtException; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; - -import java.security.Key; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.stream.Collectors; - -@Slf4j -@Component -public class JwtTokenProvider { - - private static final String AUTHORITIES_KEY = "auth"; - private static final String BEARER_TYPE = "Bearer"; - - // 만료 시간 설정 (밀리초 단위) - private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분 - private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; // 14일 - - private final Key key; - - // application.yml에서 secret 값 가져와서 키 생성 - public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - this.key = Keys.hmacShaKeyFor(keyBytes); - } - - // 1. Access Token 생성 - public String createAccessToken(Authentication authentication) { - long now = (new Date()).getTime(); - Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); - - CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); - - String authorities = userDetails.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(",")); - Long userId = userDetails.getUserId(); - - return Jwts.builder() - .setSubject(authentication.getName()) // payload "sub": "username" - .claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER" - .claim("userId", userId) - .setExpiration(accessTokenExpiresIn) // payload "exp": 1234567890 (유효기간) - .signWith(key, SignatureAlgorithm.HS256) // header "alg": "HS256" - .compact(); - } - - public String createRefreshToken(Authentication authentication) { - long now = (new Date()).getTime(); - Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME); - - return Jwts.builder() - .setSubject(authentication.getName()) // ⭐️ 핵심: 이 토큰의 주인은 이 사람이다! - .setExpiration(refreshTokenExpiresIn) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - // 3. Refresh Token의 만료 시간 조회 (저장용) - public Date getRefreshTokenExpireTime() { - return new Date(new Date().getTime() + REFRESH_TOKEN_EXPIRE_TIME); - } - - // 4. 토큰에서 인증 정보(Authentication) 꺼내기 - public Authentication getAuthentication(String accessToken) { - // 토큰 복호화 - Claims claims = parseClaims(accessToken); - - String username = claims.getSubject(); - String email = claims.get("email", String.class); - String role = claims.get("auth", String.class); - log.info("username = {}, email = {}, role = {}", username, email, role); - Number userIdNum = claims.get("userId", Long.class); - Long userId = (userIdNum == null) ? null : userIdNum.longValue(); - - if (claims.get(AUTHORITIES_KEY) == null) { - throw new RuntimeException("권한 정보가 없는 토큰입니다."); - } - - // 클레임에서 권한 정보 가져오기 - Collection authorities = - Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - // UserDetails 객체를 만들어서 Authentication 리턴 - UserDetails principal = new CustomUserDetails( - userId, - username, - "", - email, - UserRole.findByKey(role) - ); - return new UsernamePasswordAuthenticationToken(principal, "", authorities); - } - - // 5. 토큰 유효성 검증 - public boolean validateToken(String token) { - try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); - return true; - } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { - log.info("잘못된 JWT 서명입니다."); - } catch (ExpiredJwtException e) { - log.info("만료된 JWT 토큰입니다."); - } catch (UnsupportedJwtException e) { - log.info("지원되지 않는 JWT 토큰입니다."); - } catch (IllegalArgumentException e) { - log.info("JWT 토큰이 잘못되었습니다."); - } - return false; - } - - // 토큰 파싱 (내부용) - private Claims parseClaims(String accessToken) { - try { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); - } catch (ExpiredJwtException e) { - return e.getClaims(); - } - } - - public String getSubject(String token) { - return parseClaims(token).getSubject(); - } -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/mail/AsyncVerificationEmailSender.java b/src/main/java/com/chaewookim/accountbookformoms/global/mail/AsyncVerificationEmailSender.java new file mode 100644 index 0000000..d8a497b --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/mail/AsyncVerificationEmailSender.java @@ -0,0 +1,49 @@ +package com.chaewookim.accountbookformoms.global.mail; + +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.UnsupportedEncodingException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AsyncVerificationEmailSender { + + private final JavaMailSender mailSender; + private final RedisTemplate redisTemplate; + + @Value("${app.mail.from}") + private String mailFrom; + + @Async + public void send(String email, String code, VerificationType type) { + try { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + helper.setTo(email); + helper.setFrom(mailFrom, VerificationEmailTemplate.SENDER_NAME); + helper.setSubject(VerificationEmailTemplate.getSubject(type)); + helper.setText( + VerificationEmailTemplate.buildPlainText(code, type), + VerificationEmailTemplate.buildHtml(code, type) + ); + mailSender.send(mimeMessage); + log.info("인증 메일 발송 완료 - email: {}, type: {}", email, type); + } catch (MailException | MessagingException | UnsupportedEncodingException e) { + redisTemplate.delete("VERIFY:" + type + ":" + email); + redisTemplate.delete("LOCK:" + type + ":" + email); + log.error("인증 메일 발송 실패 - email: {}, type: {}, from: {}, reason: {} (Redis 롤백 완료)", email, type, mailFrom, e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/mail/VerificationEmailTemplate.java b/src/main/java/com/chaewookim/accountbookformoms/global/mail/VerificationEmailTemplate.java new file mode 100644 index 0000000..d5f10ea --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/mail/VerificationEmailTemplate.java @@ -0,0 +1,83 @@ +package com.chaewookim.accountbookformoms.global.mail; + +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; + +public final class VerificationEmailTemplate { + + public static final String SENDER_NAME = "MODI"; + + private VerificationEmailTemplate() { + } + + public static String getSubject(VerificationType type) { + return type == VerificationType.SIGNUP + ? "[MODI] 회원가입 인증번호" + : "[MODI] 비밀번호 재설정 인증번호"; + } + + public static String buildPlainText(String code, VerificationType type) { + String title = type == VerificationType.SIGNUP ? "회원가입" : "비밀번호 재설정"; + return """ + MODI %s 인증번호 + + 인증번호: %s + + 인증번호는 3분간 유효합니다. + 본인이 요청하지 않았다면 이 메일을 무시해 주세요. + """.formatted(title, code).strip(); + } + + public static String buildHtml(String code, VerificationType type) { + boolean isSignup = type == VerificationType.SIGNUP; + String title = isSignup ? "회원가입 인증번호" : "비밀번호 재설정 인증번호"; + String description = isSignup + ? "아래 인증번호를 입력하여 이메일 인증을 완료해 주세요." + : "아래 인증번호를 입력하여 비밀번호 재설정을 진행해 주세요."; + + return """ + + + + + + %s + + + + + + +
+ + + + + + + + + + +
+

MODI

+

함께하면 즐겁고 가벼워지는 공동생활 가계부

+
+

%s

+

%s

+
+ 인증번호 + %s +
+

+ 인증번호는 3분간 유효합니다.
+ 본인이 요청하지 않았다면 이 메일을 무시해 주세요. +

+
+

© MODI. All rights reserved.

+
+
+ + + """.formatted(title, title, description, code); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupScheduler.java b/src/main/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupScheduler.java new file mode 100644 index 0000000..db08170 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupScheduler.java @@ -0,0 +1,89 @@ +package com.chaewookim.accountbookformoms.global.scheduler; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetGroupCacheService; +import com.chaewookim.accountbookformoms.domain.expense.application.ExpenseGroupCacheService; +import com.chaewookim.accountbookformoms.domain.income.application.IncomeGroupCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +/** + * Compare 그룹 캐시 warm-up. + *

현재 월 기준으로 (1) 시스템 카테고리 모든 id × Budget/Expense(fixed+variable)/Income(fixed+variable), + * (2) 6개 나이대 (20-70대) × 3 도메인 의 그룹 평균을 미리 캐시에 적재.

+ *

AMOUNT axis 는 표준 버킷 정의가 없어 미포함, LOCATION 은 사용자별 반경 결과라 cross-user hit 0% 라 미포함.

+ *

다중 인스턴스 환경 중복 실행은 ShedLock 으로 차단.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.cache.warmup.compare.enabled", havingValue = "true", matchIfMissing = true) +public class CompareCacheWarmupScheduler { + + private static final List WARMUP_DECADES = List.of(20, 30, 40, 50, 60, 70); + + private final TransactionCategoryRepository categoryRepository; + private final BudgetGroupCacheService budgetGroupCache; + private final ExpenseGroupCacheService expenseGroupCache; + private final IncomeGroupCacheService incomeGroupCache; + + @Scheduled(cron = "0 */30 * * * *") + @SchedulerLock(name = "CompareCacheWarmupScheduler_warmupCompareGroupCaches", + lockAtMostFor = "PT20M", lockAtLeastFor = "PT1M") + public void warmupCompareGroupCaches() { + + YearMonth ym = YearMonth.now(); + String yearMonth = ym.toString(); + LocalDate startDate = ym.atDay(1); + LocalDate endDate = ym.atEndOfMonth(); + int currentYear = LocalDate.now().getYear(); + long start = System.currentTimeMillis(); + + log.info("Compare cache warm-up 시작: yearMonth={}", yearMonth); + + warmupAge(yearMonth, startDate, endDate, currentYear); + warmupCategory(yearMonth, startDate, endDate); + + log.info("Compare cache warm-up 완료: {}ms", System.currentTimeMillis() - start); + } + + private void warmupAge(String yearMonth, LocalDate startDate, LocalDate endDate, int currentYear) { + for (int decade : WARMUP_DECADES) { + LocalDate birthFrom = LocalDate.of(currentYear - (decade + 9), 1, 1); + LocalDate birthTo = LocalDate.of(currentYear - decade, 12, 31); + try { + budgetGroupCache.getAgeGroupSums(yearMonth, birthFrom, birthTo); + expenseGroupCache.getAgeFixedSums(startDate, endDate, birthFrom, birthTo); + expenseGroupCache.getAgeVariableSums(startDate, endDate, birthFrom, birthTo); + incomeGroupCache.getAgeFixedSums(startDate, endDate, birthFrom, birthTo); + incomeGroupCache.getAgeVariableSums(startDate, endDate, birthFrom, birthTo); + } catch (Exception e) { + log.warn("AGE warm-up 실패: decade={}, error={}", decade, e.getMessage()); + } + } + } + + private void warmupCategory(String yearMonth, LocalDate startDate, LocalDate endDate) { + List categoryIds = categoryRepository.findSystemCategoryIds(); + for (Long categoryId : categoryIds) { + try { + budgetGroupCache.getCategoryAggregation(yearMonth, categoryId); + expenseGroupCache.getCategoryFixedSums(startDate, endDate, categoryId); + expenseGroupCache.getCategoryVariableSums(startDate, endDate, categoryId); + incomeGroupCache.getCategoryFixedSums(startDate, endDate, categoryId); + incomeGroupCache.getCategoryVariableSums(startDate, endDate, categoryId); + } catch (Exception e) { + log.warn("CATEGORY warm-up 실패: categoryId={}, error={}", categoryId, e.getMessage()); + } + } + log.info("CATEGORY warm-up: {} 카테고리 처리", categoryIds.size()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CorsProperties.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CorsProperties.java new file mode 100644 index 0000000..cfa2e8e --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CorsProperties.java @@ -0,0 +1,16 @@ +package com.chaewookim.accountbookformoms.global.security.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "app.cors") +public class CorsProperties { + private List allowedOrigins; +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CsrfCookieFilter.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CsrfCookieFilter.java new file mode 100644 index 0000000..846a0f2 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/CsrfCookieFilter.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.global.security.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + // Render the token value to a cookie by causing the deferred token to be loaded + csrfToken.getToken(); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/config/SecurityConfig.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..3a787b1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/config/SecurityConfig.java @@ -0,0 +1,168 @@ +package com.chaewookim.accountbookformoms.global.security.config; + +import com.chaewookim.accountbookformoms.global.security.jwt.JwtFilter; +import com.chaewookim.accountbookformoms.global.security.jwt.JwtTokenProvider; +import com.chaewookim.accountbookformoms.global.security.oauth2.CustomOAuth2UserService; +import com.chaewookim.accountbookformoms.global.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import com.chaewookim.accountbookformoms.global.security.oauth2.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +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.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Value("${app.cors.allowed-origins:http://localhost:5173}") + private List allowedOrigins; + private final CorsProperties corsProperties; + + // 비밀번호 암호화 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // 시큐리티 필터 체인 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + // JWT 사용 방식(Authorization 헤더)이므로 CSRF 방어 비활성화 + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // JWT 사용 예정이기 때문에 폼 로그인 & HTTP Basic 인증 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // 세션을 사용하지 않음 + // Stateless로 설정 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // URL 별 권한 설정 + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html" + ).permitAll() + + // 로그인, 회원가입, 토큰 재발급 API 허용 + .requestMatchers( + HttpMethod.POST, + "/api/v1/auth/**", + "/api/v1/users/signup" + ).permitAll() + + // 관리자 전용 API + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + + .requestMatchers( + // user 부분 + "/api/v1/users/**", + "/api/v1/auth/logout", + + // account 부분 + "/api/v1/account/**", + "/api/v1/allocation/**", + + // transaction 부분 + "/api/v1/transactions/**", + + // fixedTransaction 부분 + "/api/v1/fixed-transactions/**", + + // category 부분 + "/api/v1/categories/**" + ).authenticated() + + // 그 외 모든 요청은 인증 필요 + .anyRequest().authenticated() + ) + // API 요청은 로그인 페이지로 리다이렉트하지 않고 JSON으로 응답 + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"success\":false,\"data\":null,\"error\":\"인증이 필요합니다.\"}" + ); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"success\":false,\"data\":null,\"error\":\"접근 권한이 없습니다.\"}" + ); + }) + ) + // OAuth2 로그인 설정 + .oauth2Login(oauth -> oauth + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository) + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + ) + // JWT 필터 추가 + .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + if (request.getRequestURI().startsWith("/api/")) { + response.sendError(jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + } else { + response.sendRedirect("/login"); + } + }) + ); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(corsProperties.getAllowedOrigins()); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(List.of("Authorization")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtFilter.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtFilter.java similarity index 75% rename from src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtFilter.java rename to src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtFilter.java index 1ec25d4..2baf713 100644 --- a/src/main/java/com/chaewookim/accountbookformoms/global/jwt/JwtFilter.java +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtFilter.java @@ -1,9 +1,10 @@ -package com.chaewookim.accountbookformoms.global.jwt; +package com.chaewookim.accountbookformoms.global.security.jwt; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; @@ -20,7 +21,11 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { // 1. Request Header에서 토큰 추출 String token = resolveToken(request); @@ -30,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 3. 토큰이 유효하면 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 Authentication authentication = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); - log.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), request.getRequestURI()); + log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), request.getRequestURI()); } // 4. 다음 필터로 이동 @@ -41,7 +46,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); // "Bearer " 이후의 문자열만 가져옴 + return bearerToken.substring(7); } return null; } diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7cfb8b8 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,84 @@ +package com.chaewookim.accountbookformoms.global.security.jwt; + +import com.chaewookim.accountbookformoms.global.security.principal.CustomUserDetailsService; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final CustomUserDetailsService customUserDetailsService; + + public JwtTokenProvider( + @Value("${jwt.secret}") String secretKey, + CustomUserDetailsService customUserDetailsService) { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.customUserDetailsService = customUserDetailsService; + } + + // Access 토큰 생성 + public String createAccessToken(String email, String role) { + long accessTokenValidityTime = 1000L * 60 * 30; // 30분 + return createToken(email, role, accessTokenValidityTime); + } + + // Refresh 토큰 생성 + public String createRefreshToken(String email) { + long refreshTokenValidityTime = 1000L * 60 * 60 * 24 * 14; // 14일 + return createToken(email, null, refreshTokenValidityTime); + } + + // 공통 토큰 생성 메서드 + private String createToken(String email, String role, long validityTime) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityTime); + + var builder = Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256); + + if (role != null) { + builder.claim("role", role); + } + + return builder.compact(); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + // 토큰에서 인증 정보 가져오기 + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + String email = claims.getSubject(); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..98dd3ed --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,71 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import com.chaewookim.accountbookformoms.domain.user.application.UserCommonService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.transaction.annotation.Transactional; +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; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final UserCommonService userCommonService; + + @org.springframework.beans.factory.annotation.Value("${app.admin.email:modi091321@gmail.com}") + private String adminEmail; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2UserInfo userInfo = switch (registrationId) { + case "google" -> new GoogleUserInfo(oAuth2User.getAttributes()); + case "kakao" -> new KakaoUserInfo(oAuth2User.getAttributes()); + case "naver" -> new NaverUserInfo(oAuth2User.getAttributes()); + default -> throw new CustomException(UserErrorCode.UNSUPPORTED_SOCIAL_TYPE); + }; + + var existingUser = userRepository.findByEmailIncludingDeleted(userInfo.getEmail()); + boolean newSocialSignup; + User user; + + if (existingUser.isPresent()) { + User entity = existingUser.get(); + if (entity.getDeletedAt() != null) { + user = userCommonService.restoreUser(entity, userInfo.getName(), null, null, null); + newSocialSignup = true; + } else { + user = entity.update(userInfo.getName()); + newSocialSignup = false; + } + } else { + user = userCommonService.saveSocialUser( + userInfo.getEmail(), + userInfo.getName(), + SocialProvider.from(userInfo.getProvider()) + ); + newSocialSignup = true; + } + + if (adminEmail.equals(userInfo.getEmail())) { + user.updateRole(com.chaewookim.accountbookformoms.domain.user.enums.UserRole.ROLE_ADMIN); + userRepository.save(user); + } + + return UserPrincipal.create(user, oAuth2User.getAttributes(), newSocialSignup); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfo.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfo.java new file mode 100644 index 0000000..a2b708a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfo.java @@ -0,0 +1,15 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import java.util.Map; + +public record GoogleUserInfo(Map attributes) implements OAuth2UserInfo { + + @Override + public String getProvider() { return "google"; } + + @Override + public String getEmail(){ return (String) attributes.get("email"); } + + @Override + public String getName() { return (String) attributes.get("name"); } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..581af55 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,106 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import com.chaewookim.accountbookformoms.global.util.CookieUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { + + public static final String POST_LOGIN_REDIRECT_PARAM = "post_login_redirect"; + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String POST_LOGIN_REDIRECT_COOKIE = "post_login_redirect"; + + private static final int cookieExpireSeconds = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + if (authorizationRequest == null) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, POST_LOGIN_REDIRECT_COOKIE); + CookieUtils.deleteCookie(request, response, "redirect_uri"); + return; + } + + CookieUtils.deleteCookie(request, response, "redirect_uri"); + + String redirectUriAfterLogin = resolvePostLoginRedirectParam(request); + OAuth2AuthorizationRequest toSave = authorizationRequest; + + if (redirectUriAfterLogin != null && !redirectUriAfterLogin.isBlank()) { + Map additionalParameters = new HashMap<>(authorizationRequest.getAdditionalParameters()); + additionalParameters.put(POST_LOGIN_REDIRECT_COOKIE, redirectUriAfterLogin); + toSave = OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(additionalParameters) + .build(); + CookieUtils.addCookie(response, POST_LOGIN_REDIRECT_COOKIE, redirectUriAfterLogin, cookieExpireSeconds); + } + + CookieUtils.addCookie( + response, + OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtils.serialize(toSave), + cookieExpireSeconds + ); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, POST_LOGIN_REDIRECT_COOKIE); + CookieUtils.deleteCookie(request, response, "redirect_uri"); + } + + public String resolveRedirectUriAfterLogin(HttpServletRequest request) { + String fromCookie = CookieUtils.getCookie(request, POST_LOGIN_REDIRECT_COOKIE) + .map(Cookie::getValue) + .filter(value -> !value.isBlank()) + .orElse(null); + if (fromCookie != null) { + return fromCookie; + } + + OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); + if (authorizationRequest == null) { + return null; + } + + Object embedded = authorizationRequest.getAdditionalParameters().get(POST_LOGIN_REDIRECT_COOKIE); + if (embedded == null) { + return null; + } + String value = embedded.toString(); + return value.isBlank() ? null : value; + } + + /** 앱은 post_login_redirect, 기존 웹·Admin 은 redirect_uri 파라미터 사용 */ + private static String resolvePostLoginRedirectParam(HttpServletRequest request) { + String fromPostLogin = request.getParameter(POST_LOGIN_REDIRECT_PARAM); + if (fromPostLogin != null && !fromPostLogin.isBlank()) { + return fromPostLogin; + } + String legacy = request.getParameter("redirect_uri"); + if (legacy != null && !legacy.isBlank()) { + return legacy; + } + return null; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfo.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfo.java new file mode 100644 index 0000000..86888a5 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfo.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import java.util.Map; + +public record KakaoUserInfo(Map attributes) implements OAuth2UserInfo { + + @Override + public String getProvider() { return "kakao"; } + + @Override + @SuppressWarnings("unchecked") + public String getEmail() { + Map account = (Map) attributes.get("kakao_account"); + return (String) account.get("email"); + } + + @Override + @SuppressWarnings("unchecked") + public String getName() { + Map profile = (Map) attributes.get("properties"); + return (String) profile.get("nickname"); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfo.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfo.java new file mode 100644 index 0000000..9b68172 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfo.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import java.util.Map; + +public record NaverUserInfo(Map attributes) implements OAuth2UserInfo { + + @Override + public String getProvider() { return "naver"; } + + @Override + @SuppressWarnings("unchecked") + public String getEmail() { + return (String) ((Map) attributes.get("response")).get("email"); + } + + @Override + @SuppressWarnings("unchecked") + public String getName() { + return (String) ((Map) attributes.get("response")).get("name"); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidator.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidator.java new file mode 100644 index 0000000..fcf5303 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidator.java @@ -0,0 +1,56 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +public class OAuth2RedirectUriValidator { + + private final Set allowedRedirectUris; + private final String appDefaultRedirectUri; + private final String webDefaultRedirectUri; + + public OAuth2RedirectUriValidator( + @Value("${app.oauth2.authorized-redirect-uris:}") List allowedRedirectUris, + @Value("${app.oauth2.authorized-redirect-uri:com.jointliving.app://oauth2/redirect}") String appDefaultRedirectUri, + @Value("${app.oauth2.authorized-redirect-uri-web:http://localhost:5173/oauth2/redirect}") String webDefaultRedirectUri + ) { + this.allowedRedirectUris = Set.copyOf(allowedRedirectUris); + this.appDefaultRedirectUri = appDefaultRedirectUri; + this.webDefaultRedirectUri = webDefaultRedirectUri; + } + + public String resolve(String candidateUri) { + if (candidateUri != null && !candidateUri.isBlank() && allowedRedirectUris.contains(candidateUri)) { + return candidateUri; + } + if (candidateUri != null && !candidateUri.isBlank()) { + log.warn("OAuth2 redirect_uri whitelist 미등록 값 감지, fallback 적용: {}", candidateUri); + return fallbackForUnknown(candidateUri); + } + return appDefaultRedirectUri; + } + + private String fallbackForUnknown(String candidateUri) { + if (isCapacitorMisredirect(candidateUri)) { + return appDefaultRedirectUri; + } + if (candidateUri.startsWith("http://") || candidateUri.startsWith("https://")) { + return webDefaultRedirectUri; + } + return appDefaultRedirectUri; + } + + private static boolean isCapacitorMisredirect(String candidateUri) { + return candidateUri.startsWith("https://localhost/") + || candidateUri.startsWith("capacitor://") + || candidateUri.startsWith("http://localhost/") + && !candidateUri.startsWith("http://localhost:5173/") + && !candidateUri.startsWith("http://localhost:5174/"); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2SuccessHandler.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 0000000..d113374 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,89 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import com.chaewookim.accountbookformoms.domain.user.dao.RefreshTokenRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.RefreshToken; +import com.chaewookim.accountbookformoms.global.security.jwt.JwtTokenProvider; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.HexFormat; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + static final String OAUTH_CODE_KEY_PREFIX = "OAUTH2:CODE:"; + private static final Duration OAUTH_CODE_TTL = Duration.ofSeconds(60); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + private final OAuth2RedirectUriValidator redirectUriValidator; + private final RedisTemplate redisTemplate; + + @Value("${app.oauth2.admin-redirect-uri:https://admin-frontend-rho-five.vercel.app/oauth2/redirect}") + private String adminRedirectUri; + + @Value("${app.oauth2.admin-redirect-uri-local:http://localhost:5174/oauth2/redirect}") + private String adminRedirectUriLocal; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + String email = principal.getUsername(); + String role = principal.getAuthorities().iterator().next().getAuthority(); + + String accessToken = jwtTokenProvider.createAccessToken(email, role); + String refreshToken = jwtTokenProvider.createRefreshToken(email); + refreshTokenRepository.save(new RefreshToken(email, refreshToken)); + + // AuthService를 주입하면 SecurityConfig ↔ AuthService 순환참조가 생기므로 여기서 직접 발급 + String code = issueOAuthCode(accessToken, refreshToken, principal.isNewSocialSignup()); + + String candidateUri = httpCookieOAuth2AuthorizationRequestRepository.resolveRedirectUriAfterLogin(request); + String targetUrl = redirectUriValidator.resolve(candidateUri); + + if ("ROLE_ADMIN".equals(role)) { + targetUrl = targetUrl.contains("localhost") ? adminRedirectUriLocal : adminRedirectUri; + } + + // 앱 커스텀 스킴은 UriComponentsBuilder가 host/path를 깨뜨릴 수 있어 쿼리만 직접 붙인다 + targetUrl = appendQueryParam(targetUrl, "code", code); + + clearAuthenticationAttributes(request, response); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + private String issueOAuthCode(String accessToken, String refreshToken, boolean isNewUser) { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + String code = HexFormat.of().formatHex(bytes); + String value = accessToken + "\n" + refreshToken + "\n" + isNewUser; + redisTemplate.opsForValue().set(OAUTH_CODE_KEY_PREFIX + code, value, OAUTH_CODE_TTL); + return code; + } + + protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { + super.clearAuthenticationAttributes(request); + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + } + + static String appendQueryParam(String url, String name, String value) { + String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8); + return url + (url.contains("?") ? "&" : "?") + name + "=" + encoded; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2UserInfo.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2UserInfo.java new file mode 100644 index 0000000..780b047 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2UserInfo.java @@ -0,0 +1,8 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +public interface OAuth2UserInfo { + + String getProvider(); + String getEmail(); + String getName(); +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/CustomUserDetailsService.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/CustomUserDetailsService.java new file mode 100644 index 0000000..96b9136 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.chaewookim.accountbookformoms.global.security.principal; + +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + return UserPrincipal.create(user); + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/UserPrincipal.java b/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/UserPrincipal.java new file mode 100644 index 0000000..59776e1 --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/security/principal/UserPrincipal.java @@ -0,0 +1,72 @@ +package com.chaewookim.accountbookformoms.global.security.principal; + +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class UserPrincipal implements UserDetails, OAuth2User { + + private final User user; + private Map attributes; + private final boolean newSocialSignup; + + private UserPrincipal(User user, Map attributes, boolean newSocialSignup) { + this.user = user; + this.attributes = attributes; + this.newSocialSignup = newSocialSignup; + } + + public static UserPrincipal create(User user) { + return new UserPrincipal(user, null, false); + } + + public static UserPrincipal create(User user, Map attributes) { + return new UserPrincipal(user, attributes, false); + } + + public static UserPrincipal create(User user, Map attributes, boolean newSocialSignup) { + return new UserPrincipal(user, attributes, newSocialSignup); + } + + public Long getUserId() { + return user.getId(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public String getName() { + return String.valueOf(user.getId()); + } + + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/util.java b/src/main/java/com/chaewookim/accountbookformoms/global/util.java deleted file mode 100644 index b885cdc..0000000 --- a/src/main/java/com/chaewookim/accountbookformoms/global/util.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.chaewookim.accountbookformoms.global; - -public class util { -} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/util/ClientIpUtils.java b/src/main/java/com/chaewookim/accountbookformoms/global/util/ClientIpUtils.java new file mode 100644 index 0000000..514d29c --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/util/ClientIpUtils.java @@ -0,0 +1,21 @@ +package com.chaewookim.accountbookformoms.global.util; + +import jakarta.servlet.http.HttpServletRequest; + +public final class ClientIpUtils { + + private ClientIpUtils() { + } + + public static String extract(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + String first = forwarded.split(",")[0].trim(); + if (!first.isEmpty()) { + return first; + } + } + String remote = request.getRemoteAddr(); + return remote != null ? remote : "unknown"; + } +} diff --git a/src/main/java/com/chaewookim/accountbookformoms/global/util/CookieUtils.java b/src/main/java/com/chaewookim/accountbookformoms/global/util/CookieUtils.java new file mode 100644 index 0000000..9559a3a --- /dev/null +++ b/src/main/java/com/chaewookim/accountbookformoms/global/util/CookieUtils.java @@ -0,0 +1,56 @@ +package com.chaewookim.accountbookformoms.global.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.util.SerializationUtils; + +import java.util.Base64; +import java.util.Optional; + +public class CookieUtils { + + public static Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + return Optional.empty(); + } + + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + } + } + + public static String serialize(Object object) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(object)); + } + + public static T deserialize(Cookie cookie, Class cls) { + return cls.cast(SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()))); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..38a57da --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,25 @@ +spring: + datasource: + hikari: + maximum-pool-size: 5 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + +app: + cors: + allowed-origins: + - "http://localhost:5173" + - "http://127.0.0.1:5173" + - "http://localhost:5174" + - "http://127.0.0.1:5174" + - "capacitor://localhost" + - "http://localhost" + - "https://localhost" + mail: + dev-log-code: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..7f4fb6d --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,37 @@ +firebase: + credentials-path: file:/app/firebase-service-account.json + +spring: + datasource: + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: false + +logging: + level: + org.hibernate.SQL: warn + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +app: + cors: + allowed-origins: + - "https://frontend-sand-nu-34.vercel.app" + - "https://moneydiary.cloud" + - "https://admin-frontend-rho-five.vercel.app" + - "capacitor://localhost" + mail: + dev-log-code: false diff --git a/src/main/resources/application-secret.yml.example b/src/main/resources/application-secret.yml.example new file mode 100644 index 0000000..b031d37 --- /dev/null +++ b/src/main/resources/application-secret.yml.example @@ -0,0 +1,82 @@ +# Template for application-secret.yml +# 복사 후 실제 값으로 채워서 사용: +# cp src/main/resources/application-secret.yml.example src/main/resources/application-secret.yml +# 실제 파일은 .gitignore 처리되어 커밋되지 않음. + +spring: + datasource: + url: jdbc:mysql://localhost:3306/jointliving?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + username: jointliving + password: jointliving + + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: localhost + port: 6379 + cache: + type: redis + redis: + time-to-live: 3600000 # 1시간 + + mail: + password: REPLACE_WITH_RESEND_API_KEY + + security: + oauth2: + client: + registration: + google: + client-id: REPLACE_WITH_GOOGLE_CLIENT_ID + client-secret: REPLACE_WITH_GOOGLE_CLIENT_SECRET + scope: profile, email + kakao: + client-id: REPLACE_WITH_KAKAO_CLIENT_ID + client-secret: REPLACE_WITH_KAKAO_CLIENT_SECRET + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: Kakao + scope: profile_nickname, account_email + naver: + client-id: REPLACE_WITH_NAVER_CLIENT_ID + client-secret: REPLACE_WITH_NAVER_CLIENT_SECRET + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + client-name: Naver + scope: name, email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +app: + mail: + from: noreply@moneydiary.cloud + dev-log-code: true + admin: + email: REPLACE_WITH_ADMIN_EMAIL + +jwt: + # 최소 32자 base64 문자열. 예: openssl rand -base64 48 + secret: REPLACE_WITH_JWT_SECRET_MIN_32_CHARS_BASE64 + +aws: + s3: + region: ap-northeast-2 + access-key: REPLACE_WITH_AWS_ACCESS_KEY + secret-key: REPLACE_WITH_AWS_SECRET_KEY + bucket: REPLACE_WITH_S3_BUCKET_NAME + cdn: + base-url: REPLACE_WITH_CLOUDFRONT_URL diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c269bf..4f8b843 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,21 +1,84 @@ server: port: 8000 + forward-headers-strategy: native + tomcat: + threads: + max: 30 + min-spare: 5 spring: application: name: AccountBookForMoms + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + datasource: driver-class-name: com.mysql.cj.jdbc.Driver jpa: - hibernate: - ddl-auto: update - show-sql: true properties: hibernate: - format_sql: true + jdbc: + batch_size: 20 + batch_versioned_data: true + order_inserts: true + order_updates: true + default_batch_fetch_size: 100 + + elasticsearch: + uris: ${ELASTICSEARCH_URIS:http://localhost:9200} + + data: + web: + pageable: + max-page-size: 100 config: import: - - classpath:/application-secret.yml + - optional:file:/app/application-secret.yml + - optional:classpath:/application-secret.yml + + mail: + host: smtp.resend.com + port: 587 + username: resend + password: ${RESEND_API_KEY:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + +app: + mail: + from: ${MAIL_FROM:noreply@moneydiary.cloud} + oauth2: + authorized-redirect-uri: ${FRONTEND_REDIRECT_URL:com.jointliving.app://oauth2/redirect} + authorized-redirect-uri-web: ${FRONTEND_REDIRECT_URL_WEB:http://localhost:5173/oauth2/redirect} + admin-redirect-uri: ${ADMIN_REDIRECT_URL:https://admin-frontend-rho-five.vercel.app/oauth2/redirect} + admin-redirect-uri-local: ${ADMIN_REDIRECT_URL_LOCAL:http://localhost:5174/oauth2/redirect} + # 로그인 완료 후 리다이렉트 가능한 URI 화이트리스트. 이 목록에 없는 값이 들어오면 + # `authorized-redirect-uri` 기본값으로 fallback (open redirect 방어). + authorized-redirect-uris: + - http://localhost:5173/oauth2/redirect + - http://127.0.0.1:5173/oauth2/redirect + - http://localhost:5174/oauth2/redirect + - http://127.0.0.1:5174/oauth2/redirect + - https://frontend-sand-nu-34.vercel.app/oauth2/redirect + - https://moneydiary.cloud/oauth2/redirect + - https://admin-frontend-rho-five.vercel.app/oauth2/redirect + - com.jointliving.app://oauth2/redirect + +kakao: + rest-api-key: ${KAKAO_REST_API_KEY:} diff --git a/src/test/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplicationTests.java b/src/test/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplicationTests.java index 370e222..38ee784 100644 --- a/src/test/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplicationTests.java +++ b/src/test/java/com/chaewookim/accountbookformoms/AccountBookForMomsApplicationTests.java @@ -1,11 +1,26 @@ package com.chaewookim.accountbookformoms; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchQueryRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class AccountBookForMomsApplicationTests { + @MockBean + private BoardSearchRepository boardSearchRepository; + + @MockBean + private BoardSearchQueryRepository boardSearchQueryRepository; + + @MockBean + private JavaMailSender javaMailSender; + @Test void contextLoads() { } diff --git a/src/test/java/com/chaewookim/accountbookformoms/benchmark/DbIoBenchmark.java b/src/test/java/com/chaewookim/accountbookformoms/benchmark/DbIoBenchmark.java new file mode 100644 index 0000000..a9b4527 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/benchmark/DbIoBenchmark.java @@ -0,0 +1,1508 @@ +package com.chaewookim.accountbookformoms.benchmark; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.notification.dao.NotificationRepository; +import com.chaewookim.accountbookformoms.domain.user.dao.InterestCategoryRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceUnit; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.MethodOrderer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Random; + +@Tag("benchmark") +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Rollback(false) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class DbIoBenchmark { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("benchdb") + .withUsername("bench") + .withPassword("benchpw") + .withUrlParam("useSSL", "false") + .withUrlParam("allowPublicKeyRetrieval", "true") + .withUrlParam("serverTimezone", "UTC") + .withUrlParam("rewriteBatchedStatements", "true"); + + static { + MYSQL.start(); + } + + @DynamicPropertySource + static void props(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + r.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + r.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + r.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect"); + r.add("spring.jpa.properties.hibernate.jdbc.batch_size", + () -> System.getProperty("bench.batch.size", "20")); + r.add("spring.jpa.properties.hibernate.jdbc.batch_versioned_data", () -> "true"); + r.add("spring.jpa.properties.hibernate.order_inserts", () -> "true"); + r.add("spring.jpa.properties.hibernate.order_updates", () -> "true"); + r.add("spring.jpa.properties.hibernate.default_batch_fetch_size", () -> "100"); + r.add("spring.jpa.show-sql", () -> "false"); + r.add("spring.jpa.properties.hibernate.format_sql", () -> "false"); + r.add("spring.jpa.properties.hibernate.generate_statistics", () -> "true"); + // 배제 (DataJpaTest 는 기본적으로 최소한만 로드하지만 확실히 하기 위해) + r.add("spring.autoconfigure.exclude", () -> + "org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration"); + } + + @Autowired BoardRepository boardRepository; + @Autowired CommentRepository commentRepository; + @Autowired NotificationRepository notificationRepository; + @Autowired InterestCategoryRepository interestCategoryRepository; + @Autowired FixedTransactionRepository fixedTransactionRepository; + @Autowired PlatformTransactionManager txManager; + @PersistenceContext EntityManager em; + @PersistenceUnit EntityManagerFactory emf; + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + private void resetStats() { + Statistics s = stats(); + s.setStatisticsEnabled(true); + s.clear(); + } + + private long queryCount() { + // prepared statements (each SQL execute) + return stats().getPrepareStatementCount(); + } + + static final int BOARD_COUNT = 10_000; + static final int COMMENT_COUNT = 100_000; + static final int USER_COUNT = 100; + + // R3~R5, U2 시드 규모 + static final int NOTIF_USER_COUNT = 100; + static final int NOTIF_PER_USER = 200; // R3: 100 * 200 = 20,000 + static final int USER_DEVICE_COUNT = 10_000; // R4: user_id 는 1..10000 랜덤(FK 없음) + static final int ACCOUNT_USER_COUNT = 100; + static final int ACCOUNT_PER_USER = 30; // R5: 100 * 30 = 3,000 + static final int U2_USER_COUNT = 200; // U2: 200 유저 각 200 알림 = 40,000 + static final int U2_NOTIF_PER_USER = 200; + // U2 유저는 R3 시드 범위 이후로 배정 (충돌 방지) + static final long U2_USER_ID_BASE = 1000L; // 1000 ~ 1199 + + // R6, R7 시드 규모 + static final int GP_COUNT = 10_000; // R6: group_purchase 10,000 rows + static final int GP_CATEGORY_MAX = 50; // category_id 1..50 + static final int GP_CREATOR_MAX = 100; // creator_id 1..100 + static final int TXCAT_USER_COUNT = 100; // R7: 100 users + static final int TXCAT_PER_USER = 20; // R7: 100 * 20 = 2,000 rows + // R3 별도 알림 시드에서 사용할 user_id 범위: 1..100 (Board 시드와 겹쳐도 무관, FK 없음) + + // U3, U4 시드 규모 + static final int U3_USER_COUNT = 60; // U3: user 1..60, each 100 interest_category = 6,000 rows + static final int U3_IC_PER_USER = 100; + static final int GP_CATEGORY_SEED_COUNT = 20; // group_purchase_category 20 rows + static final int U4_ACCOUNT_COUNT = 60; // U4: 60 accounts (신규, R5 account 와 별개) + static final int U4_FIXED_PER_ACCOUNT = 100; // 60 * 100 = 6,000 fixed_transaction rows + static final int U4_TXCAT_MAX = 20; // transaction_category_id 1..20 재사용 (R7 시드) + // U4 accounts 를 user_id 1..60 에 걸쳐 시드. account_id 는 auto-increment (R5 의 3000 이후로 배정됨) + + static final Path CSV_PATH = + Paths.get("docs/benchmark/results/2026-08-05_db_io.csv"); + + @BeforeAll + void seed() throws IOException { + System.out.println("=== [BENCH] Testcontainers MySQL URL: " + MYSQL.getJdbcUrl()); + System.out.println("=== [BENCH] hibernate.jdbc.batch_size = " + + System.getProperty("bench.batch.size", "20")); + + ensureCsvHeader(); + + // 시드는 batch=1 실행일 때는 스킵 (이미 첫 실행에서 seed 됐다고 가정하지 않고, + // 매 실행마다 새 컨테이너이므로 항상 시드해야 함). 실제로 컨테이너는 매 JVM 마다 새로 생김. + long boardExisting = boardRepository.count(); + if (boardExisting >= BOARD_COUNT) { + System.out.println("=== [BENCH] Seed skipped (board rows already " + boardExisting + ")"); + return; + } + + long t0 = System.currentTimeMillis(); + seedUsers(); + long tU = System.currentTimeMillis(); + System.out.println("=== [BENCH] User seed done: " + (tU - t0) + " ms"); + + seedBoards(); + long t1 = System.currentTimeMillis(); + System.out.println("=== [BENCH] Board seed done: " + (t1 - tU) + " ms"); + + seedComments(); + long t2 = System.currentTimeMillis(); + System.out.println("=== [BENCH] Comment seed done: " + (t2 - t1) + " ms"); + + seedNotifications(); + long t3 = System.currentTimeMillis(); + System.out.println("=== [BENCH] Notification seed done: " + (t3 - t2) + " ms"); + + seedUserDevices(); + long t4 = System.currentTimeMillis(); + System.out.println("=== [BENCH] UserDevice seed done: " + (t4 - t3) + " ms"); + + seedAccounts(); + long t5 = System.currentTimeMillis(); + System.out.println("=== [BENCH] Account seed done: " + (t5 - t4) + " ms"); + + seedU2Notifications(); + long t6 = System.currentTimeMillis(); + System.out.println("=== [BENCH] U2 Notification seed done: " + (t6 - t5) + " ms"); + + seedGroupPurchases(); + long t7 = System.currentTimeMillis(); + System.out.println("=== [BENCH] GroupPurchase seed done: " + (t7 - t6) + " ms"); + + seedTransactionCategories(); + long t8 = System.currentTimeMillis(); + System.out.println("=== [BENCH] TransactionCategory seed done: " + (t8 - t7) + " ms"); + + seedGpCategories(); + long t9 = System.currentTimeMillis(); + System.out.println("=== [BENCH] GpCategory seed done: " + (t9 - t8) + " ms"); + + seedInterestCategories(); + long t10 = System.currentTimeMillis(); + System.out.println("=== [BENCH] InterestCategory seed done: " + (t10 - t9) + " ms"); + + seedU4AccountsAndFixedTx(); + long t11 = System.currentTimeMillis(); + System.out.println("=== [BENCH] U4 Account + FixedTransaction seed done: " + (t11 - t10) + " ms"); + + // ANALYZE + runInTx(() -> { + em.createNativeQuery("ANALYZE TABLE board").getResultList(); + em.createNativeQuery("ANALYZE TABLE comment").getResultList(); + em.createNativeQuery("ANALYZE TABLE notification").getResultList(); + em.createNativeQuery("ANALYZE TABLE user_device").getResultList(); + em.createNativeQuery("ANALYZE TABLE account").getResultList(); + em.createNativeQuery("ANALYZE TABLE group_purchase").getResultList(); + em.createNativeQuery("ANALYZE TABLE transaction_category").getResultList(); + em.createNativeQuery("ANALYZE TABLE group_purchase_category").getResultList(); + em.createNativeQuery("ANALYZE TABLE interest_category").getResultList(); + em.createNativeQuery("ANALYZE TABLE fixed_transaction").getResultList(); + }); + System.out.println("=== [BENCH] ANALYZE done"); + } + + // R6 시드: group_purchase 10,000 rows + // - status: PurchaseStatus enum (RECRUITING/SUCCESS/FAILED/CLOSED 균등) + // - category_id 1..50, creator_id 1..100, deadline 랜덤 미래 + // creator_id 는 raw Long (FK 없음) 이므로 1..100 이면 됨 + private void seedGroupPurchases() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + Random rnd = new Random(606); + String[] statuses = {"RECRUITING", "SUCCESS", "FAILED", "CLOSED"}; + for (int i = 1; i <= GP_COUNT; i++) { + long creatorId = rnd.nextInt(GP_CREATOR_MAX) + 1; + long categoryId = rnd.nextInt(GP_CATEGORY_MAX) + 1; + String status = statuses[i % 4]; + // deadline: now + random(1..365) days + int daysAhead = rnd.nextInt(365) + 1; + int price = 1000 + rnd.nextInt(50000); + int minP = 2 + rnd.nextInt(5); + int maxP = minP + 5 + rnd.nextInt(20); + em.createNativeQuery( + "INSERT INTO group_purchase " + + "(creator_id, category_id, title, content, price, min_participants, " + + " max_participants, current_participants, status, deadline, pickup_location, " + + " view_count, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, DATE_ADD(NOW(), INTERVAL ? DAY), ?, 0, NOW(), NOW())") + .setParameter(1, creatorId) + .setParameter(2, categoryId) + .setParameter(3, "gp-title-" + i) + .setParameter(4, "gp-content-" + i) + .setParameter(5, price) + .setParameter(6, minP) + .setParameter(7, maxP) + .setParameter(8, status) + .setParameter(9, daysAhead) + .setParameter(10, "pickup-loc-" + (i % 20)) + .executeUpdate(); + if (i % 1000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... group_purchases inserted " + i); + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // R7 시드: transaction_category 2,000 rows + // - user_id 1..100 (FK: user 테이블. 이미 seedUsers 로 1..1200 시드됨) + // - name = "cat_{seq}", seq 0..19 (per user) + // - type = INCOME/EXPENSE/TRANSFER 순환 + private void seedTransactionCategories() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + String[] types = {"INCOME", "EXPENSE", "TRANSFER"}; + int total = 0; + for (long uid = 1; uid <= TXCAT_USER_COUNT; uid++) { + for (int k = 0; k < TXCAT_PER_USER; k++) { + String type = types[(int) ((uid + k) % 3)]; + em.createNativeQuery( + "INSERT INTO transaction_category " + + "(user_id, name, type, created_at, updated_at) " + + "VALUES (?, ?, ?, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, "cat_" + k) + .setParameter(3, type) + .executeUpdate(); + total++; + if (total % 500 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... transaction_categories inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // U3 지원: group_purchase_category 20 rows (InterestCategory.category_id FK) + private void seedGpCategories() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + for (int i = 1; i <= GP_CATEGORY_SEED_COUNT; i++) { + em.createNativeQuery( + "INSERT INTO group_purchase_category " + + "(name, sort_order, description, created_at, updated_at) " + + "VALUES (?, ?, ?, NOW(), NOW())") + .setParameter(1, "gp-cat-" + i) + .setParameter(2, i) + .setParameter(3, "gp-cat-desc-" + i) + .executeUpdate(); + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // U3 시드: interest_category = 60 users * 100 = 6,000 rows + // user_id 1..60, category_id 1..20 순환 + private void seedInterestCategories() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + int total = 0; + for (long uid = 1; uid <= U3_USER_COUNT; uid++) { + for (int k = 0; k < U3_IC_PER_USER; k++) { + long catId = (k % GP_CATEGORY_SEED_COUNT) + 1; + em.createNativeQuery( + "INSERT INTO interest_category " + + "(user_id, category_id, is_alarm_enabled, created_at, updated_at) " + + "VALUES (?, ?, false, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, catId) + .executeUpdate(); + total++; + if (total % 2000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... interest_categories inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // U4 시드: + // 1) account 60개 신규 (user_id 1..60, R5 accounts 뒤에 auto-increment 로 배정) + // → 배정된 account_id 를 List 로 수집해 상수로 노출 + // 2) 각 account 마다 fixed_transaction 100건 → 6,000 rows + // transaction_category_id 1..20 (R7 시드 재사용) + private final List u4AccountIds = new ArrayList<>(); + private void seedU4AccountsAndFixedTx() { + // 1) accounts 신규 60개 + { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + for (int i = 0; i < U4_ACCOUNT_COUNT; i++) { + long uid = i + 1; // user_id 1..60 + Object idObj = em.createNativeQuery( + "INSERT INTO account (user_id, account_name, initial_balance, current_balance, role, kind, " + + "goal_achieved_notified, created_at, updated_at) " + + "VALUES (?, ?, 0, 0, 'CHECKING', 'ASSET', false, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, "u4-acc-" + uid) + .executeUpdate(); + // executeUpdate() 는 int (row count) 반환. auto-increment 값을 얻기 위해 별도 SELECT. + Object lastId = em.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult(); + u4AccountIds.add(((Number) lastId).longValue()); + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + System.out.println(" ... U4 accounts seeded, ids=" + u4AccountIds.get(0) + ".." + u4AccountIds.get(u4AccountIds.size()-1)); + } + // 2) fixed_transaction 6000건 + { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + String[] types = {"INCOME", "EXPENSE", "TRANSFER"}; + int total = 0; + for (int i = 0; i < u4AccountIds.size(); i++) { + long accountId = u4AccountIds.get(i); + long uid = i + 1; // 매칭된 owner user_id + for (int k = 0; k < U4_FIXED_PER_ACCOUNT; k++) { + long catId = (k % U4_TXCAT_MAX) + 1; // 1..20 (R7 시드 - user_id 1..100 에 걸쳐 존재) + String type = types[k % 3]; + em.createNativeQuery( + "INSERT INTO fixed_transaction " + + "(user_id, account_id, category_id, type, amount, frequency, repeat_day, " + + " repeat_month, start_date, end_date, last_executed_date, next_execution_date, " + + " description, is_active, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, 'MONTHLY', 1, NULL, CURDATE(), NULL, NULL, " + + " DATE_ADD(CURDATE(), INTERVAL 30 DAY), ?, true, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, accountId) + .setParameter(3, catId) + .setParameter(4, type) + .setParameter(5, 1000 + k) + .setParameter(6, "u4-ft-" + accountId + "-" + k) + .executeUpdate(); + total++; + if (total % 2000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... fixed_transactions inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + } + + // User 필수 컬럼: email (unique, notnull), username (notnull), role (notnull enum), + // provider (notnull enum), created_at, updated_at (BaseEntity, notnull) + // U2 유저까지 포함해 넉넉히 시드 (id 1..1300 커버). identity PK 라 AUTO_INCREMENT 로 순차 배정됨. + private void seedUsers() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + // 필요한 최대 user_id = max(NOTIF_USER_COUNT, ACCOUNT_USER_COUNT, U2_USER_ID_BASE + U2_USER_COUNT) + int maxNeeded = (int) Math.max( + Math.max(NOTIF_USER_COUNT, ACCOUNT_USER_COUNT), + U2_USER_ID_BASE + U2_USER_COUNT); + // Board 시드와 겹치지만 FK 없어 무관. UserDevice 는 raw user_id 1..10000 랜덤이라 굳이 다 만들 필요 X. + for (int i = 1; i <= maxNeeded; i++) { + em.createNativeQuery( + "INSERT INTO user (email, password, username, role, provider, created_at, updated_at) " + + "VALUES (?, ?, ?, 'ROLE_USER', 'LOCAL', NOW(), NOW())") + .setParameter(1, "bench-" + i + "@example.com") + .setParameter(2, "pw") + .setParameter(3, "user-" + i) + .executeUpdate(); + if (i % 500 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... users inserted " + i); + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void seedNotifications() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + int total = 0; + for (long uid = 1; uid <= NOTIF_USER_COUNT; uid++) { + for (int k = 0; k < NOTIF_PER_USER; k++) { + em.createNativeQuery( + "INSERT INTO notification (user_id, type, title, message, is_read, created_at, updated_at) " + + "VALUES (?, 'SYSTEM', ?, ?, false, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, "n-title-" + uid + "-" + k) + .setParameter(3, "n-msg-" + uid + "-" + k) + .executeUpdate(); + total++; + if (total % 5000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... notifications inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void seedUserDevices() { + // NOTE: user_device.user_id 에 실제 JPA FK (FK_user_device_user) 가 생성되므로 + // 시드된 user 범위 (1..seedUsers 최대값) 내에서 랜덤 배정한다. + int maxUserId = (int) Math.max( + Math.max(NOTIF_USER_COUNT, ACCOUNT_USER_COUNT), + U2_USER_ID_BASE + U2_USER_COUNT); + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + Random rnd = new Random(1337); + for (int i = 1; i <= USER_DEVICE_COUNT; i++) { + long uid = rnd.nextInt(maxUserId) + 1; + em.createNativeQuery( + "INSERT INTO user_device (user_id, fcm_token) VALUES (?, ?)") + .setParameter(1, uid) + .setParameter(2, "fcm-token-" + i) + .executeUpdate(); + if (i % 2000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... user_devices inserted " + i); + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void seedAccounts() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + int total = 0; + for (long uid = 1; uid <= ACCOUNT_USER_COUNT; uid++) { + for (int k = 0; k < ACCOUNT_PER_USER; k++) { + em.createNativeQuery( + "INSERT INTO account (user_id, account_name, initial_balance, current_balance, role, kind, " + + "goal_achieved_notified, created_at, updated_at) " + + "VALUES (?, ?, 0, 0, 'CHECKING', 'ASSET', false, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, "acc-" + uid + "-" + k) + .executeUpdate(); + total++; + if (total % 1000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... accounts inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // U2 전용: user_id 1000..1199 각 사용자당 200 알림 + private void seedU2Notifications() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + int total = 0; + for (int u = 0; u < U2_USER_COUNT; u++) { + long uid = U2_USER_ID_BASE + u; + for (int k = 0; k < U2_NOTIF_PER_USER; k++) { + em.createNativeQuery( + "INSERT INTO notification (user_id, type, title, message, is_read, created_at, updated_at) " + + "VALUES (?, 'SYSTEM', ?, ?, false, NOW(), NOW())") + .setParameter(1, uid) + .setParameter(2, "u2-title-" + uid + "-" + k) + .setParameter(3, "u2-msg-" + uid + "-" + k) + .executeUpdate(); + total++; + if (total % 5000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... u2_notifications inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void seedBoards() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + Random rnd = new Random(42); + String[] types = {"QNA", "KNOWHOW"}; + for (int i = 1; i <= BOARD_COUNT; i++) { + long userId = (i % USER_COUNT) + 1; + String type = types[i % 2]; + String title = "title-" + i + "-" + rnd.nextInt(100000); + String content = "content-body-" + rnd.nextInt(1_000_000); + em.createNativeQuery( + "INSERT INTO board (user_id, category_id, title, content, type, views, " + + "admin_deleted, is_resolved, is_urgent, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, ?, 0, false, false, false, NOW(), NOW())") + .setParameter(1, userId) + .setParameter(2, title) + .setParameter(3, content) + .setParameter(4, type) + .executeUpdate(); + if (i % 1000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... boards inserted " + i); + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void seedComments() { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + Random rnd = new Random(7); + int perBoard = COMMENT_COUNT / BOARD_COUNT; // 10 + int total = 0; + for (long boardId = 1; boardId <= BOARD_COUNT; boardId++) { + for (int k = 0; k < perBoard; k++) { + long userId = rnd.nextInt(USER_COUNT) + 1; + String content = "c-" + boardId + "-" + k; + em.createNativeQuery( + "INSERT INTO comment (user_id, reference_id, reference_type, parent_id, " + + "content, admin_deleted, is_accepted, created_at, updated_at) " + + "VALUES (?, ?, 'QNA', NULL, ?, false, false, NOW(), NOW())") + .setParameter(1, userId) + .setParameter(2, boardId) + .setParameter(3, content) + .executeUpdate(); + total++; + if (total % 5000 == 0) { + em.flush(); + em.clear(); + System.out.println(" ... comments inserted " + total); + } + } + } + em.flush(); + em.clear(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void runInTx(Runnable r) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + r.run(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // ============================================================ + // R1: Board index (user_id, type) + // ============================================================ + @Test + @Order(1) + void r1_boardIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R1")) { + System.out.println("=== [BENCH] R1 skipped"); + return; + } + System.out.println("\n=== [BENCH] R1 Board index effect ==="); + + // EXPLAIN + runInTx(() -> { + System.out.println("[R1] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM board WHERE user_id=1 AND type='QNA' LIMIT 100"); + System.out.println("[R1] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM board IGNORE INDEX (idx_board_user_type, idx_board_type) " + + "WHERE user_id=1 AND type='QNA' LIMIT 100"); + }); + + Random rnd = new Random(101); + Result idxRes = benchQuery( + "SELECT * FROM board WHERE user_id=?1 AND type=?2 LIMIT 100", + rnd, 20, 100); + Result scanRes = benchQuery( + "SELECT * FROM board IGNORE INDEX (idx_board_user_type, idx_board_type) " + + "WHERE user_id=?1 AND type=?2 LIMIT 100", + new Random(101), 20, 100); + + appendCsv("R1", "index_used", "-", idxRes); + appendCsv("R1", "full_scan", "-", scanRes); + printResult("R1 index_used", idxRes); + printResult("R1 full_scan", scanRes); + } + + private Result benchQuery(String sql, Random rnd, int warmup, int iters) { + String[] types = {"QNA", "KNOWHOW"}; + // warmup + for (int i = 0; i < warmup; i++) { + long uid = rnd.nextInt(USER_COUNT) + 1; + String t = types[i % 2]; + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, uid) + .setParameter(2, t) + .getResultList()); + } + resetStats(); + long[] samples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = rnd.nextInt(USER_COUNT) + 1; + String t = types[i % 2]; + long t0 = System.nanoTime(); + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, uid) + .setParameter(2, t) + .getResultList()); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + return Result.of(samples, sqlCount); + } + + // ============================================================ + // R2: Comment index (user_id, created_at) + // ============================================================ + @Test + @Order(2) + void r2_commentIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R2")) { + System.out.println("=== [BENCH] R2 skipped"); + return; + } + System.out.println("\n=== [BENCH] R2 Comment index effect ==="); + + runInTx(() -> { + System.out.println("[R2] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM comment WHERE user_id=1 ORDER BY created_at DESC LIMIT 20"); + System.out.println("[R2] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM comment IGNORE INDEX (idx_comment_user) " + + "WHERE user_id=1 ORDER BY created_at DESC LIMIT 20"); + }); + + Random rnd = new Random(202); + Result idxRes = benchCommentQuery( + "SELECT * FROM comment WHERE user_id=?1 ORDER BY created_at DESC LIMIT 20", + rnd, 20, 100); + Result scanRes = benchCommentQuery( + "SELECT * FROM comment IGNORE INDEX (idx_comment_user) " + + "WHERE user_id=?1 ORDER BY created_at DESC LIMIT 20", + new Random(202), 20, 100); + + appendCsv("R2", "index_used", "-", idxRes); + appendCsv("R2", "full_scan", "-", scanRes); + printResult("R2 index_used", idxRes); + printResult("R2 full_scan", scanRes); + } + + private Result benchCommentQuery(String sql, Random rnd, int warmup, int iters) { + for (int i = 0; i < warmup; i++) { + long uid = rnd.nextInt(USER_COUNT) + 1; + runInTx(() -> em.createNativeQuery(sql).setParameter(1, uid).getResultList()); + } + resetStats(); + long[] samples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = rnd.nextInt(USER_COUNT) + 1; + long t0 = System.nanoTime(); + runInTx(() -> em.createNativeQuery(sql).setParameter(1, uid).getResultList()); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + return Result.of(samples, sqlCount); + } + + // ============================================================ + // U1: JDBC batch effect on updates + // ============================================================ + @Test + @Order(3) + void u1_jdbcBatch() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("U1")) { + System.out.println("=== [BENCH] U1 skipped"); + return; + } + int batchSize = Integer.parseInt(System.getProperty("bench.batch.size", "20")); + System.out.println("\n=== [BENCH] U1 JDBC batch effect (batch_size=" + batchSize + ") ==="); + + // warmup 5 + for (int i = 0; i < 5; i++) runU1Once(i); + resetStats(); + long[] samples = new long[20]; + for (int i = 0; i < 20; i++) { + long t0 = System.nanoTime(); + runU1Once(i + 1000); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + Result res = Result.of(samples, sqlCount); + appendCsv("U1", "batch", String.valueOf(batchSize), res); + printResult("U1 batch_size=" + batchSize, res); + } + + private void runU1Once(int seed) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + List ids = new ArrayList<>(200); + for (long id = 1; id <= 200; id++) ids.add(id); + List comments = commentRepository.findAllById(ids); + for (int j = 0; j < comments.size(); j++) { + comments.get(j).update("bench-" + seed + "-" + j); + } + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // ============================================================ + // R3: Notification (user_id) 인덱스 + // ============================================================ + @Test + @Order(4) + void r3_notificationIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R3")) { + System.out.println("=== [BENCH] R3 skipped"); + return; + } + System.out.println("\n=== [BENCH] R3 Notification index effect ==="); + + runInTx(() -> { + System.out.println("[R3] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM notification WHERE user_id=1 ORDER BY id DESC LIMIT 20"); + System.out.println("[R3] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM notification IGNORE INDEX (idx_notification_user) " + + "WHERE user_id=1 ORDER BY id DESC LIMIT 20"); + }); + + Random rnd = new Random(303); + Result idxRes = benchSimpleUserQuery( + "SELECT * FROM notification WHERE user_id=?1 ORDER BY id DESC LIMIT 20", + rnd, 20, 100, NOTIF_USER_COUNT); + Result scanRes = benchSimpleUserQuery( + "SELECT * FROM notification IGNORE INDEX (idx_notification_user) " + + "WHERE user_id=?1 ORDER BY id DESC LIMIT 20", + new Random(303), 20, 100, NOTIF_USER_COUNT); + + appendCsv("R3", "index_used", "-", idxRes); + appendCsv("R3", "full_scan", "-", scanRes); + printResult("R3 index_used", idxRes); + printResult("R3 full_scan", scanRes); + } + + // ============================================================ + // R4: UserDevice (user_id) 인덱스 + // ============================================================ + @Test + @Order(5) + void r4_userDeviceIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R4")) { + System.out.println("=== [BENCH] R4 skipped"); + return; + } + System.out.println("\n=== [BENCH] R4 UserDevice index effect ==="); + + runInTx(() -> { + System.out.println("[R4] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM user_device WHERE user_id=1"); + System.out.println("[R4] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM user_device IGNORE INDEX (idx_userdevice_user) WHERE user_id=1"); + }); + + // 실제 user_device 는 user_id 1..1200 (seedUsers 최대) 범위 로 시드됨. + int userSpace = (int) Math.max( + Math.max(NOTIF_USER_COUNT, ACCOUNT_USER_COUNT), + U2_USER_ID_BASE + U2_USER_COUNT); + Random rnd = new Random(404); + Result idxRes = benchSimpleUserQuery( + "SELECT * FROM user_device WHERE user_id=?1", + rnd, 20, 100, userSpace); + Result scanRes = benchSimpleUserQuery( + "SELECT * FROM user_device IGNORE INDEX (idx_userdevice_user) WHERE user_id=?1", + new Random(404), 20, 100, userSpace); + + appendCsv("R4", "index_used", "-", idxRes); + appendCsv("R4", "full_scan", "-", scanRes); + printResult("R4 index_used", idxRes); + printResult("R4 full_scan", scanRes); + } + + // ============================================================ + // R5: Account (user_id) 인덱스 + // ============================================================ + @Test + @Order(6) + void r5_accountIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R5")) { + System.out.println("=== [BENCH] R5 skipped"); + return; + } + System.out.println("\n=== [BENCH] R5 Account index effect ==="); + + runInTx(() -> { + System.out.println("[R5] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM account WHERE user_id=1"); + System.out.println("[R5] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM account IGNORE INDEX (idx_account_user, idx_account_user_name) " + + "WHERE user_id=1"); + }); + + Random rnd = new Random(505); + Result idxRes = benchSimpleUserQuery( + "SELECT * FROM account WHERE user_id=?1", + rnd, 20, 100, ACCOUNT_USER_COUNT); + Result scanRes = benchSimpleUserQuery( + "SELECT * FROM account IGNORE INDEX (idx_account_user, idx_account_user_name) " + + "WHERE user_id=?1", + new Random(505), 20, 100, ACCOUNT_USER_COUNT); + + appendCsv("R5", "index_used", "-", idxRes); + appendCsv("R5", "full_scan", "-", scanRes); + printResult("R5 index_used", idxRes); + printResult("R5 full_scan", scanRes); + } + + // 단일 user_id 파라미터 쿼리 벤치 공통 + private Result benchSimpleUserQuery(String sql, Random rnd, int warmup, int iters, int userSpace) { + for (int i = 0; i < warmup; i++) { + long uid = rnd.nextInt(userSpace) + 1; + runInTx(() -> em.createNativeQuery(sql).setParameter(1, uid).getResultList()); + } + resetStats(); + long[] samples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = rnd.nextInt(userSpace) + 1; + long t0 = System.nanoTime(); + runInTx(() -> em.createNativeQuery(sql).setParameter(1, uid).getResultList()); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + return Result.of(samples, sqlCount); + } + + // ============================================================ + // U2: Notification bulk soft-delete (H1 핵심) + // before: 200 rows 를 개별 UPDATE 200회 (옛 findAll + forEach delete 시뮬) + // after : softDeleteByUserId 단일 벌크 UPDATE + // 매 iteration 마다 새 user 사용 (warmup 3 + 측정 10) * 2 variant = 26 users 소진. + // ============================================================ + @Test + @Order(7) + void u2_notificationBulkDelete() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("U2")) { + System.out.println("=== [BENCH] U2 skipped"); + return; + } + System.out.println("\n=== [BENCH] U2 Notification bulk soft-delete ==="); + + int warmup = 3; + int iters = 10; + + // 사용자 인덱스 카운터: warmup 부터 순차 소진 + int[] userCursor = {0}; + + // ----- BEFORE: individual UPDATE per row ----- + for (int i = 0; i < warmup; i++) { + long uid = U2_USER_ID_BASE + userCursor[0]++; + runU2BeforeOnce(uid); + } + resetStats(); + long[] beforeSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = U2_USER_ID_BASE + userCursor[0]++; + long t0 = System.nanoTime(); + runU2BeforeOnce(uid); + beforeSamples[i] = System.nanoTime() - t0; + } + long beforeSql = queryCount(); + Result beforeRes = Result.of(beforeSamples, beforeSql); + appendCsv("U2", "individual_update", "-", beforeRes); + printResult("U2 individual_update", beforeRes); + + // ----- AFTER: single bulk UPDATE via repository ----- + for (int i = 0; i < warmup; i++) { + long uid = U2_USER_ID_BASE + userCursor[0]++; + runU2AfterOnce(uid); + } + resetStats(); + long[] afterSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = U2_USER_ID_BASE + userCursor[0]++; + long t0 = System.nanoTime(); + runU2AfterOnce(uid); + afterSamples[i] = System.nanoTime() - t0; + } + long afterSql = queryCount(); + Result afterRes = Result.of(afterSamples, afterSql); + appendCsv("U2", "bulk_update", "-", afterRes); + printResult("U2 bulk_update", afterRes); + } + + private void runU2BeforeOnce(long userId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + // 1) SELECT ids for user (한 번의 조회) + @SuppressWarnings("unchecked") + List ids = em.createNativeQuery( + "SELECT id FROM notification WHERE user_id=?1 AND deleted_at IS NULL") + .setParameter(1, userId) + .getResultList(); + // 2) 각 row 개별 UPDATE + for (Number idNum : ids) { + em.createNativeQuery( + "UPDATE notification SET deleted_at=NOW() WHERE id=?1") + .setParameter(1, idNum.longValue()) + .executeUpdate(); + } + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void runU2AfterOnce(long userId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + notificationRepository.softDeleteByUserId(userId); + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // ============================================================ + // R6: GroupPurchase (status, category_id, deadline) 인덱스 + // 쿼리: WHERE status=? AND category_id=? ORDER BY deadline ASC LIMIT 20 + // ============================================================ + @Test + @Order(8) + void r6_groupPurchaseIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R6")) { + System.out.println("=== [BENCH] R6 skipped"); + return; + } + System.out.println("\n=== [BENCH] R6 GroupPurchase index effect ==="); + + runInTx(() -> { + System.out.println("[R6] EXPLAIN index_used:"); + explain("EXPLAIN SELECT * FROM group_purchase WHERE status='RECRUITING' AND category_id=1 " + + "ORDER BY deadline ASC LIMIT 20"); + System.out.println("[R6] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT * FROM group_purchase IGNORE INDEX (idx_gp_status_category_deadline) " + + "WHERE status='RECRUITING' AND category_id=1 ORDER BY deadline ASC LIMIT 20"); + }); + + Random rnd = new Random(606); + Result idxRes = benchGpQuery( + "SELECT * FROM group_purchase WHERE status=?1 AND category_id=?2 " + + "ORDER BY deadline ASC LIMIT 20", + rnd, 20, 100); + Result scanRes = benchGpQuery( + "SELECT * FROM group_purchase IGNORE INDEX (idx_gp_status_category_deadline) " + + "WHERE status=?1 AND category_id=?2 ORDER BY deadline ASC LIMIT 20", + new Random(606), 20, 100); + + appendCsv("R6", "index_used", "-", idxRes); + appendCsv("R6", "full_scan", "-", scanRes); + printResult("R6 index_used", idxRes); + printResult("R6 full_scan", scanRes); + } + + // status 는 RECRUITING 위주로 편중 (실제 조회 패턴) + private Result benchGpQuery(String sql, Random rnd, int warmup, int iters) { + // RECRUITING 를 50% 나머지 나눔 + String[] statuses = {"RECRUITING", "RECRUITING", "SUCCESS", "FAILED", "CLOSED"}; + for (int i = 0; i < warmup; i++) { + String s = statuses[rnd.nextInt(statuses.length)]; + long catId = rnd.nextInt(GP_CATEGORY_MAX) + 1; + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, s) + .setParameter(2, catId) + .getResultList()); + } + resetStats(); + long[] samples = new long[iters]; + for (int i = 0; i < iters; i++) { + String s = statuses[rnd.nextInt(statuses.length)]; + long catId = rnd.nextInt(GP_CATEGORY_MAX) + 1; + long t0 = System.nanoTime(); + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, s) + .setParameter(2, catId) + .getResultList()); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + return Result.of(samples, sqlCount); + } + + // ============================================================ + // R7: TransactionCategory (user_id, name, type) 인덱스 + // 쿼리: SELECT 1 FROM transaction_category + // WHERE user_id=? AND name=? AND type=? LIMIT 1 + // ============================================================ + @Test + @Order(9) + void r7_transactionCategoryIndex() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("R7")) { + System.out.println("=== [BENCH] R7 skipped"); + return; + } + System.out.println("\n=== [BENCH] R7 TransactionCategory exists index effect ==="); + + runInTx(() -> { + System.out.println("[R7] EXPLAIN index_used:"); + explain("EXPLAIN SELECT 1 FROM transaction_category " + + "WHERE user_id=1 AND name='cat_0' AND type='INCOME' LIMIT 1"); + System.out.println("[R7] EXPLAIN full_scan:"); + explain("EXPLAIN SELECT 1 FROM transaction_category IGNORE INDEX (idx_txcategory_user_name_type) " + + "WHERE user_id=1 AND name='cat_0' AND type='INCOME' LIMIT 1"); + }); + + Random rnd = new Random(707); + Result idxRes = benchTxCatQuery( + "SELECT 1 FROM transaction_category " + + "WHERE user_id=?1 AND name=?2 AND type=?3 LIMIT 1", + rnd, 20, 100); + Result scanRes = benchTxCatQuery( + "SELECT 1 FROM transaction_category IGNORE INDEX (idx_txcategory_user_name_type) " + + "WHERE user_id=?1 AND name=?2 AND type=?3 LIMIT 1", + new Random(707), 20, 100); + + appendCsv("R7", "index_used", "-", idxRes); + appendCsv("R7", "full_scan", "-", scanRes); + printResult("R7 index_used", idxRes); + printResult("R7 full_scan", scanRes); + } + + private Result benchTxCatQuery(String sql, Random rnd, int warmup, int iters) { + String[] types = {"INCOME", "EXPENSE", "TRANSFER"}; + for (int i = 0; i < warmup; i++) { + long uid = rnd.nextInt(TXCAT_USER_COUNT) + 1; + String name = "cat_" + rnd.nextInt(TXCAT_PER_USER); + String type = types[rnd.nextInt(types.length)]; + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, uid) + .setParameter(2, name) + .setParameter(3, type) + .getResultList()); + } + resetStats(); + long[] samples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = rnd.nextInt(TXCAT_USER_COUNT) + 1; + String name = "cat_" + rnd.nextInt(TXCAT_PER_USER); + String type = types[rnd.nextInt(types.length)]; + long t0 = System.nanoTime(); + runInTx(() -> em.createNativeQuery(sql) + .setParameter(1, uid) + .setParameter(2, name) + .setParameter(3, type) + .getResultList()); + samples[i] = System.nanoTime() - t0; + } + long sqlCount = queryCount(); + return Result.of(samples, sqlCount); + } + + // ============================================================ + // U3: InterestCategory bulk soft-delete (D8 확장) + // before: 100 rows 개별 UPDATE + // after : softDeleteByUserId 단일 벌크 UPDATE + // 매 iteration 마다 새 user 사용 (warmup 3 + 측정 10) * 2 variant = 26 users 소진. + // ============================================================ + @Test + @Order(10) + void u3_interestCategoryBulkDelete() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("U3")) { + System.out.println("=== [BENCH] U3 skipped"); + return; + } + System.out.println("\n=== [BENCH] U3 InterestCategory bulk soft-delete ==="); + + int warmup = 3; + int iters = 10; + + // U3 시드는 user_id 1..60 사용. userCursor 는 1부터 시작 (0 아님). + int[] userCursor = {1}; + + // ----- BEFORE ----- + for (int i = 0; i < warmup; i++) { + long uid = userCursor[0]++; + runU3BeforeOnce(uid); + } + resetStats(); + long[] beforeSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = userCursor[0]++; + long t0 = System.nanoTime(); + runU3BeforeOnce(uid); + beforeSamples[i] = System.nanoTime() - t0; + } + long beforeSql = queryCount(); + Result beforeRes = Result.of(beforeSamples, beforeSql); + appendCsv("U3", "individual_update", "-", beforeRes); + printResult("U3 individual_update", beforeRes); + + // ----- AFTER ----- + for (int i = 0; i < warmup; i++) { + long uid = userCursor[0]++; + runU3AfterOnce(uid); + } + resetStats(); + long[] afterSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long uid = userCursor[0]++; + long t0 = System.nanoTime(); + runU3AfterOnce(uid); + afterSamples[i] = System.nanoTime() - t0; + } + long afterSql = queryCount(); + Result afterRes = Result.of(afterSamples, afterSql); + appendCsv("U3", "bulk_update", "-", afterRes); + printResult("U3 bulk_update", afterRes); + } + + private void runU3BeforeOnce(long userId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + @SuppressWarnings("unchecked") + List ids = em.createNativeQuery( + "SELECT id FROM interest_category WHERE user_id=?1 AND deleted_at IS NULL") + .setParameter(1, userId) + .getResultList(); + for (Number idNum : ids) { + em.createNativeQuery( + "UPDATE interest_category SET deleted_at=NOW() WHERE id=?1") + .setParameter(1, idNum.longValue()) + .executeUpdate(); + } + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void runU3AfterOnce(long userId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + interestCategoryRepository.softDeleteByUserId(userId); + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // ============================================================ + // U4: FixedTransaction by account_id bulk soft-delete (D8 확장) + // before: 100 rows 개별 UPDATE + // after : softDeleteByAccountId 단일 벌크 UPDATE + // 매 iteration 마다 새 account 사용. account_id 는 seed 시 수집한 u4AccountIds. + // ============================================================ + @Test + @Order(11) + void u4_fixedTransactionBulkDelete() throws IOException { + String scenarios = System.getProperty("bench.run.scenarios", "R1,R2,R3,R4,R5,R6,R7,U1,U2,U3,U4"); + if (!scenarios.contains("U4")) { + System.out.println("=== [BENCH] U4 skipped"); + return; + } + System.out.println("\n=== [BENCH] U4 FixedTransaction bulk soft-delete ==="); + + int warmup = 3; + int iters = 10; + int[] cursor = {0}; // u4AccountIds index + + // ----- BEFORE ----- + for (int i = 0; i < warmup; i++) { + long accId = u4AccountIds.get(cursor[0]++); + runU4BeforeOnce(accId); + } + resetStats(); + long[] beforeSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long accId = u4AccountIds.get(cursor[0]++); + long t0 = System.nanoTime(); + runU4BeforeOnce(accId); + beforeSamples[i] = System.nanoTime() - t0; + } + long beforeSql = queryCount(); + Result beforeRes = Result.of(beforeSamples, beforeSql); + appendCsv("U4", "individual_update", "-", beforeRes); + printResult("U4 individual_update", beforeRes); + + // ----- AFTER ----- + for (int i = 0; i < warmup; i++) { + long accId = u4AccountIds.get(cursor[0]++); + runU4AfterOnce(accId); + } + resetStats(); + long[] afterSamples = new long[iters]; + for (int i = 0; i < iters; i++) { + long accId = u4AccountIds.get(cursor[0]++); + long t0 = System.nanoTime(); + runU4AfterOnce(accId); + afterSamples[i] = System.nanoTime() - t0; + } + long afterSql = queryCount(); + Result afterRes = Result.of(afterSamples, afterSql); + appendCsv("U4", "bulk_update", "-", afterRes); + printResult("U4 bulk_update", afterRes); + } + + private void runU4BeforeOnce(long accountId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + @SuppressWarnings("unchecked") + List ids = em.createNativeQuery( + "SELECT id FROM fixed_transaction WHERE account_id=?1 AND deleted_at IS NULL") + .setParameter(1, accountId) + .getResultList(); + for (Number idNum : ids) { + em.createNativeQuery( + "UPDATE fixed_transaction SET deleted_at=NOW() WHERE id=?1") + .setParameter(1, idNum.longValue()) + .executeUpdate(); + } + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + private void runU4AfterOnce(long accountId) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus tx = txManager.getTransaction(def); + try { + fixedTransactionRepository.softDeleteByAccountId(accountId); + em.flush(); + txManager.commit(tx); + } catch (RuntimeException ex) { + txManager.rollback(tx); + throw ex; + } + } + + // ============================================================ + // Helpers + // ============================================================ + @SuppressWarnings("unchecked") + private void explain(String sql) { + List rows = em.createNativeQuery(sql).getResultList(); + for (Object[] row : rows) { + System.out.println(" " + Arrays.toString(row)); + } + } + + private void ensureCsvHeader() throws IOException { + Files.createDirectories(CSV_PATH.getParent()); + if (!Files.exists(CSV_PATH)) { + try (BufferedWriter w = Files.newBufferedWriter(CSV_PATH, StandardOpenOption.CREATE_NEW)) { + w.write("scenario,variant,batchSize,iterations,avg_ms,p50_ms,p95_ms,min_ms,max_ms,sql_count"); + w.newLine(); + } + } + } + + private void appendCsv(String scenario, String variant, String batchSize, Result r) throws IOException { + try (BufferedWriter w = Files.newBufferedWriter(CSV_PATH, + StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { + w.write(String.format(Locale.ROOT, "%s,%s,%s,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%d", + scenario, variant, batchSize, r.iterations, + r.avgMs, r.p50Ms, r.p95Ms, r.minMs, r.maxMs, r.sqlCount)); + w.newLine(); + } + } + + private void printResult(String label, Result r) { + System.out.printf(Locale.ROOT, + " [%s] iters=%d avg=%.3fms p50=%.3fms p95=%.3fms min=%.3fms max=%.3fms sql_count=%d%n", + label, r.iterations, r.avgMs, r.p50Ms, r.p95Ms, r.minMs, r.maxMs, r.sqlCount); + } + + static class Result { + int iterations; + double avgMs, p50Ms, p95Ms, minMs, maxMs; + long sqlCount; + + static Result of(long[] nanos, long sqlCount) { + Result r = new Result(); + r.iterations = nanos.length; + r.sqlCount = sqlCount; + long[] sorted = nanos.clone(); + Arrays.sort(sorted); + long sum = 0; + for (long v : nanos) sum += v; + r.avgMs = (sum / (double) nanos.length) / 1_000_000.0; + r.minMs = sorted[0] / 1_000_000.0; + r.maxMs = sorted[sorted.length - 1] / 1_000_000.0; + r.p50Ms = sorted[sorted.length / 2] / 1_000_000.0; + int p95i = (int) Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1); + if (p95i < 0) p95i = 0; + r.p95Ms = sorted[p95i] / 1_000_000.0; + return r; + } + } + +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountServiceTest.java new file mode 100644 index 0000000..fc10f3c --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AccountServiceTest.java @@ -0,0 +1,666 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountGoalRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountUpdateRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.AccountResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AccountServiceTest { + + @Mock + AccountRepository accountRepository; + + @Mock + UserRepository userRepository; + + @Mock + FixedTransactionRepository fixedTransactionRepository; + + @Mock + TransactionRepository transactionRepository; + + @Mock + TransactionCategoryRepository categoryRepository; + + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + CacheManager cacheManager; + + @InjectMocks + AccountService accountService; + + @Test + @DisplayName("계좌 생성 - 성공") + void createAccount_success() { + + // given + User user = User.builder().build(); + AccountRequest request = new AccountRequest("내 통장", BigDecimal.valueOf(10000), null, null, null); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "내 통장")).willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "내 통장")).willReturn(false); + given(accountRepository.save(any(Account.class))).willReturn(Account.builder().build()); + + // when + accountService.createAccount(1L, request); + + // then + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(Account.class); + verify(accountRepository).save(accountCaptor.capture()); + assertThat(accountCaptor.getValue().getKind()).isEqualTo(AccountKind.ASSET); + assertThat(accountCaptor.getValue().getCreditLimit()).isNull(); + } + + @Test + @DisplayName("신용카드 계좌 생성 - 음수 잔액과 0원 이상 한도 허용") + void createCreditCardAccount_success() { + User user = User.builder().build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "생활 카드")) + .willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "생활 카드")).willReturn(false); + given(accountRepository.save(any(Account.class))).willAnswer(invocation -> invocation.getArgument(0)); + + accountService.createAccount(1L, new AccountRequest( + "생활 카드", new BigDecimal("-50000"), AccountRole.SAVINGS, + AccountKind.CREDIT_CARD, new BigDecimal("1000000"))); + + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(Account.class); + verify(accountRepository).save(accountCaptor.capture()); + assertThat(accountCaptor.getValue().getKind()).isEqualTo(AccountKind.CREDIT_CARD); + assertThat(accountCaptor.getValue().getCurrentBalance()).isEqualByComparingTo("-50000"); + assertThat(accountCaptor.getValue().getCreditLimit()).isEqualByComparingTo("1000000"); + assertThat(accountCaptor.getValue().getRole()).isEqualTo(AccountRole.CHECKING); + } + + @Test + @DisplayName("계좌 생성 - 카드 한도를 넘는 초기 부채 거부") + void createCreditCardAccount_rejectsBalanceBeyondLimit() { + User user = User.builder().build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "생활 카드")) + .willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "생활 카드")).willReturn(false); + + assertThatThrownBy(() -> accountService.createAccount(1L, new AccountRequest( + "생활 카드", new BigDecimal("-1001"), null, + AccountKind.CREDIT_CARD, new BigDecimal("1000")))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.CREDIT_LIMIT_EXCEEDED)); + } + + @Test + @DisplayName("기존 대출 등록은 약정액 전체를 누적 실행액으로 초기화") + void createAlreadyDisbursedLoan_success() { + User user = User.builder().build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "기존 대출")) + .willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "기존 대출")).willReturn(false); + given(accountRepository.save(any(Account.class))).willAnswer(invocation -> invocation.getArgument(0)); + + accountService.createAccount(1L, new AccountRequest( + "기존 대출", new BigDecimal("-700"), null, AccountKind.LOAN, + null, new BigDecimal("1000"), true)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Account.class); + verify(accountRepository).save(captor.capture()); + assertThat(captor.getValue().getLoanLimit()).isEqualByComparingTo("1000"); + assertThat(captor.getValue().getDisbursedAmount()).isEqualByComparingTo("1000"); + assertThat(captor.getValue().getCurrentBalance()).isEqualByComparingTo("-700"); + } + + @Test + @DisplayName("미실행 대출 등록은 잔액과 누적 실행액을 0으로 초기화") + void createUndisbursedLoan_success() { + User user = User.builder().build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "미실행 대출")) + .willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "미실행 대출")).willReturn(false); + given(accountRepository.save(any(Account.class))).willAnswer(invocation -> invocation.getArgument(0)); + + accountService.createAccount(1L, new AccountRequest( + "미실행 대출", BigDecimal.ZERO, null, AccountKind.LOAN, + null, new BigDecimal("1000"), false)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Account.class); + verify(accountRepository).save(captor.capture()); + assertThat(captor.getValue().getLoanLimit()).isEqualByComparingTo("1000"); + assertThat(captor.getValue().getDisbursedAmount()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("대출 필드가 없으면 기존 방식으로 초기 원금을 약정액으로 처리") + void createLoan_legacyRequestCompatibility() { + User user = User.builder().build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "레거시 대출")) + .willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "레거시 대출")).willReturn(false); + given(accountRepository.save(any(Account.class))).willAnswer(invocation -> invocation.getArgument(0)); + + accountService.createAccount(1L, new AccountRequest( + "레거시 대출", new BigDecimal("-800"), null, AccountKind.LOAN, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Account.class); + verify(accountRepository).save(captor.capture()); + assertThat(captor.getValue().getLoanLimit()).isEqualByComparingTo("800"); + assertThat(captor.getValue().getDisbursedAmount()).isEqualByComparingTo("800"); + } + + @Test + @DisplayName("신용카드 한도 0원은 허용하되 부채 발생은 거부") + void creditCardZeroLimit_policy() { + Account card = Account.builder() + .initialBalance(BigDecimal.ZERO) + .kind(AccountKind.CREDIT_CARD) + .creditLimit(BigDecimal.ZERO) + .build(); + + assertThat(card.getCreditLimit()).isEqualByComparingTo(BigDecimal.ZERO); + assertThatThrownBy(() -> card.changeBalance(BigDecimal.ONE.negate())) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.CREDIT_LIMIT_EXCEEDED)); + } + + @Test + @DisplayName("신용카드와 대출은 상환 후 잔액이 0원을 초과할 수 없음") + void liabilityBalance_rejectsPositiveBalance() { + Account card = Account.builder() + .initialBalance(new BigDecimal("-100")) + .kind(AccountKind.CREDIT_CARD) + .creditLimit(new BigDecimal("1000")) + .build(); + + assertThatThrownBy(() -> card.changeBalance(new BigDecimal("101"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.INVALID_LIABILITY_BALANCE)); + } + + @Test + @DisplayName("계좌 목록 조회 - 성공") + void getAccounts_success() { + + // given + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + Account account1 = Account.builder().user(user).accountName("계좌1").build(); + Account account2 = Account.builder().user(user).accountName("계좌2").build(); + + given(accountRepository.findByUserId(userId)).willReturn(List.of(account1, account2)); + + // when + List accounts = accountService.getAccounts(userId); + + // then + assertThat(accounts).hasSize(2); + assertThat(accounts.get(0).accountName()).isEqualTo("계좌1"); + assertThat(accounts.get(1).accountName()).isEqualTo("계좌2"); + } + + @Test + @DisplayName("계좌 단건 조회 - 성공") + void getAccount_success() { + + // given + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder().user(user).accountName("통장").build(); + + given(accountRepository.findById(accountId)).willReturn(Optional.of(account)); + + // when + AccountResponse response = accountService.getAccount(userId, accountId); + + // then + assertThat(response.accountName()).isEqualTo("통장"); + } + + @Test + @DisplayName("계좌 수정 - 성공") + void updateAccount_success() { + + // given + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("기존 이름") + .initialBalance(BigDecimal.valueOf(10000)) + .build(); + ReflectionTestUtils.setField(account, "currentBalance", BigDecimal.valueOf(15000)); + + TransactionCategory category = TransactionCategory.builder() + .name("잔고 조정") + .type(TransactionType.INCOME) + .build(); + given(accountRepository.findByIdWithLock(accountId)).willReturn(Optional.of(account)); + given(accountRepository.existsByUserIdAndAccountNameAndIdNot(userId, "새 이름", accountId)).willReturn(false); + given(categoryRepository.findByUserIsNullAndNameAndType("잔고 조정", TransactionType.INCOME)) + .willReturn(Optional.of(category)); + AccountUpdateRequest request = new AccountUpdateRequest( + "새 이름", + BigDecimal.valueOf(12000), + BigDecimal.valueOf(20000), + AccountRole.SAVINGS, + AccountKind.ASSET, + null + ); + + // when + accountService.updateAccount(userId, accountId, request); + + // then + assertThat(account.getAccountName()).isEqualTo("새 이름"); + assertThat(account.getInitialBalance()).isEqualByComparingTo(BigDecimal.valueOf(12000)); + assertThat(account.getCurrentBalance()).isEqualByComparingTo(BigDecimal.valueOf(20000)); + assertThat(account.getRole()).isEqualTo(AccountRole.SAVINGS); + + ArgumentCaptor transactionCaptor = ArgumentCaptor.forClass(Transaction.class); + verify(transactionRepository).save(transactionCaptor.capture()); + Transaction adjustment = transactionCaptor.getValue(); + assertThat(adjustment.getType()).isEqualTo(TransactionType.INCOME); + assertThat(adjustment.getAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000)); + assertThat(adjustment.getDescription()).isEqualTo("잔고 조정"); + } + + @Test + @DisplayName("계좌 잔고 감소 수정 - 지출 조정 내역 생성") + void updateAccount_decreaseBalance_createsExpenseAdjustment() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("생활비") + .initialBalance(BigDecimal.valueOf(10000)) + .build(); + ReflectionTestUtils.setField(account, "currentBalance", BigDecimal.valueOf(15000)); + TransactionCategory category = TransactionCategory.builder() + .name("잔고 조정") + .type(TransactionType.EXPENSE) + .build(); + + given(accountRepository.findByIdWithLock(accountId)).willReturn(Optional.of(account)); + given(categoryRepository.findByUserIsNullAndNameAndType("잔고 조정", TransactionType.EXPENSE)) + .willReturn(Optional.of(category)); + + accountService.updateAccount( + userId, + accountId, + new AccountUpdateRequest( + "생활비", + BigDecimal.valueOf(10000), + BigDecimal.valueOf(7000), + AccountRole.CHECKING, + AccountKind.ASSET, + null + ) + ); + + assertThat(account.getCurrentBalance()).isEqualByComparingTo(BigDecimal.valueOf(7000)); + ArgumentCaptor transactionCaptor = ArgumentCaptor.forClass(Transaction.class); + verify(transactionRepository).save(transactionCaptor.capture()); + assertThat(transactionCaptor.getValue().getType()).isEqualTo(TransactionType.EXPENSE); + assertThat(transactionCaptor.getValue().getAmount()).isEqualByComparingTo(BigDecimal.valueOf(8000)); + } + + @Test + @DisplayName("계좌 잔고가 같으면 조정 내역을 생성하지 않음") + void updateAccount_sameBalance_doesNotCreateAdjustment() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("생활비") + .initialBalance(BigDecimal.valueOf(10000)) + .build(); + + given(accountRepository.findByIdWithLock(accountId)).willReturn(Optional.of(account)); + + accountService.updateAccount( + userId, + accountId, + new AccountUpdateRequest( + "이름 변경", + BigDecimal.valueOf(10000), + BigDecimal.valueOf(10000), + AccountRole.SAVINGS, + AccountKind.ASSET, + null + ) + ); + + assertThat(account.getAccountName()).isEqualTo("이름 변경"); + assertThat(account.getRole()).isEqualTo(AccountRole.SAVINGS); + verify(transactionRepository, never()).save(any(Transaction.class)); + } + + @Test + @DisplayName("계좌 종류를 ASSET으로 변경할 때 음수 초기·현재 잔액을 거부") + void updateAccount_rejectsNegativeBalanceWhenChangingToAsset() { + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account loan = Account.builder() + .user(user) + .accountName("대출") + .initialBalance(new BigDecimal("-1000")) + .kind(AccountKind.LOAN) + .build(); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(loan)); + + assertThatThrownBy(() -> accountService.updateAccount(userId, 1L, + new AccountUpdateRequest( + "대출", new BigDecimal("-1000"), new BigDecimal("-1000"), + null, AccountKind.ASSET, null))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.INVALID_ACCOUNT_BALANCE)); + } + + @Test + @DisplayName("계좌 수정 - 타인 계좌 수정 시도 시 예외 발생") + void updateAccount_fail_accessDenied() { + + // given + Long ownerId = 1L; + Long hackerId = 2L; + User owner = User.builder().build(); + ReflectionTestUtils.setField(owner, "id", ownerId); + Account account = Account.builder().user(owner).build(); + + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(account)); + + // when & then + assertThrows(CustomException.class, () -> + accountService.updateAccount( + hackerId, + 1L, + new AccountUpdateRequest("이름", BigDecimal.ZERO, BigDecimal.ZERO, null, null, null) + ) + ); + } + + @Test + @DisplayName("계좌 삭제 - 성공") + void deleteAccount_success() { + + // given + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder().user(user).build(); + + given(accountRepository.findById(1L)).willReturn(Optional.of(account)); + given(fixedTransactionRepository.softDeleteByAccountId(1L)).willReturn(0); + + // when + accountService.deleteAccount(userId, 1L); + + // then + verify(fixedTransactionRepository).softDeleteByAccountId(1L); + verify(transactionRepository).backfillSourceAccountSnapshot(1L, account.getAccountName(), account.getRole()); + verify(transactionRepository).backfillTargetAccountSnapshot(1L, account.getAccountName(), account.getRole()); + verify(transactionRepository).markSourceAccountArchived(1L); + verify(transactionRepository).markTargetAccountArchived(1L); + verify(accountRepository).delete(account); + } + + @Test + @DisplayName("계좌 생성 - 중복 이름 시 예외 발생") + void createAccount_fail_duplicateName() { + + // given + User user = User.builder().build(); + AccountRequest request = new AccountRequest("내 통장", BigDecimal.valueOf(10000), null, null, null); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "내 통장")).willReturn(Optional.empty()); + given(accountRepository.existsByUserIdAndAccountName(1L, "내 통장")).willReturn(true); + + // when & then + assertThatThrownBy(() -> accountService.createAccount(1L, request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(AssetErrorCode.DUPLICATE_ACCOUNT_NAME); + }); + } + + @Test + @DisplayName("계좌 생성 - 삭제된 계좌와 동일 이름이면 복원") + void createAccount_success_restoreDeleted() { + + // given + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", 1L); + Account deletedAccount = Account.builder() + .user(user) + .accountName("내 통장") + .initialBalance(BigDecimal.ZERO) + .build(); + ReflectionTestUtils.setField(deletedAccount, "id", 10L); + deletedAccount.delete(); + + AccountRequest request = new AccountRequest( + "내 통장", BigDecimal.valueOf(5000), AccountRole.INVESTMENT, null, null); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(accountRepository.findByUserIdAndAccountNameIncludingDeleted(1L, "내 통장")) + .willReturn(Optional.of(deletedAccount)); + given(accountRepository.save(deletedAccount)).willReturn(deletedAccount); + + // when + Long accountId = accountService.createAccount(1L, request); + + // then + assertThat(accountId).isEqualTo(10L); + assertThat(deletedAccount.getDeletedAt()).isNull(); + assertThat(deletedAccount.getInitialBalance()).isEqualByComparingTo(BigDecimal.valueOf(5000)); + assertThat(deletedAccount.getCurrentBalance()).isEqualByComparingTo(BigDecimal.valueOf(5000)); + assertThat(deletedAccount.getRole()).isEqualTo(AccountRole.INVESTMENT); + } + + @Test + @DisplayName("계좌 목표 수정 - 성공") + void updateAccountGoal_success() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("비상금") + .initialBalance(BigDecimal.valueOf(1000000)) + .role(AccountRole.SAVINGS) + .build(); + + given(accountRepository.findById(accountId)).willReturn(Optional.of(account)); + + accountService.updateAccountGoal( + userId, + accountId, + new AccountGoalRequest(BigDecimal.valueOf(5000000), LocalDate.of(2026, 12, 31)) + ); + + assertThat(account.getGoalAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000000)); + assertThat(account.getGoalDate()).isEqualTo(LocalDate.of(2026, 12, 31)); + } + + @Test + @DisplayName("대출 상환 목표는 목표 잔액 0원을 허용하고 상환 진행률을 계산") + void updateLoanGoal_success() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account loan = Account.builder() + .user(user) + .accountName("주택담보대출") + .initialBalance(new BigDecimal("-10000000")) + .kind(AccountKind.LOAN) + .build(); + ReflectionTestUtils.setField(loan, "currentBalance", new BigDecimal("-7500000")); + given(accountRepository.findById(accountId)).willReturn(Optional.of(loan)); + + accountService.updateAccountGoal( + userId, + accountId, + new AccountGoalRequest(BigDecimal.ZERO, LocalDate.of(2030, 12, 31)) + ); + + assertThat(loan.getGoalAmount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(loan.calculateGoalProgressPercent()).isEqualTo(25); + } + + @Test + @DisplayName("미실행 대출은 실제 누적 실행액을 기준으로 상환 진행률 계산") + void pendingLoanGoal_usesDisbursedAmount() { + Account loan = Account.builder() + .accountName("신규 대출") + .initialBalance(BigDecimal.ZERO) + .kind(AccountKind.LOAN) + .loanLimit(new BigDecimal("10000000")) + .disbursedAmount(BigDecimal.ZERO) + .build(); + loan.updateGoal(BigDecimal.ZERO, LocalDate.of(2030, 12, 31)); + + loan.disburse(new BigDecimal("10000000")); + loan.changeBalance(new BigDecimal("2500000")); + + assertThat(loan.calculateGoalProgressPercent()).isEqualTo(25); + } + + @Test + @DisplayName("계좌 목표 삭제 - 성공") + void clearAccountGoal_success() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("비상금") + .initialBalance(BigDecimal.valueOf(1000000)) + .role(AccountRole.SAVINGS) + .build(); + account.updateGoal(BigDecimal.valueOf(5000000), LocalDate.of(2026, 12, 31)); + + given(accountRepository.findById(accountId)).willReturn(Optional.of(account)); + + accountService.clearAccountGoal(userId, accountId); + + assertThat(account.getGoalAmount()).isNull(); + assertThat(account.getGoalDate()).isNull(); + assertThat(account.getRole()).isEqualTo(AccountRole.SAVINGS); + } + + @Test + @DisplayName("계좌 단건 조회 - 목표 달성률 포함") + void getAccount_success_withProgressPercent() { + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("비상금") + .initialBalance(BigDecimal.valueOf(3000000)) + .role(AccountRole.SAVINGS) + .build(); + ReflectionTestUtils.setField(account, "currentBalance", BigDecimal.valueOf(3000000)); + account.updateGoal(BigDecimal.valueOf(5000000), LocalDate.of(2026, 12, 31)); + + given(accountRepository.findById(accountId)).willReturn(Optional.of(account)); + + AccountResponse response = accountService.getAccount(userId, accountId); + + assertThat(response.role()).isEqualTo(AccountRole.SAVINGS); + assertThat(response.goalAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000000)); + assertThat(response.progressPercent()).isEqualTo(60); + } + + @Test + @DisplayName("계좌 수정 - 중복 이름 시 예외 발생") + void updateAccount_fail_duplicateName() { + + // given + Long userId = 1L; + Long accountId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + Account account = Account.builder() + .user(user) + .accountName("기존 이름") + .initialBalance(BigDecimal.valueOf(10000)) + .build(); + + given(accountRepository.findByIdWithLock(accountId)).willReturn(Optional.of(account)); + given(accountRepository.existsByUserIdAndAccountNameAndIdNot(userId, "중복 이름", accountId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> + accountService.updateAccount( + userId, + accountId, + new AccountUpdateRequest("중복 이름", BigDecimal.ZERO, BigDecimal.ZERO, null, null, null) + ) + ).isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(AssetErrorCode.DUPLICATE_ACCOUNT_NAME); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AssetServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AssetServiceTest.java deleted file mode 100644 index 8dcb3ad..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/AssetServiceTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.asset.application; - -import com.chaewookim.accountbookformoms.domain.asset.dao.AssetRepository; -import com.chaewookim.accountbookformoms.domain.asset.entity.Asset; -import com.chaewookim.accountbookformoms.domain.asset.entity.BankEnum; -import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountRequest; -import com.chaewookim.accountbookformoms.domain.asset.dto.request.AccountUpdateRequest; -import com.chaewookim.accountbookformoms.domain.asset.dto.response.AccountResponse; -import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -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.test.util.ReflectionTestUtils; - -import java.math.BigDecimal; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class AssetServiceTest { - @InjectMocks - private AssetService assetService; - - @Mock - private AssetRepository assetRepository; - - private String username; - private String accountNumber; - private Asset asset; - private AccountRequest accountRequest; - private List assetList; - - @BeforeEach - void setUp() { - Long userId = 1L; - Long assetId = 10L; - username = "username"; - accountNumber = "111112222222"; - - asset = Asset.builder() - .username(username) - .balance(BigDecimal.valueOf(1000000)) - .bank(BankEnum.KB) - .build(); - ReflectionTestUtils.setField(asset, "id", assetId); - - accountRequest = new AccountRequest( - BankEnum.KB, - accountNumber, - BigDecimal.valueOf(1000000), - username - ); - - assetList = List.of( - Asset.builder() - .username(username) - .balance(BigDecimal.valueOf(1000000)) - .bank(BankEnum.KB) - .build(), - Asset.builder() - .username(username) - .balance(BigDecimal.valueOf(1000000)) - .bank(BankEnum.KB) - .build(), - Asset.builder() - .username(username) - .balance(BigDecimal.valueOf(1000000)) - .bank(BankEnum.KB) - .build(), - Asset.builder() - .username(username) - .balance(BigDecimal.valueOf(1000000)) - .bank(BankEnum.KB) - .build() - ); - } - - @Test - @DisplayName("요청 정보로 계좌가 생성되는지 확인") - void createAccount() { - // given - given(assetRepository.save(any(Asset.class))).willReturn(asset); - - // when - AccountResponse response = assetService.registAccount(username, accountRequest); - - // then - assertNotNull(response); - - verify(assetRepository, times(1)).save(any(Asset.class)); - } - - - @Test - @DisplayName("계좌 삭제 - 성공") - void deleteAccount() { - // given - given(assetRepository.findByAccountNumber(accountNumber)).willReturn(Optional.of(asset)); - - // when - assetService.deleteAccount(username, accountNumber); - - // then - verify(assetRepository, times(1)).findByAccountNumber(accountNumber); - verify(assetRepository, times(1)).deleteByAccountNumber(accountNumber); - } - @Test - @DisplayName("계좌 삭제 - 사용자 불일치로 실패") - void deleteAccount_AccessDenied() { - // given - String unAuthUser = "unAuthUser"; - - given(assetRepository.findByAccountNumber(accountNumber)).willReturn(Optional.of(asset)); - - // when - CustomException exception = assertThrows( - CustomException.class, - () -> assetService.deleteAccount(unAuthUser, accountNumber) - ); - - // then - assertEquals(ErrorCode.ACCOUNT_ACCESS_DENIED, exception.getErrorCode()); - verify(assetRepository, times(1)).findByAccountNumber(accountNumber); - verify(assetRepository, never()).deleteByAccountNumber(accountNumber); - } - - - @Test - @DisplayName("계좌 전체 조회") - void selectAllAccounts() { - // given - given(assetRepository.findAllByUsername(username)).willReturn(assetList); - - // when - List responses = assetService.getAllAccounts(username); - - // then - assertNotNull(responses); - } - - - @Test - @DisplayName("계좌 잔액 수정") - void updateAccount() { - // given - AccountUpdateRequest request = new AccountUpdateRequest( - BigDecimal.valueOf(10000), - username, - 10L - ); - - given(assetRepository.findByIdAndUsername(any(), any())).willReturn(Optional.of(asset)); - - // when - AccountResponse response = assetService.updateAccount(username, request); - - // then - assertNotNull(response); - - assertEquals(10000, response.balance().intValue()); - - verify(assetRepository, times(1)).findByIdAndUsername(any(), any()); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryServiceTest.java new file mode 100644 index 0000000..2f0ff0c --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/CategoryServiceTest.java @@ -0,0 +1,221 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.CategoryRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.CategoryResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import java.util.List; +import java.util.Optional; +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 CategoryServiceTest { + + @Mock + private TransactionCategoryRepository categoryRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private FixedTransactionRepository fixedTransactionRepository; + + @Mock + private BudgetRepository budgetRepository; + + @InjectMocks + private CategoryService categoryService; + + @Test + @DisplayName("카테고리 목록 조회 - 성공") + void getCategories_Success() { + + // given + Long userId = 1L; + TransactionCategory category = TransactionCategory.builder().name("식비").type(TransactionType.EXPENSE).build(); + given(categoryRepository.findAllByUserOrSystem(userId)).willReturn(List.of(category)); + + // when + List result = categoryService.getCategories(userId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("식비"); + } + + @Test + @DisplayName("카테고리 생성 - 성공") + void createCustomCategory_Success() { + + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + CategoryRequest request = new CategoryRequest("쇼핑", TransactionType.EXPENSE); + TransactionCategory savedCategory = TransactionCategory.builder().user(user).name("쇼핑").type(TransactionType.EXPENSE).build(); + given(categoryRepository.findByUserIdAndNameAndTypeIncludingDeleted(1L, "쇼핑", "EXPENSE")) + .willReturn(Optional.empty()); + given(categoryRepository.existsByUserIsNullAndNameAndType("쇼핑", TransactionType.EXPENSE)).willReturn(false); + given(categoryRepository.existsByUserIdAndNameAndType(1L, "쇼핑", TransactionType.EXPENSE)).willReturn(false); + given(categoryRepository.save(any())).willReturn(savedCategory); + + // when + CategoryResponse response = categoryService.createCustomCategory(user, request); + + // then + assertThat(response.name()).isEqualTo("쇼핑"); + } + + @Test + @DisplayName("카테고리 수정 - 성공") + void updateCategory_Success() { + + // given + Long categoryId = 1L; + Long userId = 1L; + User user = mock(User.class); + given(user.getId()).willReturn(userId); + + TransactionCategory category = TransactionCategory.builder().user(user).name("기존").type(TransactionType.EXPENSE).build(); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + given(categoryRepository.existsByUserIsNullAndNameAndType("변경", TransactionType.INCOME)).willReturn(false); + given(categoryRepository.existsByUserIdAndNameAndTypeAndIdNot(userId, "변경", TransactionType.INCOME, categoryId)) + .willReturn(false); + + CategoryRequest request = new CategoryRequest("변경", TransactionType.INCOME); + + // when + categoryService.updateCategory(categoryId, userId, request); + + // then + assertThat(category.getName()).isEqualTo("변경"); + assertThat(category.getType()).isEqualTo(TransactionType.INCOME); + } + + @Test + @DisplayName("카테고리 수정 - 기본 카테고리 수정 시 예외 발생") + void updateCategory_Fail_Immutable() { + + // given + Long categoryId = 1L; + TransactionCategory systemCategory = TransactionCategory.builder().user(null).build(); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(systemCategory)); + + // when & then + assertThatThrownBy(() -> categoryService.updateCategory(categoryId, 1L, null)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(AssetErrorCode.CATEGORY_IMMUTABLE); + }); + } + + @Test + @DisplayName("카테고리 삭제 - 고정내역 삭제 및 스냅샷 보존") + void deleteCategory_Success() { + + // given + Long categoryId = 1L; + Long userId = 1L; + User user = mock(User.class); + given(user.getId()).willReturn(userId); + + TransactionCategory category = TransactionCategory.builder().user(user).name("식비").type(TransactionType.EXPENSE).build(); + + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + given(fixedTransactionRepository.softDeleteByTransactionCategoryId(categoryId)).willReturn(1); + + // when + categoryService.deleteCategory(categoryId, userId); + + // then + verify(fixedTransactionRepository, times(1)).softDeleteByTransactionCategoryId(categoryId); + verify(transactionRepository, times(1)).backfillCategorySnapshot(categoryId, "식비"); + verify(transactionRepository, times(1)).markCategoryArchived(categoryId); + verify(budgetRepository, times(1)).backfillCategorySnapshot(categoryId, "식비"); + verify(budgetRepository, times(1)).markCategoryArchived(categoryId); + verify(categoryRepository, times(1)).delete(category); + } + + @Test + @DisplayName("카테고리 삭제 - 거래·예산이 있어도 삭제 가능") + void deleteCategory_Success_WithExistingHistory() { + + // given + Long categoryId = 1L; + Long userId = 1L; + User user = mock(User.class); + given(user.getId()).willReturn(userId); + + TransactionCategory category = TransactionCategory.builder().user(user).name("식비").type(TransactionType.EXPENSE).build(); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + given(fixedTransactionRepository.softDeleteByTransactionCategoryId(categoryId)).willReturn(0); + + // when + categoryService.deleteCategory(categoryId, userId); + + // then + verify(categoryRepository, times(1)).delete(category); + verify(transactionRepository, never()).existsByTransactionCategoryId(any()); + } + + @Test + @DisplayName("카테고리 삭제 - 타인 카테고리 삭제 시 예외 발생") + void deleteCategory_Fail_Forbidden() { + + // given + Long categoryId = 1L; + User owner = mock(User.class); + given(owner.getId()).willReturn(2L); + + TransactionCategory category = TransactionCategory.builder().user(owner).build(); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + + // when & then + assertThatThrownBy(() -> categoryService.deleteCategory(categoryId, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(AssetErrorCode.CATEGORY_FORBIDDEN); + }); + } + + @Test + @DisplayName("카테고리 생성 - 중복 이름 시 예외 발생") + void createCustomCategory_Fail_DuplicateName() { + + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + CategoryRequest request = new CategoryRequest("쇼핑", TransactionType.EXPENSE); + given(categoryRepository.findByUserIdAndNameAndTypeIncludingDeleted(1L, "쇼핑", "EXPENSE")) + .willReturn(Optional.empty()); + given(categoryRepository.existsByUserIsNullAndNameAndType("쇼핑", TransactionType.EXPENSE)).willReturn(false); + given(categoryRepository.existsByUserIdAndNameAndType(1L, "쇼핑", TransactionType.EXPENSE)).willReturn(true); + + // when & then + assertThatThrownBy(() -> categoryService.createCustomCategory(user, request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(AssetErrorCode.DUPLICATE_CATEGORY_NAME); + }); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutorTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutorTest.java new file mode 100644 index 0000000..da81ddb --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionExecutorTest.java @@ -0,0 +1,189 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.FixedTransactionExecutionFailure; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.asset.event.FixedTransactionExecutionFailedEvent; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FixedTransactionExecutorTest { + + @Mock + private TransactionService transactionService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private FixedTransactionExecutor fixedTransactionExecutor; + + private FixedTransaction fixedTransaction; + private final LocalDate today = LocalDate.now(); + + @BeforeEach + void setUp() { + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", 1L); + + Account account = Account.builder().accountName("생활비").build(); + ReflectionTestUtils.setField(account, "id", 10L); + + TransactionCategory category = TransactionCategory.builder().build(); + ReflectionTestUtils.setField(category, "id", 20L); + + fixedTransaction = FixedTransaction.builder() + .user(user) + .account(account) + .transactionCategory(category) + .type(TransactionType.EXPENSE) + .amount(new BigDecimal("10000")) + .frequency(TransactionFrequency.MONTHLY) + .repeatDay(today.getDayOfMonth()) + .startDate(today.minusDays(1)) + .description("월세") + .build(); + } + + @Test + @DisplayName("실행일이면 거래를 생성한다") + void executeIfDue_OnExecutionDay() { + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isTrue(); + verify(transactionService).createTransactionFromFixed(eq(1L), any(TransactionRequest.class)); + assertThat(fixedTransaction.getLastExecutedDate()).isEqualTo(today); + assertThat(fixedTransaction.hasExecutionFailure()).isFalse(); + } + + @Test + @DisplayName("실행일이 아니면 거래를 생성하지 않는다") + void executeIfDue_NotExecutionDay() { + int otherDay = today.getDayOfMonth() == 1 ? 2 : 1; + ReflectionTestUtils.setField(fixedTransaction, "repeatDay", otherDay); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isFalse(); + verify(transactionService, never()).createTransactionFromFixed(any(), any()); + } + + @Test + @DisplayName("오늘 이미 실행했으면 거래를 생성하지 않는다") + void executeIfDue_AlreadyExecutedToday() { + ReflectionTestUtils.setField(fixedTransaction, "lastExecutedDate", today); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isFalse(); + verify(transactionService, never()).createTransactionFromFixed(any(), any()); + } + + @Test + @DisplayName("실행일이 지났으면 예정일 기준으로 보정 생성한다") + void executeIfDue_CatchUpMissedExecutionDate() { + LocalDate missedDate = today.minusDays(1); + ReflectionTestUtils.setField(fixedTransaction, "repeatDay", missedDate.getDayOfMonth()); + ReflectionTestUtils.setField(fixedTransaction, "nextExecutionDate", missedDate); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isTrue(); + assertThat(fixedTransaction.getLastExecutedDate()).isEqualTo(missedDate); + verify(transactionService).createTransactionFromFixed(eq(1L), argThat(request -> + request.transactionDate().equals(missedDate))); + } + + @Test + @DisplayName("이체 고정 거래면 대상 계좌 ID를 넘겨 생성한다") + void executeIfDue_TransferIncludesTargetAccount() { + Account targetAccount = Account.builder().build(); + ReflectionTestUtils.setField(targetAccount, "id", 11L); + ReflectionTestUtils.setField(fixedTransaction, "type", TransactionType.TRANSFER); + ReflectionTestUtils.setField(fixedTransaction, "targetAccount", targetAccount); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isTrue(); + verify(transactionService).createTransactionFromFixed(eq(1L), argThat(request -> + request.type() == TransactionType.TRANSFER + && request.accountId().equals(10L) + && request.targetAccountId().equals(11L))); + } + + @Test + @DisplayName("잔액 부족이면 실패 상태로 남기고 알림 이벤트를 발행한다") + void executeIfDue_InsufficientBalanceMarksFailure() { + doThrow(new CustomException(AssetErrorCode.INSUFFICIENT_BALANCE)) + .when(transactionService).createTransactionFromFixed(any(), any()); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isFalse(); + assertThat(fixedTransaction.hasExecutionFailure()).isTrue(); + assertThat(fixedTransaction.getFailureReason()).isEqualTo(FixedTransactionExecutionFailure.INSUFFICIENT_BALANCE); + assertThat(fixedTransaction.getFailedExecutionDate()).isEqualTo(today); + assertThat(fixedTransaction.getLastExecutedDate()).isNull(); + verify(eventPublisher).publishEvent(any(FixedTransactionExecutionFailedEvent.class)); + } + + @Test + @DisplayName("이미 실패 상태면 자동 재시도하지 않는다") + void executeIfDue_SkipsWhenAlreadyFailed() { + fixedTransaction.markExecutionFailed(today, FixedTransactionExecutionFailure.INSUFFICIENT_BALANCE); + + boolean executed = fixedTransactionExecutor.executeIfDue(fixedTransaction, today); + + assertThat(executed).isFalse(); + verify(transactionService, never()).createTransactionFromFixed(any(), any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("실패 회차 재실행에 성공하면 실패 상태를 해제한다") + void retryFailedOccurrence_SuccessClearsFailure() { + fixedTransaction.markExecutionFailed(today, FixedTransactionExecutionFailure.INSUFFICIENT_BALANCE); + + fixedTransactionExecutor.retryFailedOccurrence(fixedTransaction); + + assertThat(fixedTransaction.hasExecutionFailure()).isFalse(); + assertThat(fixedTransaction.getLastExecutedDate()).isEqualTo(today); + verify(transactionService).createTransactionFromFixed(eq(1L), argThat(request -> + request.transactionDate().equals(today))); + } + + @Test + @DisplayName("실패하지 않은 고정 거래를 재실행하면 예외가 발생한다") + void retryFailedOccurrence_NotFailed() { + assertThatThrownBy(() -> fixedTransactionExecutor.retryFailedOccurrence(fixedTransaction)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.FIXED_TRANSACTION_NOT_FAILED); + } +} + \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionServiceTest.java new file mode 100644 index 0000000..0f02ffb --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/FixedTransactionServiceTest.java @@ -0,0 +1,198 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.FixedTransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.FixedTransactionResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.FixedTransaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +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.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FixedTransactionServiceTest { + + @Mock + private FixedTransactionRepository fixedTransactionRepository; + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionCategoryRepository categoryRepository; + + @Mock + private FixedTransactionImmediateExecutionService fixedTransactionImmediateExecutionService; + + @Mock + private FixedTransactionExecutor fixedTransactionExecutor; + + @InjectMocks + private FixedTransactionService fixedTransactionService; + + private final LocalDate now = LocalDate.now(); + + @Test + @DisplayName("고정 내역 생성 - 성공") + void createFixedTransaction_Success() { + + // given + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + FixedTransactionRequest request = new FixedTransactionRequest(1L, null, 1L, TransactionType.EXPENSE, new BigDecimal("10000"), TransactionFrequency.MONTHLY, now.getDayOfMonth(), null, now, null, "desc"); + + given(accountRepository.findByIdAndUserId(any(), any())).willReturn(Optional.of(Account.builder().build())); + given(categoryRepository.findById(any())).willReturn(Optional.of(TransactionCategory.builder().build())); + + FixedTransaction savedFt = FixedTransaction.builder() + .user(user) + .account(Account.builder().build()) + .transactionCategory(TransactionCategory.builder().build()) + .type(TransactionType.EXPENSE) + .frequency(TransactionFrequency.MONTHLY) + .startDate(now) + .repeatDay(now.getDayOfMonth()) + .build(); + ReflectionTestUtils.setField(savedFt, "id", 1L); + given(fixedTransactionRepository.save(any())).willReturn(savedFt); + + // when + Long id = fixedTransactionService.createFixedTransaction(userId, user, request); + + // then + assertThat(id).isEqualTo(1L); + verify(fixedTransactionImmediateExecutionService).executeIfDue(1L); + } + + @Test + @DisplayName("고정 내역 목록 조회 - 성공") + void getFixedTransactions_Success() { + + // given + Long userId = 1L; + Account account = Account.builder().accountName("Bank").build(); + ReflectionTestUtils.setField(account, "id", 1L); + FixedTransaction ft = FixedTransaction.builder() + .type(TransactionType.EXPENSE) + .frequency(TransactionFrequency.MONTHLY) + .startDate(now) + .repeatDay(1) + .account(account) + .transactionCategory(TransactionCategory.builder().name("Food").build()) + .build(); + given(fixedTransactionRepository.findAllByUserId(userId)).willReturn(List.of(ft)); + given(accountRepository.findById(1L)).willReturn(Optional.of(account)); + + // when + List results = fixedTransactionService.getFixedTransactions(userId); + + // then + assertThat(results).hasSize(1); + } + + @Test + @DisplayName("고정 내역 수정 - 성공") + void updateFixedTransaction_Success() { + + // given + Long userId = 1L; + Long id = 1L; + FixedTransactionRequest request = new FixedTransactionRequest(1L, null, 1L, TransactionType.EXPENSE, new BigDecimal("20000"), TransactionFrequency.MONTHLY, 5, null, now, null, "updated"); + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + FixedTransaction ft = FixedTransaction.builder() + .user(user) + .type(TransactionType.EXPENSE) + .frequency(TransactionFrequency.MONTHLY) + .startDate(now) + .repeatDay(1) + .build(); + + given(fixedTransactionRepository.findById(id)).willReturn(Optional.of(ft)); + given(accountRepository.findByIdAndUserId(any(), any())).willReturn(Optional.of(Account.builder().build())); + given(categoryRepository.findById(any())).willReturn(Optional.of(TransactionCategory.builder().build())); + + // when + fixedTransactionService.updateFixedTransaction(userId, id, request); + + // then + assertThat(ft.getAmount()).isEqualTo(new BigDecimal("20000")); + } + + @Test + @DisplayName("고정 내역 활성/비활성 토글 - 성공") + void toggleActiveStatus_Success() { + + // given + Long userId = 1L; + Long id = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + FixedTransaction ft = FixedTransaction.builder() + .user(user) + .type(TransactionType.EXPENSE) + .frequency(TransactionFrequency.MONTHLY) + .startDate(now) + .repeatDay(1) + .build(); + ReflectionTestUtils.setField(ft, "isActive", true); + + given(fixedTransactionRepository.findById(id)).willReturn(Optional.of(ft)); + + // when + fixedTransactionService.toggleActiveStatus(userId, id); + + // then + assertThat(ft.getIsActive()).isFalse(); + } + + @Test + @DisplayName("고정 내역 삭제 - 성공") + void deleteFixedTransaction_Success() { + + // given + Long userId = 1L; + Long id = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + FixedTransaction ft = FixedTransaction.builder() + .user(user) + .type(TransactionType.EXPENSE) + .frequency(TransactionFrequency.MONTHLY) + .startDate(now) + .repeatDay(1) + .build(); + + given(fixedTransactionRepository.findById(id)).willReturn(Optional.of(ft)); + + // when + fixedTransactionService.deleteFixedTransaction(userId, id); + + // then + verify(fixedTransactionRepository).delete(ft); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationServiceTest.java new file mode 100644 index 0000000..70fa7b2 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/MonthlyAllocationServiceTest.java @@ -0,0 +1,297 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.portfolio.application.PortfolioService; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.MyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +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.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class MonthlyAllocationServiceTest { + + @Mock + TransactionRepository transactionRepository; + + @Mock + AccountRepository accountRepository; + + @Mock + PortfolioService portfolioService; + + @InjectMocks + MonthlyAllocationService monthlyAllocationService; + + @Test + @DisplayName("월별 저축·투자 집계 - 역할 계좌의 이체 순유입 반영") + void computeMonthlyAllocation_success() { + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + Account checking = account(user, 1L, "월급통장", AccountRole.CHECKING, "1000000"); + Account savings = account(user, 2L, "비상금", AccountRole.SAVINGS, "500000"); + + Transaction inflow = transfer( + user, + checking, + savings, + null, + new BigDecimal("200000"), + LocalDate.of(2026, 6, 15) + ); + Transaction outflow = transfer( + user, + savings, + checking, + null, + new BigDecimal("50000"), + LocalDate.of(2026, 6, 20) + ); + + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of(inflow, outflow)); + + MonthlyAllocationResponse response = monthlyAllocationService.computeMonthlyAllocation( + userId, + "2026-06", + new BigDecimal("1000000") + ); + + assertThat(response.savings().inflow()).isEqualByComparingTo("200000"); + assertThat(response.savings().outflow()).isEqualByComparingTo("50000"); + assertThat(response.savings().net()).isEqualByComparingTo("150000"); + assertThat(response.savings().rate()).isEqualByComparingTo("15.0"); + assertThat(response.investment().net()).isEqualByComparingTo("0"); + } + + @Test + @DisplayName("월별 저축·투자 집계 - 일반 이체 카테고리에 의존하지 않음") + void computeMonthlyAllocation_ignoresCategory() { + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + Account source = account(user, 1L, "생활비", AccountRole.CHECKING, "1000000"); + Account target = account(user, 2L, "용돈", AccountRole.CHECKING, "0"); + Transaction transfer = transfer( + user, + source, + target, + null, + new BigDecimal("200000"), + LocalDate.of(2026, 6, 15) + ); + + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of(transfer)); + + MonthlyAllocationResponse response = monthlyAllocationService.computeMonthlyAllocation( + userId, + "2026-06", + new BigDecimal("1000000") + ); + + assertThat(response.savings().net()).isEqualByComparingTo("0"); + assertThat(response.investment().net()).isEqualByComparingTo("0"); + } + + @Test + @DisplayName("월별 저축·투자 집계 - 삭제된 계좌 이체는 snapshotAccountId로 처리") + void computeMonthlyAllocation_archivedAccountTransfer() { + Long userId = 1L; + User user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", userId); + + Account savings = account(user, 2L, "비상금", AccountRole.SAVINGS, "500000"); + Transaction transfer = transfer( + user, + null, + savings, + null, + new BigDecimal("100000"), + LocalDate.of(2026, 7, 10) + ); + ReflectionTestUtils.setField(transfer, "snapshotAccountId", 99L); + ReflectionTestUtils.setField(transfer, "snapshotAccountName", "삭제된 통장"); + ReflectionTestUtils.setField(transfer, "accountArchived", true); + + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of(transfer)); + + MonthlyAllocationResponse response = monthlyAllocationService.computeMonthlyAllocation( + userId, + "2026-07", + new BigDecimal("1000000") + ); + + assertThat(response.savings().inflow()).isEqualByComparingTo("100000"); + } + + @Test + @DisplayName("역할 변경 후에도 거래 시점 역할로 과거 통계를 계산") + void computeMonthlyAllocation_preservesSnapshotAfterRoleChange() { + Long userId = 1L; + User user = User.builder().build(); + Account checking = account(user, 1L, "생활비", AccountRole.CHECKING, "1000000"); + Account savings = account(user, 2L, "저축", AccountRole.SAVINGS, "0"); + Transaction transfer = transfer(user, checking, savings, null, + new BigDecimal("200000"), LocalDate.of(2026, 6, 15)); + + savings.updateRole(AccountRole.CHECKING); + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of(transfer)); + + MonthlyAllocationResponse response = monthlyAllocationService.computeMonthlyAllocation( + userId, "2026-06", new BigDecimal("1000000")); + + assertThat(response.savings().inflow()).isEqualByComparingTo("200000"); + } + + @Test + @DisplayName("삭제된 저축·투자 계좌도 역할 스냅샷으로 과거 통계를 계산") + void computeMonthlyAllocation_preservesDeletedSavingsAndInvestmentAccounts() { + Long userId = 1L; + User user = User.builder().build(); + Account savings = account(user, 1L, "저축", AccountRole.SAVINGS, "300000"); + Account investment = account(user, 2L, "투자", AccountRole.INVESTMENT, "0"); + Transaction transfer = transfer(user, savings, investment, null, + new BigDecimal("100000"), LocalDate.of(2026, 6, 15)); + ReflectionTestUtils.setField(transfer, "account", null); + ReflectionTestUtils.setField(transfer, "targetAccount", null); + + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of(transfer)); + + MonthlyAllocationResponse response = monthlyAllocationService.computeMonthlyAllocation( + userId, "2026-06", new BigDecimal("1000000")); + + assertThat(response.savings().outflow()).isEqualByComparingTo("100000"); + assertThat(response.investment().inflow()).isEqualByComparingTo("100000"); + } + + @Test + @DisplayName("월별 저축·투자 집계 API - 월 수입 포함 응답") + void getMonthlyAllocationSummary_success() { + Long userId = 1L; + given(portfolioService.getMyPortfolio(userId, "2026-06")).willReturn( + new MyPortfolioResponse( + "2026-06", + new BigDecimal("1000000"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("1000000"), + null, + null, + null + ) + ); + given(transactionRepository.findByUserIdAndDateBetween(eq(userId), any(), any())) + .willReturn(List.of()); + + var response = monthlyAllocationService.getMonthlyAllocationSummary(userId, "2026-06"); + + assertThat(response.yearMonth()).isEqualTo("2026-06"); + assertThat(response.totalIncome()).isEqualByComparingTo("1000000"); + } + + @Test + @DisplayName("목표 달성률 목록 - 목표가 있는 계좌만 반환") + void buildGoalProgress_success() { + Long userId = 1L; + User user = User.builder().build(); + + Account withGoal = Account.builder() + .user(user) + .accountName("비상금") + .initialBalance(new BigDecimal("3000000")) + .role(AccountRole.SAVINGS) + .build(); + ReflectionTestUtils.setField(withGoal, "id", 1L); + ReflectionTestUtils.setField(withGoal, "currentBalance", new BigDecimal("3000000")); + withGoal.updateGoal(new BigDecimal("5000000"), LocalDate.of(2026, 12, 31)); + + Account withoutGoal = account(user, 2L, "월급통장", AccountRole.CHECKING, "1000000"); + Account creditCardGoal = Account.builder() + .user(user) + .accountName("카드") + .initialBalance(new BigDecimal("-100000")) + .kind(AccountKind.CREDIT_CARD) + .creditLimit(new BigDecimal("1000000")) + .build(); + + Account loanGoal = Account.builder() + .user(user) + .accountName("주택담보대출") + .initialBalance(new BigDecimal("-10000000")) + .kind(AccountKind.LOAN) + .build(); + ReflectionTestUtils.setField(loanGoal, "currentBalance", new BigDecimal("-7000000")); + loanGoal.updateGoal(BigDecimal.ZERO, LocalDate.of(2030, 12, 31)); + + given(accountRepository.findByUserId(userId)) + .willReturn(List.of(withGoal, withoutGoal, creditCardGoal, loanGoal)); + + var result = monthlyAllocationService.buildGoalProgress(userId); + + assertThat(result).hasSize(2); + assertThat(result.get(0).accountName()).isEqualTo("비상금"); + assertThat(result.get(0).progressPercent()).isEqualTo(60); + assertThat(result.get(1).accountName()).isEqualTo("주택담보대출"); + assertThat(result.get(1).progressPercent()).isEqualTo(30); + } + + private Account account(User user, Long id, String name, AccountRole role, String balance) { + Account account = Account.builder() + .user(user) + .accountName(name) + .initialBalance(new BigDecimal(balance)) + .role(role) + .build(); + ReflectionTestUtils.setField(account, "id", id); + ReflectionTestUtils.setField(account, "currentBalance", new BigDecimal(balance)); + return account; + } + + private Transaction transfer( + User user, + Account source, + Account target, + TransactionCategory category, + BigDecimal amount, + LocalDate date + ) { + return Transaction.builder() + .user(user) + .account(source) + .targetAccount(target) + .transactionCategory(category) + .type(TransactionType.TRANSFER) + .amount(amount) + .transactionDate(date) + .description("test") + .build(); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportServiceTest.java new file mode 100644 index 0000000..30e88f7 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionExportServiceTest.java @@ -0,0 +1,100 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.Transaction; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +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.core.io.Resource; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.chaewookim.accountbookformoms.global.error.CustomException; + +@ExtendWith(MockitoExtension.class) +class TransactionExportServiceTest { + + @Mock + private TransactionRepository transactionRepository; + + @InjectMocks + private TransactionExportService transactionExportService; + + @Test + @DisplayName("거래 내역 엑셀 파일 생성 - 성공") + void exportToExcel_Success() throws Exception { + + // given + Long userId = 1L; + LocalDate startDate = LocalDate.of(2026, 6, 1); + LocalDate endDate = LocalDate.of(2026, 6, 30); + + Transaction transaction = Transaction.builder() + .transactionDate(LocalDate.now()) + .type(TransactionType.EXPENSE) + .amount(new BigDecimal("10000")) + .build(); + + given(transactionRepository.findByUserIdAndDateBetween(userId, startDate, endDate)).willReturn(List.of(transaction)); + + // when + Resource resource = transactionExportService.exportToCsv(userId, startDate, endDate); + + // then + assertThat(resource).isNotNull(); + + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(resource.getInputStream().readAllBytes()))) { + assertThat(workbook.getNumberOfSheets()).isEqualTo(2); + assertThat(workbook.getSheetAt(0).getSheetName()).isEqualTo("거래 내역"); + assertThat(workbook.getSheetAt(1).getSheetName()).isEqualTo("기간 요약"); + assertThat(workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue()).isEqualTo("MODI 거래 내역"); + } + } + + @Test + @DisplayName("거래 내역 엑셀 내보내기 - 시작일이 종료일보다 늦으면 실패") + void exportToExcel_InvalidDateRange() { + assertThatThrownBy(() -> transactionExportService.exportToCsv( + 1L, + LocalDate.of(2026, 6, 30), + LocalDate.of(2026, 6, 1) + )).isInstanceOf(CustomException.class); + + verify(transactionRepository, never()).findByUserIdAndDateBetween( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + @DisplayName("거래 내역 엑셀 내보내기 - 12개월 초과 기간이면 실패") + void exportToExcel_PeriodTooLong() { + assertThatThrownBy(() -> transactionExportService.exportToCsv( + 1L, + LocalDate.of(2026, 1, 1), + LocalDate.of(2027, 1, 2) + )).isInstanceOf(CustomException.class); + + verify(transactionRepository, never()).findByUserIdAndDateBetween( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionServiceTest.java new file mode 100644 index 0000000..d12124b --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/application/TransactionServiceTest.java @@ -0,0 +1,451 @@ +package com.chaewookim.accountbookformoms.domain.asset.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.chaewookim.accountbookformoms.domain.asset.dao.*; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.TransactionResponse; +import com.chaewookim.accountbookformoms.domain.asset.entity.*; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountKind; +import com.chaewookim.accountbookformoms.domain.asset.enums.AccountRole; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.asset.error.AssetErrorCode; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TransactionServiceTest { + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionCategoryRepository categoryRepository; + + @Mock + private CacheManager cacheManager; + + @Mock + private org.springframework.context.ApplicationEventPublisher eventPublisher; + + @Mock + private Cache dashboardCache; + + + + @InjectMocks + private TransactionService transactionService; + + private void setId(Object entity, Long id) { + ReflectionTestUtils.setField(entity, "id", id); + } + + @Test + @DisplayName("일반 거래 등록 - 성공") + void createNormalTransaction_Success() { + + // given + User user = User.builder().build(); setId(user, 1L); + Account account = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(account, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(account)); + given(categoryRepository.findById(1L)).willReturn(Optional.of(category)); + given(transactionRepository.save(any())).willAnswer(i -> { + Transaction t = i.getArgument(0); + setId(t, 1L); + return t; + }); + + // when + TransactionRequest req = new TransactionRequest(1L, null, 1L, TransactionType.INCOME, new BigDecimal("500"), LocalDate.now(), "월급"); + transactionService.createTransaction(1L, req); + + // then + assertThat(account.getCurrentBalance()).isEqualByComparingTo("1500"); + ArgumentCaptor captor = ArgumentCaptor.forClass(Transaction.class); + verify(transactionRepository).save(captor.capture()); + assertThat(captor.getValue().getSnapshotAccountRole()).isEqualTo(AccountRole.CHECKING); + assertThat(captor.getValue().getSnapshotTargetAccountRole()).isNull(); + } + + @Test + @DisplayName("이체 거래 등록 - 성공") + void createTransferTransaction_Success() { + + // given + User user = User.builder().build(); setId(user, 1L); + Account src = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(src, 1L); + Account target = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(target, 2L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(src)); + given(accountRepository.findByIdWithLock(2L)).willReturn(Optional.of(target)); + given(categoryRepository.findById(1L)).willReturn(Optional.of(category)); + given(transactionRepository.save(any())).willAnswer(i -> i.getArgument(0)); + + // when + TransactionRequest req = new TransactionRequest(1L, 2L, 1L, TransactionType.TRANSFER, new BigDecimal("300"), LocalDate.now(), "이체"); + transactionService.createTransaction(1L, req); + + // then + assertThat(src.getCurrentBalance()).isEqualByComparingTo("700"); + assertThat(target.getCurrentBalance()).isEqualByComparingTo("1300"); + ArgumentCaptor captor = ArgumentCaptor.forClass(Transaction.class); + verify(transactionRepository).save(captor.capture()); + assertThat(captor.getValue().getSnapshotAccountRole()).isEqualTo(AccountRole.CHECKING); + assertThat(captor.getValue().getSnapshotTargetAccountRole()).isEqualTo(AccountRole.CHECKING); + } + + @Test + @DisplayName("신용카드 지출은 잔액을 음수로 기록") + void createCreditCardExpense_success() { + User user = User.builder().build(); setId(user, 1L); + Account card = Account.builder() + .user(user).initialBalance(BigDecimal.ZERO) + .kind(AccountKind.CREDIT_CARD).creditLimit(new BigDecimal("1000")).build(); + setId(card, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(card)); + given(categoryRepository.findById(1L)).willReturn(Optional.of(category)); + given(transactionRepository.save(any())).willAnswer(i -> i.getArgument(0)); + + transactionService.createTransaction(1L, new TransactionRequest( + 1L, null, 1L, TransactionType.EXPENSE, new BigDecimal("300"), LocalDate.now(), "카드 결제")); + + assertThat(card.getCurrentBalance()).isEqualByComparingTo("-300"); + } + + @Test + @DisplayName("신용카드에는 수입을 직접 등록할 수 없음") + void createCreditCardIncome_rejected() { + User user = User.builder().build(); setId(user, 1L); + Account card = Account.builder() + .user(user).initialBalance(new BigDecimal("-300")) + .kind(AccountKind.CREDIT_CARD).creditLimit(new BigDecimal("1000")).build(); + setId(card, 1L); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(card)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, new TransactionRequest( + 1L, null, 1L, TransactionType.INCOME, + new BigDecimal("100"), LocalDate.now(), "잘못된 수입"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.INCOME_NOT_ALLOWED_FOR_LIABILITY)); + assertThat(card.getCurrentBalance()).isEqualByComparingTo("-300"); + } + + @Test + @DisplayName("자산 계좌에서 신용카드로 이체하면 카드 부채를 상환") + void transferAssetToCreditCard_success() { + User user = User.builder().build(); setId(user, 1L); + Account asset = account(user, 1L, AccountKind.ASSET, "1000", null); + Account card = account(user, 2L, AccountKind.CREDIT_CARD, "-500", "1000"); + prepareTransfer(asset, card); + + transactionService.createTransaction(1L, transferRequest("200")); + + assertThat(asset.getCurrentBalance()).isEqualByComparingTo("800"); + assertThat(card.getCurrentBalance()).isEqualByComparingTo("-300"); + } + + @Test + @DisplayName("대출 계좌에서 자산 계좌로 이체하면 대출과 자산이 함께 증가") + void transferLoanToAsset_success() { + User user = User.builder().build(); setId(user, 1L); + Account loan = account(user, 1L, AccountKind.LOAN, "-1000", null); + Account asset = account(user, 2L, AccountKind.ASSET, "0", null); + prepareTransfer(loan, asset); + + transactionService.createTransaction(1L, transferRequest("200")); + + assertThat(loan.getCurrentBalance()).isEqualByComparingTo("-1200"); + assertThat(loan.getDisbursedAmount()).isEqualByComparingTo("1200"); + assertThat(asset.getCurrentBalance()).isEqualByComparingTo("200"); + } + + @Test + @DisplayName("자산 계좌에서 대출 계좌로 이체하면 대출을 상환") + void transferAssetToLoan_success() { + User user = User.builder().build(); setId(user, 1L); + Account asset = account(user, 1L, AccountKind.ASSET, "1000", null); + Account loan = account(user, 2L, AccountKind.LOAN, "-1000", null); + prepareTransfer(asset, loan); + + transactionService.createTransaction(1L, transferRequest("300")); + + assertThat(asset.getCurrentBalance()).isEqualByComparingTo("700"); + assertThat(loan.getCurrentBalance()).isEqualByComparingTo("-700"); + assertThat(loan.getDisbursedAmount()).isEqualByComparingTo("1000"); + } + + @Test + @DisplayName("대출을 부분 실행하고 누적 실행액이 약정액을 넘으면 거부") + void disburseLoan_rejectsAmountBeyondLimit() { + User user = User.builder().build(); setId(user, 1L); + Account loan = loanAccount(user, 1L, "0", "1000", "0"); + Account asset = account(user, 2L, AccountKind.ASSET, "0", null); + prepareTransfer(loan, asset); + + transactionService.createTransaction(1L, transferRequest("600")); + + assertThat(loan.getCurrentBalance()).isEqualByComparingTo("-600"); + assertThat(loan.getDisbursedAmount()).isEqualByComparingTo("600"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, transferRequest("401"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.LOAN_LIMIT_EXCEEDED)); + } + + @Test + @DisplayName("대출을 전액 상환해도 누적 실행액이 줄지 않아 재인출 불가") + void repaidLoan_cannotBeRedrawn() { + User user = User.builder().build(); setId(user, 1L); + Account loan = loanAccount(user, 1L, "0", "500", "0"); + Account asset = account(user, 2L, AccountKind.ASSET, "0", null); + prepareTransfer(loan, asset); + + transactionService.createTransaction(1L, transferRequest("500")); + transactionService.createTransaction(1L, new TransactionRequest( + 2L, 1L, 1L, TransactionType.TRANSFER, new BigDecimal("500"), + LocalDate.now(), "원금 상환")); + + assertThat(loan.getCurrentBalance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(loan.getDisbursedAmount()).isEqualByComparingTo("500"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, transferRequest("1"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.LOAN_LIMIT_EXCEEDED)); + } + + @Test + @DisplayName("대출 실행 거래 삭제 시 잔액과 누적 실행액을 함께 역산") + void deleteLoanDisbursement_reversesBalanceAndDisbursedAmount() { + given(cacheManager.getCache("dashboard")).willReturn(dashboardCache); + User user = User.builder().build(); setId(user, 1L); + Account loan = loanAccount(user, 1L, "0", "1000", "0"); + Account asset = account(user, 2L, AccountKind.ASSET, "0", null); + loan.disburse(new BigDecimal("300")); + asset.changeBalance(new BigDecimal("300")); + Transaction transaction = Transaction.builder() + .user(user).account(loan).targetAccount(asset) + .type(TransactionType.TRANSFER).amount(new BigDecimal("300")) + .transactionDate(LocalDate.now()).build(); + setId(transaction, 10L); + given(transactionRepository.findById(10L)).willReturn(Optional.of(transaction)); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(loan)); + given(accountRepository.findByIdWithLock(2L)).willReturn(Optional.of(asset)); + + transactionService.deleteTransaction(1L, 10L); + + assertThat(loan.getCurrentBalance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(loan.getDisbursedAmount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(asset.getCurrentBalance()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("대출이 포함된 잘못된 방향의 이체를 거부") + void transferLoanToCreditCard_rejected() { + User user = User.builder().build(); setId(user, 1L); + Account loan = loanAccount(user, 1L, "0", "1000", "0"); + Account card = account(user, 2L, AccountKind.CREDIT_CARD, "0", "1000"); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(loan)); + given(accountRepository.findByIdWithLock(2L)).willReturn(Optional.of(card)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, transferRequest("100"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.INVALID_LOAN_TRANSFER)); + } + + @Test + @DisplayName("대출 계좌의 일반 지출을 거부") + void createLoanExpense_rejected() { + User user = User.builder().build(); setId(user, 1L); + Account loan = loanAccount(user, 1L, "-500", "1000", "500"); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(loan)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, new TransactionRequest( + 1L, null, 1L, TransactionType.EXPENSE, + new BigDecimal("100"), LocalDate.now(), "잘못된 이자"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.NORMAL_TRANSACTION_NOT_ALLOWED_FOR_LOAN)); + } + + @Test + @DisplayName("이체 대상 계좌가 타인 소유면 거부") + void createTransferTransaction_rejectsForeignTarget() { + User owner = User.builder().build(); setId(owner, 1L); + User other = User.builder().build(); setId(other, 2L); + Account source = account(owner, 1L, AccountKind.ASSET, "1000", null); + Account target = account(other, 2L, AccountKind.ASSET, "0", null); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(source)); + given(accountRepository.findByIdWithLock(2L)).willReturn(Optional.of(target)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> transactionService.createTransaction(1L, transferRequest("100"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(AssetErrorCode.TRANSACTION_FORBIDDEN)); + assertThat(source.getCurrentBalance()).isEqualByComparingTo("1000"); + assertThat(target.getCurrentBalance()).isEqualByComparingTo("0"); + } + + @Test + @DisplayName("계좌 거래 내역 목록 조회 - 성공") + void getTransactions_Success() { + + // given + User user = User.builder().build(); setId(user, 1L); + Account account = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(account, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + Transaction t = Transaction.builder().user(user).account(account).transactionCategory(category).build(); setId(t, 1L); + + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(t), pageable, 1); + + given(transactionRepository.findAllByUserIdAndAccountIdAndTransactionDateBetween(any(), any(), any(), any(), any())) + .willReturn(page); + + // when + Page result = transactionService.getTransactions(1L, 1L, LocalDate.now(), LocalDate.now(), pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("거래 내역 상세 조회- 성공") + void getTransaction_Success() { + + // given + User user = User.builder().build(); setId(user, 1L); + Account account = Account.builder().user(user).build(); setId(account, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + Transaction t = Transaction.builder().user(user).account(account).transactionCategory(category).type(TransactionType.EXPENSE).amount(BigDecimal.TEN).build(); + + given(transactionRepository.findById(1L)).willReturn(Optional.of(t)); + + // when + TransactionResponse result = transactionService.getTransaction(1L); + + // then + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("거래 내역 수정 - 성공") + void updateTransaction_Success() { + + // given + given(cacheManager.getCache("dashboard")).willReturn(dashboardCache); + User user = User.builder().build(); setId(user, 1L); + Account account = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(account, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + Transaction t = Transaction.builder().user(user).account(account).transactionCategory(category).type(TransactionType.EXPENSE).amount(new BigDecimal("100")).transactionDate(LocalDate.now()).build(); + setId(t, 1L); + + given(transactionRepository.findById(1L)).willReturn(Optional.of(t)); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(account)); + given(categoryRepository.findById(1L)).willReturn(Optional.of(category)); + + // when + TransactionRequest req = new TransactionRequest(1L, null, 1L, TransactionType.EXPENSE, new BigDecimal("200"), LocalDate.now(), "수정"); + transactionService.updateTransaction(1L, 1L, req); + + // then + assertThat(account.getCurrentBalance()).isEqualByComparingTo("900"); // 1000 - 200 + 100(기존값복구) + } + + @Test + @DisplayName("거래 내역 삭제 - 성공") + void deleteTransaction_Success() { + + // given + given(cacheManager.getCache("dashboard")).willReturn(dashboardCache); + User user = User.builder().build(); setId(user, 1L); + Account account = Account.builder().user(user).initialBalance(new BigDecimal("1000")).build(); setId(account, 1L); + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + Transaction t = Transaction.builder().user(user).account(account).transactionCategory(category).type(TransactionType.EXPENSE).amount(new BigDecimal("100")).transactionDate(LocalDate.now()).build(); + setId(t, 1L); + + given(transactionRepository.findById(1L)).willReturn(Optional.of(t)); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(account)); + + // when + transactionService.deleteTransaction(1L, 1L); + + // then + assertThat(account.getCurrentBalance()).isEqualByComparingTo("1100"); // 1000 + 100 + } + + private Account account(User user, Long id, AccountKind kind, String balance, String creditLimit) { + Account account = Account.builder() + .user(user) + .initialBalance(new BigDecimal(balance)) + .kind(kind) + .creditLimit(creditLimit != null ? new BigDecimal(creditLimit) : null) + .loanLimit(kind == AccountKind.LOAN ? new BigDecimal(balance).abs().add(new BigDecimal("1000")) : null) + .disbursedAmount(kind == AccountKind.LOAN ? new BigDecimal(balance).abs() : null) + .build(); + setId(account, id); + return account; + } + + private Account loanAccount(User user, Long id, String balance, String loanLimit, String disbursedAmount) { + Account account = Account.builder() + .user(user) + .initialBalance(new BigDecimal(balance)) + .kind(AccountKind.LOAN) + .loanLimit(new BigDecimal(loanLimit)) + .disbursedAmount(new BigDecimal(disbursedAmount)) + .build(); + setId(account, id); + return account; + } + + private void prepareTransfer(Account source, Account target) { + TransactionCategory category = TransactionCategory.builder().build(); setId(category, 1L); + given(accountRepository.findByIdWithLock(1L)).willReturn(Optional.of(source)); + given(accountRepository.findByIdWithLock(2L)).willReturn(Optional.of(target)); + given(categoryRepository.findById(1L)).willReturn(Optional.of(category)); + given(transactionRepository.save(any())).willAnswer(i -> i.getArgument(0)); + } + + private TransactionRequest transferRequest(String amount) { + return new TransactionRequest( + 1L, 2L, 1L, TransactionType.TRANSFER, new BigDecimal(amount), LocalDate.now(), "이체"); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/AssetTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/AssetTest.java deleted file mode 100644 index fe2c9ab..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/AssetTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.asset.entity; - -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.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import java.math.BigDecimal; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class AssetTest { - - private Asset asset; - private String username; - - @BeforeEach - void setUp() { - username = "username"; - - asset = Asset.builder() - .bank(BankEnum.KB) - .accountNumber("123") - .username(username) - .build(); - ReflectionTestUtils.setField(asset, "id", 1L); - } - - @Test - @DisplayName("Asset 객체 생성 시 기본값의 정상 적용") - void createAsset_DefaultValues() { - - assertEquals(asset.getBalance(), BigDecimal.ZERO); - } - - @Test - @DisplayName("Asset 객체 생성 후 updateBalance 정상 작동 확인") - void createAsset_subtractBalance() { - asset.subtractBalance(BigDecimal.TEN); - - assertEquals(asset.getBalance(), BigDecimal.TEN); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/BudgetTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/BudgetTest.java deleted file mode 100644 index 1e79ab4..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/BudgetTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.asset.entity; - -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.junit.jupiter.MockitoExtension; - -import java.math.BigDecimal; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class BudgetTest { - private Budget budget; - - @BeforeEach - void setUp() { - budget = Budget.builder() - .userId(1L) - .build(); - } - - @Test - @DisplayName("객체 생성 시 기본값 확인") - void budgetCreate_DefaultValue() { - assertEquals(budget.getBudgetAmount(), BigDecimal.ZERO); - assertEquals(budget.getEstimatedExpense(), BigDecimal.ZERO); - assertEquals(budget.isPublic(), false); - assertEquals(budget.getTotalIncome(), BigDecimal.ZERO); - assertEquals(budget.getTotalExpense(), BigDecimal.ZERO); - } - - @Test - @DisplayName("객체 생성 후") - void budgetCreate_UpdateAll() { - budget.updateAll(BigDecimal.valueOf(10), BigDecimal.valueOf(10), BigDecimal.valueOf(10), BigDecimal.valueOf(10)); - - assertEquals(budget.getBudgetAmount(), BigDecimal.valueOf(10)); - assertEquals(budget.getEstimatedExpense(), BigDecimal.valueOf(10)); - assertEquals(budget.getTotalIncome(), BigDecimal.valueOf(10)); - assertEquals(budget.getTotalExpense(), BigDecimal.valueOf(10)); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/StandardCategoryTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/StandardCategoryTest.java deleted file mode 100644 index d9b57bd..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/StandardCategoryTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.asset.entity; - -import com.chaewookim.accountbookformoms.domain.category.domain.StandardCategory; -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.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class StandardCategoryTest { - - private StandardCategory standardCategory; - - @BeforeEach - void setUp() { - standardCategory = new StandardCategory(); - } - - @Test - @DisplayName("객체 생성 시 기본값 확인") - void createStandardCategory_DefaultValue() { - assertEquals(standardCategory.isIncome(), false); - assertEquals(standardCategory.isInterested(), false); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/UserCreatedCategoryTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/UserCreatedCategoryTest.java deleted file mode 100644 index 7161c10..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/entity/UserCreatedCategoryTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.asset.entity; - -import com.chaewookim.accountbookformoms.domain.category.domain.UserCategory; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class UserCreatedCategoryTest { - - @Test - @DisplayName("객체 생성 시 기본값 정상 삽입 확인") - void createUserCategory() { - UserCategory userCategory = new UserCategory(); - - assertEquals(userCategory.isIncome(), false); - assertEquals(userCategory.isInterested(), false); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionSchedulerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionSchedulerTest.java new file mode 100644 index 0000000..3ea9f4a --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/asset/scheduler/FixedTransactionSchedulerTest.java @@ -0,0 +1,128 @@ +package com.chaewookim.accountbookformoms.domain.asset.scheduler; + +import com.chaewookim.accountbookformoms.domain.asset.application.FixedTransactionExecutor; +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.*; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionFrequency; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FixedTransactionSchedulerTest { + + @Mock + private FixedTransactionRepository fixedTransactionRepository; + + @Mock + private FixedTransactionExecutor fixedTransactionExecutor; + + @InjectMocks + private FixedTransactionScheduler scheduler; + + private FixedTransaction fixedTransaction; + + @BeforeEach + void setUp() { + + User user = mock(User.class); + Account account = mock(Account.class); + TransactionCategory category = mock(TransactionCategory.class); + + when(user.getId()).thenReturn(1L); + when(account.getId()).thenReturn(1L); + when(category.getId()).thenReturn(1L); + + fixedTransaction = FixedTransaction.builder() + .user(user) + .account(account) + .transactionCategory(category) + .type(TransactionType.EXPENSE) + .amount(new BigDecimal("10000")) + .frequency(TransactionFrequency.MONTHLY) + .repeatDay(LocalDate.now().getDayOfMonth()) + .startDate(LocalDate.now().minusDays(1)) + .build(); + } + + @Test + @DisplayName("고정 거래 스케줄러 - 성공") + void processFixedTransactions_Success() { + + // given + when(fixedTransactionRepository.findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(any(LocalDate.class))).thenReturn(List.of(fixedTransaction)); + + // when + scheduler.processFixedTransactions(); + + // then + verify(fixedTransactionExecutor, times(1)).executeIfDue(eq(fixedTransaction), any(LocalDate.class)); + } + + @Test + @DisplayName("고정 거래 스케줄러 - 오늘 이미 실행한 거래도 executor에 위임") + void processFixedTransactions_AlreadyExecuted() { + + // given + ReflectionTestUtils.setField(fixedTransaction, "lastExecutedDate", LocalDate.now()); + when(fixedTransactionRepository.findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(any(LocalDate.class))).thenReturn(List.of(fixedTransaction)); + + // when + scheduler.processFixedTransactions(); + + // then + verify(fixedTransactionExecutor, times(1)).executeIfDue(eq(fixedTransaction), any(LocalDate.class)); + } + + @Test + @DisplayName("고정 거래 스케줄러 - 실행일이 아니면 생성하지 않음") + void processFixedTransactions_NotTargetDay() { + + // given + ReflectionTestUtils.setField(fixedTransaction, "repeatDay", LocalDate.now().getDayOfMonth() + 1); + when(fixedTransactionRepository.findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(any(LocalDate.class))).thenReturn(List.of()); + when(fixedTransactionRepository.findAllByIsActiveTrue()).thenReturn(List.of(fixedTransaction)); + + // when + scheduler.processFixedTransactions(); + + // then + verify(fixedTransactionExecutor, never()).executeIfDue(any(), any()); + } + + @Test + @DisplayName("고정 거래 스케줄러 - 지난 예정일은 보정 생성한다") + void processFixedTransactions_CatchUpMissedDate() { + + // given + LocalDate missedDate = LocalDate.now().minusDays(1); + ReflectionTestUtils.setField(fixedTransaction, "repeatDay", missedDate.getDayOfMonth()); + ReflectionTestUtils.setField(fixedTransaction, "nextExecutionDate", missedDate); + when(fixedTransactionRepository.findAllByIsActiveTrueAndNextExecutionDateLessThanEqual(any(LocalDate.class))) + .thenReturn(List.of(fixedTransaction)); + + // when + scheduler.processFixedTransactions(); + + // then + verify(fixedTransactionExecutor, times(1)).executeIfDue(eq(fixedTransaction), any(LocalDate.class)); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardServiceTest.java new file mode 100644 index 0000000..7b4bf3b --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/AdminBoardServiceTest.java @@ -0,0 +1,136 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.AdminBoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dto.response.AdminBoardResponse; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminBoardServiceTest { + + @Mock + private BoardRepository boardRepository; + + @Mock + private AdminBoardRepository adminBoardRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private AdminBoardService adminBoardService; + + private static final Long POST_ID = 100L; + private static final Long OWNER_ID = 1L; + private static final Long CATEGORY_ID = 10L; + + private Board buildBoard(Long id, Long userId) { + Board board = Board.builder() + .userId(userId) + .categoryId(CATEGORY_ID) + .title("제목") + .content("본문") + .type(BOARD_TYPE.QNA) + .build(); + ReflectionTestUtils.setField(board, "id", id); + return board; + } + + @Test + @DisplayName("성공 — 관리자가 게시물을 admin-deleted로 표시") + void deleteByAdmin_success() { + // given + Board board = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + + // when + Long deletedId = adminBoardService.deleteByAdmin(POST_ID); + + // then + assertThat(deletedId).isEqualTo(POST_ID); + assertThat(board.isAdminDeleted()).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardChangedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().boardId()).isEqualTo(POST_ID); + assertThat(captor.getValue().type()).isEqualTo(BoardChangedEvent.Type.DELETE); + } + + @Test + @DisplayName("실패 — 존재하지 않는 게시물이면 BOARD_NOT_FOUND") + void deleteByAdmin_fail_not_found() { + // given + given(boardRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminBoardService.deleteByAdmin(POST_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + } + + @Test + @DisplayName("list — includeDeleted=false 는 boardRepository.findAll 경로 사용") + void list_without_deleted_uses_default_repo() { + Pageable pageable = PageRequest.of(0, 10); + Board board = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.findAll(pageable)).willReturn(new PageImpl<>(List.of(board), pageable, 1)); + + Page result = adminBoardService.list(null, false, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(POST_ID); + } + + @Test + @DisplayName("list — includeDeleted=true 는 native adminBoardRepository 경로 사용") + void list_with_deleted_uses_native_repo() { + Pageable pageable = PageRequest.of(0, 10); + Board board = buildBoard(POST_ID, OWNER_ID); + given(adminBoardRepository.findAllIncludingDeleted(10, 0)).willReturn(List.of(board)); + given(adminBoardRepository.countIncludingDeleted()).willReturn(1L); + + Page result = adminBoardService.list(null, true, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(POST_ID); + } + + @Test + @DisplayName("list — includeDeleted=true + type 지정 시 typed native 조회") + void list_with_deleted_and_type() { + Pageable pageable = PageRequest.of(0, 10); + Board board = buildBoard(POST_ID, OWNER_ID); + given(adminBoardRepository.findAllIncludingDeletedByType("QNA", 10, 0)) + .willReturn(List.of(board)); + given(adminBoardRepository.countIncludingDeletedByType("QNA")).willReturn(1L); + + Page result = adminBoardService.list(BOARD_TYPE.QNA, true, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListenerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListenerTest.java new file mode 100644 index 0000000..18bdda5 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardIndexEventListenerTest.java @@ -0,0 +1,122 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchRepository; +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BoardIndexEventListenerTest { + + @Mock + private BoardRepository boardRepository; + + @Mock + private BoardSearchRepository boardSearchRepository; + + @Mock + private TagService tagService; + + @InjectMocks + private BoardIndexEventListener listener; + + private static final Long BOARD_ID = 100L; + private static final Long USER_ID = 1L; + private static final Long CATEGORY_ID = 10L; + + private Board buildBoard() { + Board board = Board.builder() + .userId(USER_ID) + .categoryId(CATEGORY_ID) + .title("절약 노하우") + .content("이번 달 절약법") + .type(BOARD_TYPE.QNA) + .build(); + ReflectionTestUtils.setField(board, "id", BOARD_ID); + return board; + } + + @Test + @DisplayName("UPSERT — 게시물이 존재하면 태그와 함께 ES에 색인") + void handle_upsert_indexes_when_board_exists() { + Board board = buildBoard(); + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board)); + given(tagService.tagsOfBoard(BOARD_ID)).willReturn(java.util.List.of("절약", "재테크")); + + listener.handle(BoardChangedEvent.upsert(BOARD_ID)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardDocument.class); + verify(boardSearchRepository).save(captor.capture()); + BoardDocument indexed = captor.getValue(); + assertThat(indexed.getId()).isEqualTo(BOARD_ID); + assertThat(indexed.getTitle()).isEqualTo("절약 노하우"); + assertThat(indexed.getContent()).isEqualTo("이번 달 절약법"); + assertThat(indexed.getType()).isEqualTo(BOARD_TYPE.QNA.name()); + assertThat(indexed.getUserId()).isEqualTo(USER_ID); + assertThat(indexed.getCategoryId()).isEqualTo(CATEGORY_ID); + assertThat(indexed.getTags()).containsExactly("절약", "재테크"); + } + + @Test + @DisplayName("UPSERT — 게시물이 없으면 색인 호출 없음") + void handle_upsert_skips_when_board_missing() { + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.empty()); + + listener.handle(BoardChangedEvent.upsert(BOARD_ID)); + + verify(boardSearchRepository, never()).save(any(BoardDocument.class)); + } + + @Test + @DisplayName("DELETE — ES 인덱스에서 삭제") + void handle_delete_removes_from_index() { + listener.handle(BoardChangedEvent.delete(BOARD_ID)); + + verify(boardSearchRepository).deleteById(BOARD_ID); + verify(boardRepository, never()).findById(any()); + } + + @Test + @DisplayName("ES 호출 실패해도 예외 전파 없이 로깅만") + void handle_swallows_es_exception() { + Board board = buildBoard(); + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board)); + given(tagService.tagsOfBoard(BOARD_ID)).willReturn(java.util.List.of()); + willThrow(new RuntimeException("ES down")) + .given(boardSearchRepository).save(any(BoardDocument.class)); + + assertThatCode(() -> listener.handle(BoardChangedEvent.upsert(BOARD_ID))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("DELETE — ES 삭제 실패해도 예외 전파 없음") + void handle_swallows_es_delete_exception() { + willThrow(new RuntimeException("ES down")) + .given(boardSearchRepository).deleteById(BOARD_ID); + + assertThatCode(() -> listener.handle(BoardChangedEvent.delete(BOARD_ID))) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardServiceTest.java new file mode 100644 index 0000000..9904b9d --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardServiceTest.java @@ -0,0 +1,340 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardSearchQueryRepository; +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardCreateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.request.BoardUpdateRequest; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardCreateResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardSearchResponse; +import com.chaewookim.accountbookformoms.domain.board.dto.response.BoardUpdateResponse; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.bookmark.application.BookmarkService; +import com.chaewookim.accountbookformoms.domain.image.application.ImageService; +import com.chaewookim.accountbookformoms.domain.like.application.PostLikeService; +import com.chaewookim.accountbookformoms.domain.tag.application.TagService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.event.BoardChangedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BoardServiceTest { + + @Mock + private BoardRepository boardRepository; + + @Mock + private BoardSearchQueryRepository boardSearchQueryRepository; + + @Mock + private BoardViewCountService viewCountService; + + @Mock + private UserRepository userRepository; + + @Mock + private PostLikeService likeService; + + @Mock + private BookmarkService bookmarkService; + + @Mock + private TagService tagService; + + @Mock + private ImageService imageService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private BoardService boardService; + + private static final Long OWNER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long POST_ID = 100L; + private static final Long CATEGORY_ID = 10L; + + private Board buildBoard(Long id, Long userId) { + Board board = Board.builder() + .userId(userId) + .categoryId(CATEGORY_ID) + .title("제목") + .content("본문") + .type(BOARD_TYPE.QNA) + .build(); + ReflectionTestUtils.setField(board, "id", id); + return board; + } + + @Nested + @DisplayName("게시물 목록 조회") + class List_ { + + @Test + @DisplayName("성공 — 페이지 단위로 BoardResponse 반환") + void list_success() { + Pageable pageable = PageRequest.of(0, 10); + Board board = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.findAll(pageable)) + .willReturn(new PageImpl<>(List.of(board), pageable, 1)); + given(viewCountService.getPendingDeltas(anyCollection())).willReturn(Map.of()); + + Page result = boardService.list(null, null, pageable, null); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(POST_ID); + assertThat(result.getContent().get(0).userId()).isEqualTo(OWNER_ID); + } + + @Test + @DisplayName("성공 — 응답 views 는 DB views + Redis pending delta") + void list_addsPendingViews() { + Pageable pageable = PageRequest.of(0, 10); + Board board = buildBoard(POST_ID, OWNER_ID); + ReflectionTestUtils.setField(board, "views", 10); + given(boardRepository.findAll(pageable)) + .willReturn(new PageImpl<>(List.of(board), pageable, 1)); + given(viewCountService.getPendingDeltas(anyCollection())) + .willReturn(Map.of(POST_ID, 7L)); + + Page result = boardService.list(null, null, pageable, null); + + assertThat(result.getContent().get(0).views()).isEqualTo(17); + } + } + + @Nested + @DisplayName("게시물 생성") + class Create_ { + + @Test + @DisplayName("성공 — 저장 후 UPSERT 이벤트 발행") + void create_success() { + BoardCreateRequest request = new BoardCreateRequest( + CATEGORY_ID, "새 제목", "새 본문", BOARD_TYPE.QNA, null); + Board saved = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.save(any(Board.class))).willReturn(saved); + + BoardCreateResponse response = boardService.create(request, OWNER_ID); + + assertThat(response.id()).isEqualTo(POST_ID); + assertThat(response.title()).isEqualTo(saved.getTitle()); + verify(boardRepository).save(any(Board.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardChangedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().boardId()).isEqualTo(POST_ID); + assertThat(captor.getValue().type()).isEqualTo(BoardChangedEvent.Type.UPSERT); + } + } + + @Nested + @DisplayName("게시물 단건 조회") + class Get_ { + + @Test + @DisplayName("성공 — id로 게시물 조회 시 Redis INCR + 응답에 합산") + void get_success() { + Board board = buildBoard(POST_ID, OWNER_ID); + ReflectionTestUtils.setField(board, "views", 5); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + given(viewCountService.increment(POST_ID)).willReturn(3L); + + BoardResponse response = boardService.get(POST_ID, null); + + assertThat(response.id()).isEqualTo(POST_ID); + assertThat(response.title()).isEqualTo(board.getTitle()); + assertThat(response.type()).isEqualTo(BOARD_TYPE.QNA); + assertThat(response.views()).isEqualTo(8); + verify(viewCountService).increment(eq(POST_ID)); + } + + @Test + @DisplayName("실패 — 존재하지 않는 게시물이면 BOARD_NOT_FOUND") + void get_fail_not_found() { + given(boardRepository.findById(POST_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> boardService.get(POST_ID, null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + } + } + + @Nested + @DisplayName("게시물 수정") + class Update_ { + + @Test + @DisplayName("성공 — 작성자가 자기 게시물 수정 후 UPSERT 이벤트 발행") + void update_success() { + Board board = buildBoard(POST_ID, OWNER_ID); + BoardUpdateRequest request = new BoardUpdateRequest( + "수정된 제목", "수정된 본문", BOARD_TYPE.KNOWHOW, null); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + + BoardUpdateResponse response = boardService.update(POST_ID, request, OWNER_ID); + + assertThat(response.id()).isEqualTo(POST_ID); + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(board.getTitle()).isEqualTo("수정된 제목"); + assertThat(board.getContent()).isEqualTo("수정된 본문"); + assertThat(board.getType()).isEqualTo(BOARD_TYPE.KNOWHOW); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardChangedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().type()).isEqualTo(BoardChangedEvent.Type.UPSERT); + } + + @Test + @DisplayName("실패 — 존재하지 않는 게시물이면 BOARD_NOT_FOUND") + void update_fail_not_found() { + BoardUpdateRequest request = new BoardUpdateRequest("t", "c", BOARD_TYPE.QNA, null); + given(boardRepository.findById(POST_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> boardService.update(POST_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("실패 — 작성자가 아니면 BOARD_ACCESS_DENIED 발생 및 내용 미변경") + void update_fail_not_owner() { + Board board = buildBoard(POST_ID, OWNER_ID); + String originalTitle = board.getTitle(); + BoardUpdateRequest request = new BoardUpdateRequest("hacked", "hacked", BOARD_TYPE.QNA, null); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + + assertThatThrownBy(() -> boardService.update(POST_ID, request, OTHER_USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_ACCESS_DENIED); + + assertThat(board.getTitle()).isEqualTo(originalTitle); + verify(eventPublisher, never()).publishEvent(any()); + } + } + + @Nested + @DisplayName("게시물 삭제") + class Delete_ { + + @Test + @DisplayName("성공 — 작성자 삭제 후 DELETE 이벤트 발행") + void delete_success() { + Board board = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + + Long deletedId = boardService.delete(POST_ID, OWNER_ID); + + assertThat(deletedId).isEqualTo(POST_ID); + verify(boardRepository).delete(board); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardChangedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().boardId()).isEqualTo(POST_ID); + assertThat(captor.getValue().type()).isEqualTo(BoardChangedEvent.Type.DELETE); + } + + @Test + @DisplayName("실패 — 존재하지 않는 게시물이면 BOARD_NOT_FOUND") + void delete_fail_not_found() { + given(boardRepository.findById(POST_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> boardService.delete(POST_ID, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + + verify(boardRepository, never()).delete(any(Board.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("실패 — 작성자가 아니면 BOARD_ACCESS_DENIED 발생 및 삭제 미호출") + void delete_fail_not_owner() { + Board board = buildBoard(POST_ID, OWNER_ID); + given(boardRepository.findById(POST_ID)).willReturn(Optional.of(board)); + + assertThatThrownBy(() -> boardService.delete(POST_ID, OTHER_USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_ACCESS_DENIED); + + verify(boardRepository, never()).delete(any(Board.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + } + + @Nested + @DisplayName("게시물 검색 (Elasticsearch)") + class Search_ { + + @Test + @DisplayName("성공 — keyword로 ES 검색 후 BoardSearchResponse 매핑") + void search_success() { + String keyword = "절약"; + Pageable pageable = PageRequest.of(0, 10); + BoardDocument doc = BoardDocument.builder() + .id(POST_ID) + .userId(OWNER_ID) + .categoryId(CATEGORY_ID) + .title("절약 노하우") + .content("절약 본문") + .type(BOARD_TYPE.QNA.name()) + .build(); + given(boardSearchQueryRepository.search(keyword, pageable)) + .willReturn(new PageImpl<>(List.of(doc), pageable, 1)); + + Page result = boardService.search(keyword, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(POST_ID); + assertThat(result.getContent().get(0).title()).isEqualTo("절약 노하우"); + verify(boardSearchQueryRepository).search(keyword, pageable); + } + + @Test + @DisplayName("성공 — 일치 결과 없으면 빈 페이지 반환") + void search_empty() { + String keyword = "없는키워드"; + Pageable pageable = PageRequest.of(0, 10); + given(boardSearchQueryRepository.search(keyword, pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + Page result = boardService.search(keyword, pageable); + + assertThat(result.getTotalElements()).isZero(); + assertThat(result.getContent()).isEmpty(); + } + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountServiceTest.java new file mode 100644 index 0000000..0d4d90b --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/application/BoardViewCountServiceTest.java @@ -0,0 +1,133 @@ +package com.chaewookim.accountbookformoms.domain.board.application; + +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BoardViewCountServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOps; + + @Mock + private SetOperations setOps; + + @InjectMocks + private BoardViewCountService viewCountService; + + @BeforeEach + void setUp() { + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(redisTemplate.opsForSet()).willReturn(setOps); + } + + @Test + @DisplayName("increment — 카운터 INCR + dirty set SADD, 새 카운터 값 반환") + void increment_addsToDirtySet() { + given(valueOps.increment("board:views:100")).willReturn(4L); + + long result = viewCountService.increment(100L); + + assertThat(result).isEqualTo(4L); + verify(valueOps).increment("board:views:100"); + verify(setOps).add("board:views:dirty", "100"); + } + + @Test + @DisplayName("increment — INCR 결과 null 이면 0 반환") + void increment_nullSafe() { + given(valueOps.increment(any(String.class))).willReturn(null); + + long result = viewCountService.increment(100L); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("getPendingDeltas — MGET 결과 파싱, 없는 키(null)는 0") + void getPendingDeltas_parsesValues() { + given(valueOps.multiGet(List.of("board:views:1", "board:views:2"))) + .willReturn(java.util.Arrays.asList("5", null)); + + Map result = viewCountService.getPendingDeltas(List.of(1L, 2L)); + + assertThat(result).containsEntry(1L, 5L).containsEntry(2L, 0L); + } + + @Test + @DisplayName("getPendingDeltas — 빈 입력은 빈 맵 반환 (Redis 호출 없음)") + void getPendingDeltas_emptyInput() { + Map result = viewCountService.getPendingDeltas(List.of()); + + assertThat(result).isEmpty(); + verify(valueOps, never()).multiGet(any()); + } + + @Test + @DisplayName("snapshotDirtyBoardIds — dirty set 멤버를 Long 으로 파싱") + void snapshotDirtyBoardIds_parsesLong() { + given(setOps.members("board:views:dirty")).willReturn(Set.of("10", "20")); + + Set ids = viewCountService.snapshotDirtyBoardIds(); + + assertThat(ids).containsExactlyInAnyOrder(10L, 20L); + } + + @Test + @DisplayName("snapshotDirtyBoardIds — dirty set 이 비어 있으면 빈 Set") + void snapshotDirtyBoardIds_empty() { + given(setOps.members("board:views:dirty")).willReturn(Set.of()); + + assertThat(viewCountService.snapshotDirtyBoardIds()).isEmpty(); + } + + @Test + @DisplayName("readAndReset — SREM 먼저 → GETSET 0 순서 보장, 이전 값 반환") + void readAndReset_orderAndReturn() { + given(valueOps.getAndSet("board:views:42", "0")).willReturn("9"); + + long result = viewCountService.readAndReset(42L); + + assertThat(result).isEqualTo(9L); + + var order = inOrder(setOps, valueOps); + order.verify(setOps).remove("board:views:dirty", "42"); + order.verify(valueOps).getAndSet(eq("board:views:42"), eq("0")); + } + + @Test + @DisplayName("readAndReset — 이전 값이 null 이면 0 반환") + void readAndReset_nullPrevious() { + given(valueOps.getAndSet(any(String.class), eq("0"))).willReturn(null); + + long result = viewCountService.readAndReset(42L); + + assertThat(result).isZero(); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepositoryTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepositoryTest.java new file mode 100644 index 0000000..f4aa087 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/dao/BoardSearchQueryRepositoryTest.java @@ -0,0 +1,85 @@ +package com.chaewookim.accountbookformoms.domain.board.dao; + +import com.chaewookim.accountbookformoms.domain.board.document.BoardDocument; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BoardSearchQueryRepositoryTest { + + @Mock + private ElasticsearchOperations elasticsearchOperations; + + @Mock + private SearchHits searchHits; + + @Mock + private SearchHit hit; + + @InjectMocks + private BoardSearchQueryRepository repository; + + @Test + @DisplayName("성공 — ES NativeQuery 호출 결과를 Page로 변환") + void search_returns_page_from_hits() { + String keyword = "절약"; + Pageable pageable = PageRequest.of(0, 10); + BoardDocument doc = BoardDocument.builder() + .id(100L) + .title("절약 노하우") + .content("이번 달 절약") + .type("QNA") + .userId(1L) + .categoryId(10L) + .build(); + + given(elasticsearchOperations.search(any(NativeQuery.class), eq(BoardDocument.class))) + .willReturn(searchHits); + given(searchHits.stream()).willReturn(Stream.of(hit)); + given(searchHits.getTotalHits()).willReturn(1L); + given(hit.getContent()).willReturn(doc); + + Page result = repository.search(keyword, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0)).isSameAs(doc); + verify(elasticsearchOperations).search(any(NativeQuery.class), eq(BoardDocument.class)); + } + + @Test + @DisplayName("성공 — 일치 결과 없으면 빈 Page 반환") + void search_returns_empty_page_when_no_hits() { + String keyword = "없는키워드"; + Pageable pageable = PageRequest.of(0, 10); + + given(elasticsearchOperations.search(any(NativeQuery.class), eq(BoardDocument.class))) + .willReturn(searchHits); + given(searchHits.stream()).willReturn(Stream.empty()); + given(searchHits.getTotalHits()).willReturn(0L); + + Page result = repository.search(keyword, pageable); + + assertThat(result.getTotalElements()).isZero(); + assertThat(result.getContent()).isEmpty(); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocumentTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocumentTest.java new file mode 100644 index 0000000..3b7c166 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/document/BoardDocumentTest.java @@ -0,0 +1,39 @@ +package com.chaewookim.accountbookformoms.domain.board.document; + +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class BoardDocumentTest { + + @Test + @DisplayName("from — Board 엔티티의 필드를 그대로 매핑") + void from_maps_all_fields() { + LocalDateTime now = LocalDateTime.of(2026, 6, 1, 12, 0); + Board board = Board.builder() + .userId(1L) + .categoryId(10L) + .title("절약 노하우") + .content("본문") + .type(BOARD_TYPE.KNOWHOW) + .build(); + ReflectionTestUtils.setField(board, "id", 100L); + ReflectionTestUtils.setField(board, "createdAt", now); + + BoardDocument doc = BoardDocument.from(board); + + assertThat(doc.getId()).isEqualTo(100L); + assertThat(doc.getUserId()).isEqualTo(1L); + assertThat(doc.getCategoryId()).isEqualTo(10L); + assertThat(doc.getTitle()).isEqualTo("절약 노하우"); + assertThat(doc.getContent()).isEqualTo("본문"); + assertThat(doc.getType()).isEqualTo(BOARD_TYPE.KNOWHOW.name()); + assertThat(doc.getCreatedAt()).isEqualTo(now); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncSchedulerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncSchedulerTest.java new file mode 100644 index 0000000..6882072 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/board/scheduler/BoardViewSyncSchedulerTest.java @@ -0,0 +1,81 @@ +package com.chaewookim.accountbookformoms.domain.board.scheduler; + +import com.chaewookim.accountbookformoms.domain.board.application.BoardViewCountService; +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +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 java.util.Set; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BoardViewSyncSchedulerTest { + + @Mock + private BoardViewCountService viewCountService; + + @Mock + private BoardRepository boardRepository; + + @InjectMocks + private BoardViewSyncScheduler scheduler; + + @Test + @DisplayName("sync — dirty set 의 각 id 에 대해 readAndReset → increaseViews 호출") + void sync_appliesDeltas() { + given(viewCountService.snapshotDirtyBoardIds()).willReturn(Set.of(1L, 2L)); + given(viewCountService.readAndReset(1L)).willReturn(3L); + given(viewCountService.readAndReset(2L)).willReturn(7L); + given(boardRepository.increaseViews(anyLong(), anyLong())).willReturn(1); + + scheduler.sync(); + + verify(boardRepository).increaseViews(1L, 3L); + verify(boardRepository).increaseViews(2L, 7L); + } + + @Test + @DisplayName("sync — dirty set 이 비어 있으면 repository 호출 없음") + void sync_emptyDirty() { + given(viewCountService.snapshotDirtyBoardIds()).willReturn(Set.of()); + + scheduler.sync(); + + verify(viewCountService, never()).readAndReset(anyLong()); + verify(boardRepository, never()).increaseViews(anyLong(), anyLong()); + } + + @Test + @DisplayName("sync — delta 가 0 이면 increaseViews 호출 없음 (race 후 잔여 멤버 흡수)") + void sync_skipsZeroDelta() { + given(viewCountService.snapshotDirtyBoardIds()).willReturn(Set.of(99L)); + given(viewCountService.readAndReset(99L)).willReturn(0L); + + scheduler.sync(); + + verify(boardRepository, never()).increaseViews(anyLong(), anyLong()); + } + + @Test + @DisplayName("sync — increaseViews 가 0 (board 없음) 이어도 다음 id 진행") + void sync_continuesOnMissingBoard() { + given(viewCountService.snapshotDirtyBoardIds()).willReturn(Set.of(1L, 2L)); + given(viewCountService.readAndReset(1L)).willReturn(5L); + given(viewCountService.readAndReset(2L)).willReturn(4L); + given(boardRepository.increaseViews(1L, 5L)).willReturn(0); + given(boardRepository.increaseViews(2L, 4L)).willReturn(1); + + scheduler.sync(); + + verify(boardRepository).increaseViews(1L, 5L); + verify(boardRepository).increaseViews(2L, 4L); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryServiceTest.java new file mode 100644 index 0000000..4ebf636 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/boardcategory/application/BoardCategoryServiceTest.java @@ -0,0 +1,52 @@ +package com.chaewookim.accountbookformoms.domain.boardcategory.application; + +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.boardcategory.dao.BoardCategoryRepository; +import com.chaewookim.accountbookformoms.domain.boardcategory.dto.response.BoardCategoryResponse; +import com.chaewookim.accountbookformoms.domain.boardcategory.entity.BoardCategory; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BoardCategoryServiceTest { + + @Mock private BoardCategoryRepository repository; + + @InjectMocks + private BoardCategoryService boardCategoryService; + + @Test + @DisplayName("list(null) — 전체 조회 (findAll)") + void list_all() { + BoardCategory c1 = BoardCategory.builder().name("재테크").boardType(BOARD_TYPE.QNA).displayOrder(0).build(); + given(repository.findAll()).willReturn(List.of(c1)); + + List result = boardCategoryService.list(null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("재테크"); + } + + @Test + @DisplayName("list(QNA) — boardType 필터로 orderByDisplayOrder 조회") + void list_by_type() { + BoardCategory c1 = BoardCategory.builder().name("재테크").boardType(BOARD_TYPE.QNA).displayOrder(0).build(); + BoardCategory c2 = BoardCategory.builder().name("가계부").boardType(BOARD_TYPE.QNA).displayOrder(1).build(); + given(repository.findByBoardTypeOrderByDisplayOrderAsc(BOARD_TYPE.QNA)).willReturn(List.of(c1, c2)); + + List result = boardCategoryService.list(BOARD_TYPE.QNA); + + assertThat(result).extracting(BoardCategoryResponse::name).containsExactly("재테크", "가계부"); + verify(repository).findByBoardTypeOrderByDisplayOrderAsc(BOARD_TYPE.QNA); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkServiceTest.java new file mode 100644 index 0000000..c828bc8 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/bookmark/application/BookmarkServiceTest.java @@ -0,0 +1,101 @@ +package com.chaewookim.accountbookformoms.domain.bookmark.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.bookmark.dao.BookmarkRepository; +import com.chaewookim.accountbookformoms.domain.bookmark.dto.response.BookmarkToggleResponse; +import com.chaewookim.accountbookformoms.domain.bookmark.entity.Bookmark; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BookmarkServiceTest { + + @Mock private BookmarkRepository bookmarkRepository; + @Mock private BoardRepository boardRepository; + + @InjectMocks + private BookmarkService bookmarkService; + + private static final Long USER_ID = 1L; + private static final Long BOARD_ID = 100L; + + @Test + @DisplayName("toggle — 북마크 없던 상태에서 저장 후 true 반환") + void toggle_add() { + given(boardRepository.existsById(BOARD_ID)).willReturn(true); + given(bookmarkRepository.findByUserIdAndBoardId(USER_ID, BOARD_ID)).willReturn(Optional.empty()); + + BookmarkToggleResponse response = bookmarkService.toggle(BOARD_ID, USER_ID); + + assertThat(response.bookmarked()).isTrue(); + verify(bookmarkRepository).save(any(Bookmark.class)); + } + + @Test + @DisplayName("toggle — 북마크 있던 상태에서 삭제 후 false 반환") + void toggle_remove() { + Bookmark existing = Bookmark.builder().userId(USER_ID).boardId(BOARD_ID).build(); + given(boardRepository.existsById(BOARD_ID)).willReturn(true); + given(bookmarkRepository.findByUserIdAndBoardId(USER_ID, BOARD_ID)).willReturn(Optional.of(existing)); + + BookmarkToggleResponse response = bookmarkService.toggle(BOARD_ID, USER_ID); + + assertThat(response.bookmarked()).isFalse(); + verify(bookmarkRepository).delete(existing); + } + + @Test + @DisplayName("toggle 실패 — 게시물이 존재하지 않으면 BOARD_NOT_FOUND") + void toggle_fail_board_not_found() { + given(boardRepository.existsById(BOARD_ID)).willReturn(false); + + assertThatThrownBy(() -> bookmarkService.toggle(BOARD_ID, USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + } + + @Test + @DisplayName("isBookmarked — viewerId 가 null 이면 항상 false") + void isBookmarked_null_viewer() { + assertThat(bookmarkService.isBookmarked(BOARD_ID, null)).isFalse(); + verify(bookmarkRepository, never()).findByUserIdAndBoardId(any(), any()); + } + + @Test + @DisplayName("bookmarkedBoards — viewerId null 또는 빈 리스트면 빈 Set") + void bookmarkedBoards_empty_cases() { + assertThat(bookmarkService.bookmarkedBoards(List.of(1L, 2L), null)).isEmpty(); + assertThat(bookmarkService.bookmarkedBoards(List.of(), USER_ID)).isEmpty(); + verify(bookmarkRepository, never()).findByUserIdAndBoardIdIn(any(), any()); + } + + @Test + @DisplayName("bookmarkedBoards — DB에서 조회한 board_id 집합 반환") + void bookmarkedBoards_returns_set() { + Bookmark b1 = Bookmark.builder().userId(USER_ID).boardId(1L).build(); + Bookmark b2 = Bookmark.builder().userId(USER_ID).boardId(3L).build(); + given(bookmarkRepository.findByUserIdAndBoardIdIn(USER_ID, List.of(1L, 2L, 3L))) + .willReturn(List.of(b1, b2)); + + Set result = bookmarkService.bookmarkedBoards(List.of(1L, 2L, 3L), USER_ID); + + assertThat(result).containsExactlyInAnyOrder(1L, 3L); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareServiceTest.java new file mode 100644 index 0000000..068e684 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetCompareServiceTest.java @@ -0,0 +1,579 @@ +package com.chaewookim.accountbookformoms.domain.budget.application; + +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetCompareRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.PublicBudgetFilterRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PairBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PublicMonthlyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; +import com.chaewookim.accountbookformoms.domain.user.entity.UserSetting; +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import com.chaewookim.accountbookformoms.domain.budget.error.BudgetErrorCode; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetGroupCacheService.CategoryAggregation; +import com.chaewookim.accountbookformoms.domain.user.application.UserLocationService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class BudgetCompareServiceTest { + + @Mock + private BudgetRepository budgetRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserLocationService userLocationService; + + @Mock + private BudgetGroupCacheService groupCache; + + @InjectMocks + private BudgetCompareService budgetCompareService; + + private Budget budget(BigDecimal total, BigDecimal expected, TransactionCategory category) { + return Budget.builder() + .transactionCategory(category) + .yearMonth("2026-06") + .totalBudget(total) + .expectedExpense(expected) + .build(); + } + + @Test + @DisplayName("내 월별 예산 조회 - 총 예산 합산 및 카테고리 항목 반환") + void getMyMonthlyBudget_success() { + + // given + TransactionCategory category = TransactionCategory.builder().name("식비").build(); + given(budgetRepository.findByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(List.of( + budget(new BigDecimal("300000"), new BigDecimal("200000"), category), + budget(new BigDecimal("100000"), new BigDecimal("50000"), null) + )); + + // when + MyBudgetResponse response = budgetCompareService.getMyMonthlyBudget(1L, "2026-06"); + + // then + assertThat(response.yearMonth()).isEqualTo("2026-06"); + assertThat(response.totalBudget()).isEqualByComparingTo("400000"); + assertThat(response.categoryBudgets()).hasSize(2); + } + + @Test + @DisplayName("내 월별 예산 조회 - 잘못된 yearMonth 포맷 예외") + void getMyMonthlyBudget_invalid_year_month() { + + // when & then + assertThatThrownBy(() -> budgetCompareService.getMyMonthlyBudget(1L, "2026/06")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.INVALID_YEAR_MONTH); + } + + @Test + @DisplayName("공개 사용자 월별 총 예산 - 연월/금액 필터 적용") + void getPublicMonthlyBudgets_success() { + + // given + PublicBudgetFilterRequest filter = new PublicBudgetFilterRequest( + 2026, 6, + new BigDecimal("100000"), new BigDecimal("1000000")); + + given(budgetRepository.findPublicMonthlyTotals( + eq("2026-06"), + eq(LocalDate.of(2026, 6, 1)), + eq(LocalDate.of(2026, 6, 30)), + any(BigDecimal.class), any(BigDecimal.class))) + .willReturn(List.of( + new Object[]{10L, "alice", "2026-06", new BigDecimal("500000")}, + new Object[]{20L, "bob", "2026-06", new BigDecimal("0")} + )); + + // when + List result = budgetCompareService.getPublicMonthlyBudgets(filter); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).username()).isEqualTo("alice"); + assertThat(result.get(0).totalBudget()).isEqualByComparingTo("500000"); + assertThat(result.get(1).username()).isEqualTo("bob"); + assertThat(result.get(1).totalBudget()).isEqualByComparingTo("0"); + } + + @Test + @DisplayName("공개 사용자 목록 - 금액 구간 역전 시 예외") + void getPublicMonthlyBudgets_invalid_amount_range() { + + // given + PublicBudgetFilterRequest filter = new PublicBudgetFilterRequest( + 2026, 6, + new BigDecimal("1000000"), new BigDecimal("500000")); + + // when & then + assertThatThrownBy(() -> budgetCompareService.getPublicMonthlyBudgets(filter)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.INVALID_AMOUNT_RANGE); + } + + @Test + @DisplayName("공개 사용자 목록 - year/month 누락 시 예외") + void getPublicMonthlyBudgets_missing_year_month() { + + // given + PublicBudgetFilterRequest filter = new PublicBudgetFilterRequest( + null, null, null, null); + + // when & then + assertThatThrownBy(() -> budgetCompareService.getPublicMonthlyBudgets(filter)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.INVALID_YEAR_MONTH); + } + + @Test + @DisplayName("AGE 비교 - 본인 나이대 사용자들 평균과 본인 합계 비교") + void compareWithGroup_age_success() { + + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(user.getBirthDate()).willReturn(LocalDate.of(LocalDate.now().getYear() - 35, 5, 1)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + // 그룹 캐시는 본인 포함 모든 공개 사용자 반환 → service 가 본인 제거 + given(groupCache.getAgeGroupSums(anyString(), any(LocalDate.class), any(LocalDate.class))) + .willReturn(Map.of( + 1L, new BigDecimal("700000"), + 2L, new BigDecimal("400000"), + 3L, new BigDecimal("600000") + )); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("700000")); + + // when + BudgetCompareResponse response = budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.AGE, "2026-06", null, null, null, null)); + + // then + assertThat(response.type()).isEqualTo(BudgetCompareType.AGE); + assertThat(response.myAmount()).isEqualByComparingTo("700000"); + assertThat(response.averageAmount()).isEqualByComparingTo("500000"); + assertThat(response.sampleSize()).isEqualTo(2); + assertThat(response.difference()).isEqualByComparingTo("200000"); + } + + @Test + @DisplayName("AGE 비교 - 생년월일 없는 사용자 예외") + void compareWithGroup_age_birth_date_required() { + + // given + User user = mock(User.class); + given(user.getBirthDate()).willReturn(null); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.AGE, "2026-06", null, null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.BIRTH_DATE_REQUIRED); + } + + @Test + @DisplayName("AMOUNT 비교 - 표본 없을 때 평균 0 반환") + void compareWithGroup_amount_empty_group() { + + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(groupCache.getAmountGroupSums(anyString(), any(), any())) + .willReturn(Map.of()); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("250000")); + + // when + BudgetCompareResponse response = budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.AMOUNT, "2026-06", + new BigDecimal("100000"), new BigDecimal("500000"), null, null)); + + // then + assertThat(response.averageAmount()).isEqualByComparingTo("0"); + assertThat(response.sampleSize()).isZero(); + assertThat(response.myAmount()).isEqualByComparingTo("250000"); + } + + @Test + @DisplayName("CATEGORY 비교 - 같은 카테고리 평균과 본인 카테고리 예산 비교") + void compareWithGroup_category_success() { + + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + // 캐시는 본인 포함. 다른 4명 평균=250000 + 본인 300000 → sumAll = 1300000, count=5 + // 서비스가 본인을 제외 → groupSum=1000000 / count=4 → avg 250000 + given(groupCache.getCategoryAggregation(anyString(), anyLong())) + .willReturn(new CategoryAggregation(new BigDecimal("1300000"), 5L)); + given(budgetRepository.findMyCategoryBudget(anyLong(), anyString(), anyLong())) + .willReturn(new BigDecimal("300000")); + + // when + BudgetCompareResponse response = budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.CATEGORY, "2026-06", null, null, 10L, null)); + + // then + assertThat(response.type()).isEqualTo(BudgetCompareType.CATEGORY); + assertThat(response.myAmount()).isEqualByComparingTo("300000"); + assertThat(response.averageAmount()).isEqualByComparingTo("250000"); + assertThat(response.sampleSize()).isEqualTo(4); + assertThat(response.difference()).isEqualByComparingTo("50000"); + } + + @Test + @DisplayName("CATEGORY 비교 - categoryId 없으면 예외") + void compareWithGroup_category_id_required() { + + // given + User user = mock(User.class); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.CATEGORY, "2026-06", null, null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.CATEGORY_ID_REQUIRED); + } + + @Test + @DisplayName("비교 - 사용자 없을 시 예외") + void compareWithGroup_user_not_found() { + + // given + given(userRepository.findById(99L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 99L, new BudgetCompareRequest(BudgetCompareType.AGE, "2026-06", null, null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } + + private User publicUser(Long id, String username, LocalDate birthDate) { + User user = mock(User.class); + lenient().when(user.getId()).thenReturn(id); + lenient().when(user.getUsername()).thenReturn(username); + lenient().when(user.getBirthDate()).thenReturn(birthDate); + UserSetting setting = mock(UserSetting.class); + lenient().when(setting.getIsPortfolioPublic()).thenReturn(true); + lenient().when(user.getUserSetting()).thenReturn(setting); + return user; + } + + @Test + @DisplayName("두 사용자 예산 세부 조회 - 본인+대상자 월 총/카테고리별 예산 반환") + void getPairBudgetDetails_success() { + + // given + User me = publicUser(1L, "me", LocalDate.of(1990, 1, 1)); + User target = publicUser(2L, "target", LocalDate.of(1992, 3, 1)); + + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + + TransactionCategory food = TransactionCategory.builder().name("식비").build(); + given(budgetRepository.findByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(List.of(budget(new BigDecimal("200000"), new BigDecimal("100000"), food))); + given(budgetRepository.findByUserIdAndYearMonth(2L, "2026-06")) + .willReturn(List.of( + budget(new BigDecimal("300000"), new BigDecimal("200000"), food), + budget(new BigDecimal("100000"), new BigDecimal("80000"), null))); + + // when + PairBudgetDetailResponse response = budgetCompareService.getPairBudgetDetails(1L, 2L, "2026-06"); + + // then + assertThat(response.me().userId()).isEqualTo(1L); + assertThat(response.me().totalBudget()).isEqualByComparingTo("200000"); + assertThat(response.me().categoryBudgets()).hasSize(1); + assertThat(response.target().userId()).isEqualTo(2L); + assertThat(response.target().totalBudget()).isEqualByComparingTo("400000"); + assertThat(response.target().categoryBudgets()).hasSize(2); + } + + @Test + @DisplayName("두 사용자 세부 조회 - 자기 자신 비교 예외") + void getPairBudgetDetails_self_compare_forbidden() { + + // when & then + assertThatThrownBy(() -> budgetCompareService.getPairBudgetDetails(1L, 1L, "2026-06")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.CANNOT_COMPARE_SELF); + } + + @Test + @DisplayName("두 사용자 세부 조회 - 비공개 사용자는 예외") + void getPairBudgetDetails_target_not_public() { + + // given + User me = mock(User.class); + User target = mock(User.class); + UserSetting setting = mock(UserSetting.class); + given(setting.getIsPortfolioPublic()).willReturn(false); + given(target.getUserSetting()).willReturn(setting); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.getPairBudgetDetails(1L, 2L, "2026-06")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.TARGET_NOT_PUBLIC); + } + + @Test + @DisplayName("선택 사용자 비교 - AMOUNT 모드: 월 총 예산 직접 비교") + void compareWithUser_amount_success() { + + // given + User me = publicUser(1L, "me", null); + User target = publicUser(2L, "target", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("500000")); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(2L, "2026-06")) + .willReturn(new BigDecimal("700000")); + + // when + UserBudgetCompareResponse response = budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.AMOUNT, "2026-06", null); + + // then + assertThat(response.type()).isEqualTo(BudgetCompareType.AMOUNT); + assertThat(response.myAmount()).isEqualByComparingTo("500000"); + assertThat(response.targetAmount()).isEqualByComparingTo("700000"); + assertThat(response.difference()).isEqualByComparingTo("-200000"); + } + + @Test + @DisplayName("선택 사용자 비교 - AGE 모드: 나이대 라벨 동반") + void compareWithUser_age_success() { + + // given + int year = LocalDate.now().getYear(); + User me = publicUser(1L, "me", LocalDate.of(year - 35, 1, 1)); // 30대 + User target = publicUser(2L, "target", LocalDate.of(year - 42, 1, 1)); // 40대 + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("400000")); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(2L, "2026-06")) + .willReturn(new BigDecimal("600000")); + + // when + UserBudgetCompareResponse response = budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.AGE, "2026-06", null); + + // then + assertThat(response.myLabel()).isEqualTo("30대"); + assertThat(response.targetLabel()).isEqualTo("40대"); + assertThat(response.difference()).isEqualByComparingTo("-200000"); + } + + @Test + @DisplayName("선택 사용자 비교 - CATEGORY 모드: 카테고리별 예산 비교") + void compareWithUser_category_success() { + + // given + User me = publicUser(1L, "me", null); + User target = publicUser(2L, "target", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + given(budgetRepository.findMyCategoryBudget(1L, "2026-06", 10L)) + .willReturn(new BigDecimal("200000")); + given(budgetRepository.findMyCategoryBudget(2L, "2026-06", 10L)) + .willReturn(new BigDecimal("150000")); + + // when + UserBudgetCompareResponse response = budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.CATEGORY, "2026-06", 10L); + + // then + assertThat(response.type()).isEqualTo(BudgetCompareType.CATEGORY); + assertThat(response.myAmount()).isEqualByComparingTo("200000"); + assertThat(response.targetAmount()).isEqualByComparingTo("150000"); + assertThat(response.difference()).isEqualByComparingTo("50000"); + } + + @Test + @DisplayName("선택 사용자 비교 - CATEGORY 모드 categoryId 없으면 예외") + void compareWithUser_category_id_required() { + + // given + User me = publicUser(1L, "me", null); + User target = publicUser(2L, "target", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.CATEGORY, "2026-06", null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.CATEGORY_ID_REQUIRED); + } + + @Test + @DisplayName("선택 사용자 비교 - AGE 모드: 생년월일 없으면 예외") + void compareWithUser_age_birth_required() { + + // given + User me = publicUser(1L, "me", null); + User target = publicUser(2L, "target", LocalDate.of(1990, 1, 1)); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.AGE, "2026-06", null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.BIRTH_DATE_REQUIRED); + } + + @Test + @DisplayName("그룹 평균 비교 - LOCATION: 반경 내 공개 사용자 그룹 평균 + 본인 차이") + void compareWithGroup_location_success() { + + // given + User me = publicUser(1L, "me", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userLocationService.findNearbyUserIds(1L, 3.0)).willReturn(Set.of(2L, 3L)); + given(budgetRepository.sumMonthlyTotalsByUserIds(eq("2026-06"), any())) + .willReturn(List.of( + new Object[]{2L, new BigDecimal("400000")}, + new Object[]{3L, new BigDecimal("800000")})); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("500000")); + + // when + BudgetCompareResponse response = budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.LOCATION, "2026-06", null, null, null, 3.0)); + + // then + assertThat(response.type()).isEqualTo(BudgetCompareType.LOCATION); + assertThat(response.sampleSize()).isEqualTo(2L); + assertThat(response.averageAmount()).isEqualByComparingTo("600000"); + assertThat(response.myAmount()).isEqualByComparingTo("500000"); + assertThat(response.difference()).isEqualByComparingTo("-100000"); + } + + @Test + @DisplayName("그룹 평균 비교 - LOCATION: 반경 누락 시 예외") + void compareWithGroup_location_radius_required() { + + // given + User me = publicUser(1L, "me", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.LOCATION, "2026-06", null, null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.RADIUS_REQUIRED); + } + + @Test + @DisplayName("그룹 평균 비교 - LOCATION: 반경 0.1km 미만이면 예외") + void compareWithGroup_location_radius_too_small() { + + // given + User me = publicUser(1L, "me", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.LOCATION, "2026-06", null, null, null, 0.05))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.INVALID_RADIUS); + } + + @Test + @DisplayName("그룹 평균 비교 - LOCATION: 반경 50km 초과면 예외") + void compareWithGroup_location_radius_too_large() { + + // given + User me = publicUser(1L, "me", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.LOCATION, "2026-06", null, null, null, 100.0))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.INVALID_RADIUS); + } + + @Test + @DisplayName("그룹 평균 비교 - LOCATION: 반경 내 사용자 0명일 때 sampleSize=0, average=0") + void compareWithGroup_location_no_neighbors() { + + // given + User me = publicUser(1L, "me", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userLocationService.findNearbyUserIds(1L, 1.0)).willReturn(Set.of()); + given(budgetRepository.sumTotalBudgetByUserIdAndYearMonth(1L, "2026-06")) + .willReturn(new BigDecimal("500000")); + + // when + BudgetCompareResponse response = budgetCompareService.compareWithGroup( + 1L, new BudgetCompareRequest(BudgetCompareType.LOCATION, "2026-06", null, null, null, 1.0)); + + // then + assertThat(response.sampleSize()).isZero(); + assertThat(response.averageAmount()).isEqualByComparingTo("0"); + assertThat(response.myAmount()).isEqualByComparingTo("500000"); + } + + @Test + @DisplayName("선택 사용자 비교 - LOCATION 은 1:1 비교 미지원 예외") + void compareWithUser_location_not_supported() { + + // given + User me = publicUser(1L, "me", null); + User target = publicUser(2L, "target", null); + given(userRepository.findById(1L)).willReturn(Optional.of(me)); + given(userRepository.findById(2L)).willReturn(Optional.of(target)); + + // when & then + assertThatThrownBy(() -> budgetCompareService.compareWithUser( + 1L, 2L, BudgetCompareType.LOCATION, "2026-06", null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BudgetErrorCode.LOCATION_PAIR_NOT_SUPPORTED); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetServiceTest.java new file mode 100644 index 0000000..c683f7f --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/budget/application/BudgetServiceTest.java @@ -0,0 +1,351 @@ +package com.chaewookim.accountbookformoms.domain.budget.application; + +import com.chaewookim.accountbookformoms.domain.asset.dao.FixedTransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCopyResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetSummaryResponse; +import com.chaewookim.accountbookformoms.domain.budget.entity.Budget; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BudgetServiceTest { + + @Mock + private BudgetRepository budgetRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private TransactionCategoryRepository categoryRepository; + + @Mock + private FixedTransactionRepository fixedTransactionRepository; + + @Mock + private org.springframework.cache.CacheManager cacheManager; + + @InjectMocks + private BudgetService budgetService; + + @Test + @DisplayName("예산 생성 - 소프트 삭제된 예산 복구") + void createBudget_restoreDeleted() { + + // given + Long userId = 1L; + BudgetRequest request = new BudgetRequest(1L, "2026-07", new BigDecimal("1000"), new BigDecimal("500")); + Budget deletedBudget = Budget.builder() + .yearMonth("2026-07") + .totalBudget(new BigDecimal("100")) + .expectedExpense(BigDecimal.ZERO) + .build(); + deletedBudget.delete(); + + given(budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId(userId, request.yearMonth(), request.categoryId())) + .willReturn(Optional.empty()); + given(budgetRepository.findByUserIdAndYearMonthAndCategoryIdIncludingDeleted(userId, request.yearMonth(), request.categoryId())) + .willReturn(Optional.of(deletedBudget)); + given(budgetRepository.save(deletedBudget)).willReturn(deletedBudget); + + // when + budgetService.createBudget(userId, request); + + // then + assertThat(deletedBudget.getDeletedAt()).isNull(); + verify(budgetRepository, times(1)).save(deletedBudget); + verify(userRepository, never()).getReferenceById(any()); + verify(categoryRepository, never()).getReferenceById(any()); + } + + @Test + @DisplayName("예산 생성 - 성공") + void createBudget_success() { + + // given + Long userId = 1L; + Long categoryId = 1L; + BudgetRequest request = new BudgetRequest(1L, "2026-06", new BigDecimal("1000"), new BigDecimal("500")); + + given(userRepository.getReferenceById(userId)).willReturn(mock(com.chaewookim.accountbookformoms.domain.user.entity.User.class)); + given(categoryRepository.getReferenceById(categoryId)).willReturn(mock(com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory.class)); + given(budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId(any(), any(), any())).willReturn(Optional.empty()); + given(budgetRepository.findByUserIdAndYearMonthAndCategoryIdIncludingDeleted(any(), any(), any())).willReturn(Optional.empty()); + given(budgetRepository.save(any())).willReturn(Budget.builder().build()); + + // when + budgetService.createBudget(userId, request); + + // then + verify(budgetRepository, times(1)).save(any(Budget.class)); + verify(userRepository, times(1)).getReferenceById(userId); + verify(categoryRepository, times(1)).getReferenceById(categoryId); + } + + @Test + @DisplayName("월간 예산 현황 조회 - 성공") + void getMonthlyBudgetStatus_success() { + + // given + Long userId = 1L; + String yearMonth = "2026-06"; + + given(budgetRepository.findByUserIdAndYearMonth(userId, yearMonth)).willReturn(List.of()); + given(fixedTransactionRepository.sumByUserCategory(any(), any(), any(), any())).willReturn(List.of()); + given(transactionRepository.sumAmountByUserIdGroupByCategoryId(userId, yearMonth)).willReturn(List.of()); + + // when + List responses = budgetService.getMonthlyBudgetStatus(userId, yearMonth); + + // then + assertThat(responses).isNotNull(); + } + + @Test + @DisplayName("월간 예산 요약 조회 - 성공") + void getMonthlyBudgetSummary_success() { + + // given + Long userId = 1L; + String yearMonth = "2026-06"; + + given(budgetRepository.findByUserIdAndYearMonth(userId, yearMonth)).willReturn(List.of()); + given(fixedTransactionRepository.sumByUserCategory(any(), any(), any(), any())).willReturn(List.of()); + given(transactionRepository.sumByUserAndType(any(), eq(TransactionType.EXPENSE), any(), any())).willReturn(BigDecimal.ZERO); + + // when + BudgetSummaryResponse summary = budgetService.getMonthlyBudgetSummary(userId, yearMonth); + + // then + assertThat(summary.totalPlannedBudgetSum()).isEqualTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("예산 수정 - 성공") + void updateBudget_success() { + + // given + Long userId = 1L; + Long budgetId = 1L; + Budget budget = mock(Budget.class); + User user = mock(User.class); + + given(budgetRepository.findById(budgetId)).willReturn(Optional.of(budget)); + given(budget.getUser()).willReturn(user); + given(user.getId()).willReturn(userId); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + budgetService.updateBudget(userId, budgetId, new BudgetRequest(1L, "2026-06", new BigDecimal("2000"), new BigDecimal("0"))); + + // then + verify(budget, times(1)).update(any(), any()); + verify(user, times(1)).updateLastBudgetAlertMonth(null); + verify(userRepository, times(1)).save(user); + } + + @Test + @DisplayName("예산 삭제 - 성공") + void deleteBudget_success() { + + // given + Long userId = 1L; + Long budgetId = 1L; + Budget budget = mock(Budget.class); + User user = mock(User.class); + + given(budgetRepository.findById(budgetId)).willReturn(Optional.of(budget)); + given(budget.getUser()).willReturn(user); + given(user.getId()).willReturn(userId); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + budgetService.deleteBudget(userId, budgetId); + + // then + verify(budgetRepository, times(1)).delete(budget); + verify(user, times(1)).updateLastBudgetAlertMonth(null); + verify(userRepository, times(1)).save(user); + } + + @Test + @DisplayName("최근 예산 불러오기 미리보기 - 삭제된 카테고리 제외") + void previewCopyFromLatest_skipDeletedCategory() { + Long userId = 1L; + String targetYearMonth = "2026-08"; + String sourceYearMonth = "2026-06"; + + TransactionCategory activeCategory = mock(TransactionCategory.class); + TransactionCategory deletedCategory = mock(TransactionCategory.class); + given(activeCategory.getId()).willReturn(1L); + given(activeCategory.getType()).willReturn(TransactionType.EXPENSE); + given(deletedCategory.getId()).willReturn(2L); + given(deletedCategory.getName()).willReturn("삭제된 카테고리"); + + Budget activeBudget = Budget.builder() + .yearMonth(sourceYearMonth) + .totalBudget(new BigDecimal("1000")) + .expectedExpense(new BigDecimal("100")) + .transactionCategory(activeCategory) + .build(); + + Budget deletedCategoryBudget = Budget.builder() + .yearMonth(sourceYearMonth) + .totalBudget(new BigDecimal("500")) + .expectedExpense(BigDecimal.ZERO) + .transactionCategory(deletedCategory) + .build(); + + given(budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth)).willReturn(List.of()); + given(budgetRepository.findLatestBudgetYearMonthBefore(userId, targetYearMonth)) + .willReturn(Optional.of(sourceYearMonth)); + given(budgetRepository.findByUserIdAndYearMonth(userId, sourceYearMonth)) + .willReturn(List.of(activeBudget, deletedCategoryBudget)); + given(categoryRepository.findAllByUserOrSystem(userId)).willReturn(List.of(activeCategory)); + + BudgetCopyResponse preview = budgetService.previewCopyFromLatest(userId, targetYearMonth); + + assertThat(preview.copyCount()).isEqualTo(1); + assertThat(preview.items()).hasSize(2); + assertThat(preview.items().stream().filter(BudgetCopyResponse.Item::selected).count()).isEqualTo(1); + assertThat(preview.items().stream().filter(item -> !item.selected()).findFirst().orElseThrow().skipReason()) + .isEqualTo("DELETED_CATEGORY"); + } + + @Test + @DisplayName("최근 예산 불러오기 - 사용자가 설정한 예산이 있으면 실패") + void copyFromLatest_targetMonthNotEmpty() { + Long userId = 1L; + String targetYearMonth = "2026-07"; + + Budget configuredBudget = Budget.builder() + .yearMonth(targetYearMonth) + .totalBudget(new BigDecimal("1000")) + .expectedExpense(BigDecimal.ZERO) + .build(); + + given(budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth)) + .willReturn(List.of(configuredBudget)); + + assertThatThrownBy(() -> budgetService.copyFromLatest(userId, targetYearMonth)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("최근 예산 불러오기 - 고정 지출용 placeholder만 있으면 허용") + void copyFromLatest_allowsWhenOnlyPlaceholderBudgets() { + Long userId = 1L; + String targetYearMonth = "2026-08"; + String sourceYearMonth = "2026-06"; + + Budget placeholderBudget = Budget.builder() + .yearMonth(targetYearMonth) + .totalBudget(BigDecimal.ZERO) + .expectedExpense(BigDecimal.ZERO) + .build(); + + TransactionCategory category = mock(TransactionCategory.class); + given(category.getId()).willReturn(1L); + given(category.getType()).willReturn(TransactionType.EXPENSE); + given(category.getName()).willReturn("식비"); + + Budget sourceBudget = Budget.builder() + .yearMonth(sourceYearMonth) + .totalBudget(new BigDecimal("1000")) + .expectedExpense(new BigDecimal("200")) + .transactionCategory(category) + .build(); + + User user = mock(User.class); + + given(budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth)) + .willReturn(List.of(placeholderBudget)); + given(budgetRepository.findLatestBudgetYearMonthBefore(userId, targetYearMonth)) + .willReturn(Optional.of(sourceYearMonth)); + given(budgetRepository.findByUserIdAndYearMonth(userId, sourceYearMonth)).willReturn(List.of(sourceBudget)); + given(categoryRepository.findAllByUserOrSystem(userId)).willReturn(List.of(category)); + given(budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId(userId, targetYearMonth, 1L)) + .willReturn(Optional.empty()); + given(budgetRepository.findByUserIdAndYearMonthAndCategoryIdIncludingDeleted(userId, targetYearMonth, 1L)) + .willReturn(Optional.empty()); + given(userRepository.getReferenceById(userId)).willReturn(user); + given(categoryRepository.getReferenceById(1L)).willReturn(category); + given(budgetRepository.save(any())).willReturn(Budget.builder().build()); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + BudgetCopyResponse response = budgetService.copyFromLatest(userId, targetYearMonth); + + assertThat(response.copyCount()).isEqualTo(1); + verify(budgetRepository).delete(placeholderBudget); + } + + @Test + @DisplayName("최근 예산 불러오기 - 성공") + void copyFromLatest_success() { + Long userId = 1L; + String targetYearMonth = "2026-08"; + String sourceYearMonth = "2026-06"; + + TransactionCategory category = mock(TransactionCategory.class); + given(category.getId()).willReturn(1L); + given(category.getType()).willReturn(TransactionType.EXPENSE); + given(category.getName()).willReturn("식비"); + + Budget sourceBudget = Budget.builder() + .yearMonth(sourceYearMonth) + .totalBudget(new BigDecimal("1000")) + .expectedExpense(new BigDecimal("200")) + .transactionCategory(category) + .build(); + + User user = mock(User.class); + + given(budgetRepository.findByUserIdAndYearMonth(userId, targetYearMonth)).willReturn(List.of()); + given(budgetRepository.findLatestBudgetYearMonthBefore(userId, targetYearMonth)) + .willReturn(Optional.of(sourceYearMonth)); + given(budgetRepository.findByUserIdAndYearMonth(userId, sourceYearMonth)).willReturn(List.of(sourceBudget)); + given(categoryRepository.findAllByUserOrSystem(userId)).willReturn(List.of(category)); + given(budgetRepository.findByUserIdAndYearMonthAndTransactionCategoryId(userId, targetYearMonth, 1L)) + .willReturn(Optional.empty()); + given(budgetRepository.findByUserIdAndYearMonthAndCategoryIdIncludingDeleted(userId, targetYearMonth, 1L)) + .willReturn(Optional.empty()); + given(userRepository.getReferenceById(userId)).willReturn(user); + given(categoryRepository.getReferenceById(1L)).willReturn(category); + given(budgetRepository.save(any())).willReturn(Budget.builder().build()); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + BudgetCopyResponse response = budgetService.copyFromLatest(userId, targetYearMonth); + + assertThat(response.copyCount()).isEqualTo(1); + assertThat(response.items().stream().filter(BudgetCopyResponse.Item::selected).count()).isEqualTo(1); + verify(budgetRepository, times(1)).save(any(Budget.class)); + verify(user, times(1)).updateLastBudgetAlertMonth(null); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentServiceTest.java new file mode 100644 index 0000000..1398efe --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/AdminCommentServiceTest.java @@ -0,0 +1,118 @@ +package com.chaewookim.accountbookformoms.domain.comment.application; + +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.AdminCommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class AdminCommentServiceTest { + + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private AdminCommentService adminCommentService; + + private static final Long COMMENT_ID = 1000L; + private static final Long OWNER_ID = 1L; + private static final Long POST_ID = 100L; + + private Comment buildComment(Long id, Long userId) { + Comment comment = Comment.builder() + .userId(userId) + .referenceId(POST_ID) + .referenceType(ReferenceType.QNA) + .parentId(null) + .content("내용") + .build(); + ReflectionTestUtils.setField(comment, "id", id); + return comment; + } + + @Test + @DisplayName("성공 — 관리자가 댓글을 admin-deleted로 표시") + void deleteByAdmin_success() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + Long deletedId = adminCommentService.deleteByAdmin(COMMENT_ID); + + // then + assertThat(deletedId).isEqualTo(COMMENT_ID); + assertThat(comment.isAdminDeleted()).isTrue(); + } + + @Test + @DisplayName("실패 — 존재하지 않는 댓글이면 COMMENT_NOT_FOUND") + void deleteByAdmin_fail_not_found() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminCommentService.deleteByAdmin(COMMENT_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("list — 필터 없으면 findAll(pageable) 위임") + void list_no_filter() { + Pageable pageable = PageRequest.of(0, 10); + Comment c = buildComment(COMMENT_ID, OWNER_ID); + given(commentRepository.findAll(pageable)).willReturn(new PageImpl<>(List.of(c), pageable, 1)); + + Page result = adminCommentService.list(null, null, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(COMMENT_ID); + } + + @Test + @DisplayName("list — referenceType 만 지정 시 typed 조회") + void list_by_type() { + Pageable pageable = PageRequest.of(0, 10); + Comment c = buildComment(COMMENT_ID, OWNER_ID); + given(commentRepository.findByReferenceType(ReferenceType.QNA, pageable)) + .willReturn(new PageImpl<>(List.of(c), pageable, 1)); + + Page result = adminCommentService.list(ReferenceType.QNA, null, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("list — type + refId 둘 다 지정 시 좁힌 조회") + void list_by_type_and_ref() { + Pageable pageable = PageRequest.of(0, 10); + Comment c = buildComment(COMMENT_ID, OWNER_ID); + given(commentRepository.findByReferenceIdAndReferenceType(POST_ID, ReferenceType.QNA, pageable)) + .willReturn(new PageImpl<>(List.of(c), pageable, 1)); + + Page result = adminCommentService.list(ReferenceType.QNA, POST_ID, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentServiceTest.java new file mode 100644 index 0000000..8830bf0 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/comment/application/CommentServiceTest.java @@ -0,0 +1,417 @@ +package com.chaewookim.accountbookformoms.domain.comment.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentCreateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.request.CommentUpdateRequest; +import com.chaewookim.accountbookformoms.domain.comment.dto.response.CommentResponse; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.like.application.PostLikeService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock + private CommentRepository commentRepository; + + @Mock + private BoardRepository boardRepository; + + @Mock + private GroupPurchaseRepository groupPurchaseRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PostLikeService likeService; + @InjectMocks + private CommentService commentService; + + private static final Long OWNER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long POST_ID = 100L; + private static final Long COMMENT_ID = 1000L; + private static final Long PARENT_ID = 999L; + private static final Long REPLY_ID = 1001L; + + private Comment buildComment(Long id, Long userId, Long referenceId, ReferenceType type, Long parentId) { + Comment comment = Comment.builder() + .userId(userId) + .referenceId(referenceId) + .referenceType(type) + .parentId(parentId) + .content("본문") + .build(); + ReflectionTestUtils.setField(comment, "id", id); + return comment; + } + + @Nested + @DisplayName("댓글 생성") + class Create_ { + + @Test + @DisplayName("성공 — 댓글이 저장되고 id 반환") + void create_success() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "새 댓글", false); + Comment saved = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.save(any(Comment.class))).willReturn(saved); + + // when + Long resultId = commentService.create(POST_ID, request, OWNER_ID); + + // then + assertThat(resultId).isEqualTo(COMMENT_ID); + verify(commentRepository).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 대상 QNA 게시글이 존재하지 않으면 BOARD_NOT_FOUND") + void create_fail_board_not_found() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "새 댓글", false); + given(boardRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> commentService.create(POST_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 대상 공동구매 글이 존재하지 않으면 GROUP_PURCHASE_NOT_FOUND") + void create_fail_group_purchase_not_found() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.GROUPPURCHASE, "새 댓글", false); + given(groupPurchaseRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> commentService.create(POST_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_NOT_FOUND); + + verify(commentRepository, never()).save(any(Comment.class)); + } + } + + @Nested + @DisplayName("대댓글 생성") + class Reply_ { + + @Test + @DisplayName("성공 — 1depth 댓글에 대댓글 작성") + void reply_success() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대댓글", false); + Comment parent = buildComment(PARENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + Comment saved = buildComment(REPLY_ID, OWNER_ID, POST_ID, ReferenceType.QNA, PARENT_ID); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + given(commentRepository.save(any(Comment.class))).willReturn(saved); + + // when + Long resultId = commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID); + + // then + assertThat(resultId).isEqualTo(REPLY_ID); + verify(commentRepository).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 대댓글 작성 시 대상 QNA 게시글이 존재하지 않으면 BOARD_NOT_FOUND") + void reply_fail_board_not_found() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대댓글", false); + given(boardRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 대댓글 작성 시 대상 공동구매 글이 존재하지 않으면 GROUP_PURCHASE_NOT_FOUND") + void reply_fail_group_purchase_not_found() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.GROUPPURCHASE, "대댓글", false); + given(groupPurchaseRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_NOT_FOUND); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 부모 댓글이 존재하지 않으면 COMMENT_NOT_FOUND") + void reply_fail_parent_not_found() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대댓글", false); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_NOT_FOUND); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 부모가 이미 대댓글이면 COMMENT_REPLY_DEPTH_EXCEEDED") + void reply_fail_depth_exceeded() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대대댓글", false); + Comment parentReply = buildComment(PARENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, 998L); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parentReply)); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_REPLY_DEPTH_EXCEEDED); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 부모의 referenceId가 다르면 COMMENT_REFERENCE_MISMATCH") + void reply_fail_reference_id_mismatch() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대댓글", false); + Comment parent = buildComment(PARENT_ID, OWNER_ID, 999L, ReferenceType.QNA, null); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_REFERENCE_MISMATCH); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 부모의 referenceType이 다르면 COMMENT_REFERENCE_MISMATCH") + void reply_fail_reference_type_mismatch() { + // given + CommentCreateRequest request = new CommentCreateRequest(ReferenceType.QNA, "대댓글", false); + Comment parent = buildComment(PARENT_ID, OWNER_ID, POST_ID, ReferenceType.GROUPPURCHASE, null); + given(boardRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + + // when & then + assertThatThrownBy(() -> commentService.reply(POST_ID, PARENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_REFERENCE_MISMATCH); + + verify(commentRepository, never()).save(any(Comment.class)); + } + } + + @Nested + @DisplayName("댓글 수정") + class Update_ { + + @Test + @DisplayName("성공 — 작성자가 본인 댓글 수정") + void update_success() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 내용"); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + Long resultId = commentService.update(COMMENT_ID, request, OWNER_ID); + + // then + assertThat(resultId).isEqualTo(COMMENT_ID); + assertThat(comment.getContent()).isEqualTo("수정된 내용"); + } + + @Test + @DisplayName("실패 — 존재하지 않으면 COMMENT_NOT_FOUND") + void update_fail_not_found() { + // given + CommentUpdateRequest request = new CommentUpdateRequest("수정"); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.update(COMMENT_ID, request, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("실패 — 작성자가 아니면 COMMENT_ACCESS_DENIED 및 내용 미변경") + void update_fail_not_owner() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + String originalContent = comment.getContent(); + CommentUpdateRequest request = new CommentUpdateRequest("hacked"); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.update(COMMENT_ID, request, OTHER_USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_ACCESS_DENIED); + + assertThat(comment.getContent()).isEqualTo(originalContent); + } + } + + @Nested + @DisplayName("댓글 삭제") + class Delete_ { + + @Test + @DisplayName("성공 — 작성자가 본인 댓글 삭제") + void delete_success() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + Long resultId = commentService.delete(COMMENT_ID, OWNER_ID); + + // then + assertThat(resultId).isEqualTo(COMMENT_ID); + verify(commentRepository).delete(comment); + } + + @Test + @DisplayName("실패 — 존재하지 않으면 COMMENT_NOT_FOUND") + void delete_fail_not_found() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.delete(COMMENT_ID, OWNER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_NOT_FOUND); + + verify(commentRepository, never()).delete(any(Comment.class)); + } + + @Test + @DisplayName("실패 — 작성자가 아니면 COMMENT_ACCESS_DENIED 및 삭제 미호출") + void delete_fail_not_owner() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.delete(COMMENT_ID, OTHER_USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_ACCESS_DENIED); + + verify(commentRepository, never()).delete(any(Comment.class)); + } + } + + @Nested + @DisplayName("댓글 목록 조회") + class List_ { + + @Test + @DisplayName("성공 — 게시물의 댓글 목록을 CommentResponse로 매핑") + void list_success() { + // given + Comment comment = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + given(commentRepository.findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc(POST_ID, ReferenceType.QNA)) + .willReturn(List.of(comment)); + + User mockUser = mock(User.class); + given(mockUser.getId()).willReturn(OWNER_ID); + given(mockUser.getUsername()).willReturn("홍길동"); + given(userRepository.findAllById(List.of(OWNER_ID))).willReturn(List.of(mockUser)); + + // when + List result = commentService.list(POST_ID, ReferenceType.QNA, null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).id()).isEqualTo(COMMENT_ID); + assertThat(result.get(0).content()).isEqualTo("본문"); + assertThat(result.get(0).authorNickname()).isEqualTo("홍길동"); + assertThat(result.get(0).deleted()).isFalse(); + } + + @Test + @DisplayName("성공 — 빈 결과면 빈 리스트 반환") + void list_empty() { + // given + given(commentRepository.findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc(POST_ID, ReferenceType.QNA)) + .willReturn(List.of()); + + // when + List result = commentService.list(POST_ID, ReferenceType.QNA, null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("성공 — soft-delete된 댓글은 안내 문구로 치환") + void list_soft_deleted_shows_placeholder() { + // given + Comment deleted = buildComment(COMMENT_ID, OWNER_ID, POST_ID, ReferenceType.QNA, null); + ReflectionTestUtils.setField(deleted, "deletedAt", LocalDateTime.now()); + given(commentRepository.findByReferenceIdAndReferenceTypeOrderByCreatedAtAsc(POST_ID, ReferenceType.QNA)) + .willReturn(List.of(deleted)); + + User mockUser = mock(User.class); + given(mockUser.getId()).willReturn(OWNER_ID); + given(mockUser.getUsername()).willReturn("홍길동"); + given(userRepository.findAllById(List.of(OWNER_ID))).willReturn(List.of(mockUser)); + + // when + List result = commentService.list(POST_ID, ReferenceType.QNA, null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).deleted()).isTrue(); + assertThat(result.get(0).content()).isEqualTo("삭제된 댓글입니다."); + assertThat(result.get(0).authorNickname()).isEqualTo("홍길동"); + } + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardServiceTest.java new file mode 100644 index 0000000..aa48a2c --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/dashboard/application/DashboardServiceTest.java @@ -0,0 +1,148 @@ +package com.chaewookim.accountbookformoms.domain.dashboard.application; + +import com.chaewookim.accountbookformoms.domain.asset.application.MonthlyAllocationService; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.AllocationBucketResponse; +import com.chaewookim.accountbookformoms.domain.asset.dto.response.MonthlyAllocationResponse; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetService; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetSummaryResponse; +import com.chaewookim.accountbookformoms.domain.dashboard.dto.response.DashboardResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.application.PortfolioService; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.MyPortfolioResponse; +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 java.math.BigDecimal; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class DashboardServiceTest { + + @Mock + private BudgetService budgetService; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private PortfolioService portfolioService; + + @Mock + private MonthlyAllocationService monthlyAllocationService; + + @InjectMocks + private DashboardService dashboardService; + + @Test + @DisplayName("대시보드 조회 - 성공") + void getDashboard_Success() { + + Long userId = 1L; + String yearMonth = "2026-06"; + List categoryList = Collections.singletonList(new Object[]{"식비", new BigDecimal("50000")}); + List trendList = Collections.singletonList(new Object[]{"2026-06", new BigDecimal("100000"), new BigDecimal("50000")}); + + BudgetSummaryResponse budgetSummary = new BudgetSummaryResponse( + yearMonth, + new BigDecimal("100000"), + new BigDecimal("50000"), + new BigDecimal("50000") + ); + + MonthlyAllocationResponse allocation = new MonthlyAllocationResponse( + new AllocationBucketResponse( + new BigDecimal("200000"), + new BigDecimal("20.0"), + new BigDecimal("200000"), + BigDecimal.ZERO + ), + new AllocationBucketResponse( + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO + ) + ); + + doReturn(categoryList).when(transactionRepository).sumCategoryExpense(eq(userId), eq(yearMonth)); + doReturn(trendList).when(transactionRepository).sumMonthlyTrends(eq(userId), any()); + given(budgetService.getMonthlyBudgetSummary(eq(userId), eq(yearMonth))).willReturn(budgetSummary); + given(portfolioService.getMyPortfolio(userId, yearMonth)).willReturn( + new MyPortfolioResponse( + yearMonth, + new BigDecimal("1000000"), + new BigDecimal("500000"), + new BigDecimal("100000"), + new BigDecimal("500000"), + null, + null, + null + ) + ); + given(monthlyAllocationService.computeMonthlyAllocation(userId, yearMonth, new BigDecimal("1000000"))) + .willReturn(allocation); + given(monthlyAllocationService.buildGoalProgress(userId)).willReturn(List.of()); + given(monthlyAllocationService.computeTotalAsset(userId)).willReturn(new BigDecimal("1500000")); + + DashboardResponse response = dashboardService.getDashboard(userId, yearMonth); + + assertThat(response).isNotNull(); + assertThat(response.categoryExpenses()).containsEntry("식비", new BigDecimal("50000")); + assertThat(response.trends()).hasSize(1); + assertThat(response.budgetStatus().actualExpense()).isEqualByComparingTo("50000"); + assertThat(response.allocation().savings().rate()).isEqualByComparingTo("20.0"); + assertThat(response.totalAsset()).isEqualByComparingTo("1500000"); + } + + @Test + @DisplayName("대시보드 조회 - 동일 카테고리명 합산 및 숫자 타입 변환") + void getDashboard_mergeDuplicateCategories() { + Long userId = 1L; + String yearMonth = "2026-06"; + List categoryList = List.of( + new Object[]{"식비", 50000L}, + new Object[]{"식비", Double.valueOf(30000.0)} + ); + List trendList = Collections.singletonList( + new Object[]{"2026-06", 100000L, Double.valueOf(50000.0)} + ); + + BudgetSummaryResponse budgetSummary = new BudgetSummaryResponse( + yearMonth, + new BigDecimal("100000"), + new BigDecimal("50000"), + new BigDecimal("50000") + ); + + MonthlyAllocationResponse allocation = new MonthlyAllocationResponse( + new AllocationBucketResponse(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO), + new AllocationBucketResponse(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO) + ); + + doReturn(categoryList).when(transactionRepository).sumCategoryExpense(eq(userId), eq(yearMonth)); + doReturn(trendList).when(transactionRepository).sumMonthlyTrends(eq(userId), any()); + given(budgetService.getMonthlyBudgetSummary(eq(userId), eq(yearMonth))).willReturn(budgetSummary); + given(portfolioService.getMyPortfolio(userId, yearMonth)).willReturn( + new MyPortfolioResponse(yearMonth, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, null, null) + ); + given(monthlyAllocationService.computeMonthlyAllocation(eq(userId), eq(yearMonth), any())).willReturn(allocation); + given(monthlyAllocationService.buildGoalProgress(userId)).willReturn(List.of()); + given(monthlyAllocationService.computeTotalAsset(userId)).willReturn(BigDecimal.ZERO); + + DashboardResponse response = dashboardService.getDashboard(userId, yearMonth); + + assertThat(response.categoryExpenses().get("식비")).isEqualByComparingTo("80000"); + assertThat(response.trends()).hasSize(1); + assertThat(response.trends().get(0).income()).isEqualByComparingTo("100000"); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowServiceTest.java new file mode 100644 index 0000000..96e405f --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/follow/application/FollowServiceTest.java @@ -0,0 +1,122 @@ +package com.chaewookim.accountbookformoms.domain.follow.application; + +import com.chaewookim.accountbookformoms.domain.follow.dao.FollowRepository; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowToggleResponse; +import com.chaewookim.accountbookformoms.domain.follow.dto.response.FollowUserResponse; +import com.chaewookim.accountbookformoms.domain.follow.entity.Follow; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FollowServiceTest { + + @Mock private FollowRepository followRepository; + @Mock private UserRepository userRepository; + + @InjectMocks + private FollowService followService; + + private static final Long FOLLOWER = 1L; + private static final Long TARGET = 2L; + + @Test + @DisplayName("toggle — 팔로우 없던 상태에서 저장 후 following=true") + void toggle_add() { + given(userRepository.existsById(TARGET)).willReturn(true); + given(followRepository.findByFollowerIdAndFollowingId(FOLLOWER, TARGET)).willReturn(Optional.empty()); + given(followRepository.countByFollowingId(TARGET)).willReturn(1L); + + FollowToggleResponse response = followService.toggle(TARGET, FOLLOWER); + + assertThat(response.following()).isTrue(); + assertThat(response.followerCount()).isEqualTo(1L); + verify(followRepository).save(any(Follow.class)); + } + + @Test + @DisplayName("toggle — 이미 팔로우 상태였다면 삭제 후 following=false") + void toggle_remove() { + Follow existing = Follow.builder().followerId(FOLLOWER).followingId(TARGET).build(); + given(userRepository.existsById(TARGET)).willReturn(true); + given(followRepository.findByFollowerIdAndFollowingId(FOLLOWER, TARGET)).willReturn(Optional.of(existing)); + given(followRepository.countByFollowingId(TARGET)).willReturn(0L); + + FollowToggleResponse response = followService.toggle(TARGET, FOLLOWER); + + assertThat(response.following()).isFalse(); + verify(followRepository).delete(existing); + } + + @Test + @DisplayName("toggle 실패 — 자기 자신 팔로우는 USER_NOT_FOUND (self-follow 차단)") + void toggle_self_forbidden() { + assertThatThrownBy(() -> followService.toggle(FOLLOWER, FOLLOWER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + verify(followRepository, never()).save(any()); + } + + @Test + @DisplayName("toggle 실패 — 대상 유저가 없으면 USER_NOT_FOUND") + void toggle_target_missing() { + given(userRepository.existsById(TARGET)).willReturn(false); + + assertThatThrownBy(() -> followService.toggle(TARGET, FOLLOWER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("isFollowing — viewerId null 이면 항상 false") + void isFollowing_null_viewer() { + assertThat(followService.isFollowing(TARGET, null)).isFalse(); + verify(followRepository, never()).findByFollowerIdAndFollowingId(any(), any()); + } + + @Test + @DisplayName("followerCount / followingCount — repository 위임") + void counts_delegated() { + given(followRepository.countByFollowingId(TARGET)).willReturn(5L); + given(followRepository.countByFollowerId(TARGET)).willReturn(3L); + + assertThat(followService.followerCount(TARGET)).isEqualTo(5L); + assertThat(followService.followingCount(TARGET)).isEqualTo(3L); + } + + @Test + @DisplayName("listFollowers — 팔로워 id 기반으로 닉네임 매칭, 없는 id는 '탈퇴한 사용자'") + void list_followers_resolves_unknown() { + Follow f1 = Follow.builder().followerId(10L).followingId(TARGET).build(); + Follow f2 = Follow.builder().followerId(11L).followingId(TARGET).build(); + given(followRepository.findByFollowingId(TARGET)).willReturn(List.of(f1, f2)); + + User u10 = mock(User.class); + given(u10.getId()).willReturn(10L); + given(u10.getUsername()).willReturn("한사람"); + given(userRepository.findAllById(List.of(10L, 11L))).willReturn(List.of(u10)); + + List result = followService.listFollowers(TARGET); + + assertThat(result).extracting(FollowUserResponse::userId).containsExactly(10L, 11L); + assertThat(result).extracting(FollowUserResponse::nickname).containsExactly("한사람", "탈퇴한 사용자"); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseControllerTest.java new file mode 100644 index 0000000..552ae58 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminGroupPurchaseControllerTest.java @@ -0,0 +1,111 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseAdminResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseDashboardResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class AdminGroupPurchaseControllerTest extends ControllerTestSupport { + + private UserPrincipal adminPrincipal; + private UserPrincipal userPrincipal; + + @BeforeEach + void setUp() { + User admin = User.forTestBuilder() + .id(1L) + .email("admin@test.com") + .role(UserRole.ROLE_ADMIN) + .build(); + adminPrincipal = UserPrincipal.create(admin); + + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + } + + @Test + @DisplayName("어드민 요약 정보 조회 성공 — ROLE_ADMIN 권한 소유") + void getSummary_admin_success() throws Exception { + // given + GroupPurchaseDashboardResponse response = new GroupPurchaseDashboardResponse( + 5L, 10L, 3L, 2L, 0L, 60.0, 40.0, 0.0 + ); + given(groupPurchaseService.getDashboardSummary()).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/admin/group-purchases/summary") + .with(user(adminPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.todayCreatedCount").value(5L)) + .andExpect(jsonPath("$.data.recruitingRatio").value(60.0)); + } + + @Test + @DisplayName("어드민 요약 정보 조회 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void getSummary_user_forbidden() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/admin/group-purchases/summary") + .with(user(userPrincipal))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("어드민용 전체 공구 목록 모니터링 조회 성공 — ROLE_ADMIN 권한 소유") + void getGroupPurchases_admin_success() throws Exception { + // given + GroupPurchaseAdminResponse mockAdminResponse = new GroupPurchaseAdminResponse( + 101L, 2L, "개설자닉네임", 3L, "식료품", "맛있는 밀키트 공구", + 15000, 3, 5, 2, PurchaseStatus.RECRUITING, + LocalDateTime.now().plusDays(2), LocalDateTime.now(), 0L + ); + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(List.of(mockAdminResponse), pageable, 1); + + given(groupPurchaseService.getGroupPurchasesForAdmin(eq("RECRUITING"), any(Pageable.class))) + .willReturn(page); + + // when & then + mockMvc.perform(get("/api/v1/admin/group-purchases") + .with(user(adminPrincipal)) + .param("status", "RECRUITING") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(101L)) + .andExpect(jsonPath("$.data.content[0].creatorUsername").value("개설자닉네임")); + } + + @Test + @DisplayName("어드민용 전체 공구 목록 모니터링 조회 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void getGroupPurchases_user_forbidden() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/admin/group-purchases") + .with(user(userPrincipal)) + .param("status", "RECRUITING")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportControllerTest.java new file mode 100644 index 0000000..77214c6 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/AdminReportControllerTest.java @@ -0,0 +1,122 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportProcessRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ReportResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminReportControllerTest extends ControllerTestSupport { + + private UserPrincipal adminPrincipal; + private UserPrincipal userPrincipal; + private ReportResponse mockResponse; + + @BeforeEach + void setUp() { + User admin = User.forTestBuilder() + .id(1L) + .email("admin@test.com") + .role(UserRole.ROLE_ADMIN) + .build(); + adminPrincipal = UserPrincipal.create(admin); + + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + + mockResponse = new ReportResponse( + 901L, 2L, ReportTargetType.GROUP_PURCHASE, 101L, "스팸성 광고 글", + false, null, null, null, LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("어드민용 신고 목록 조회 성공 — ROLE_ADMIN 권한 소유") + void getReports_admin_success() throws Exception { + // given + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(List.of(mockResponse), pageable, 1); + given(reportService.getReports(eq(false), eq(ReportTargetType.GROUP_PURCHASE), any(Pageable.class))).willReturn(page); + + // when & then + mockMvc.perform(get("/api/v1/admin/reports") + .with(user(adminPrincipal)) + .param("isProcessed", "false") + .param("targetType", "GROUP_PURCHASE") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(901L)) + .andExpect(jsonPath("$.data.content[0].reason").value("스팸성 광고 글")); + } + + @Test + @DisplayName("어드민용 신고 목록 조회 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void getReports_user_forbidden() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/admin/reports") + .with(user(userPrincipal)) + .param("isProcessed", "false")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("어드민용 신고 처리 성공 — ROLE_ADMIN 권한 소유") + void processReport_admin_success() throws Exception { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.WARNED, "경고 조치 취함"); + given(reportService.processReport(eq(901L), any(ReportProcessRequest.class))).willReturn(901L); + + // when & then + mockMvc.perform(post("/api/v1/admin/reports/901/process") + .with(csrf()) + .with(user(adminPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(901L)); + } + + @Test + @DisplayName("어드민용 신고 처리 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void processReport_user_forbidden() throws Exception { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.WARNED, "경고 조치 취함"); + + // when & then + mockMvc.perform(post("/api/v1/admin/reports/901/process") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ControllerTestSupport.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ControllerTestSupport.java new file mode 100644 index 0000000..91b6ad4 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ControllerTestSupport.java @@ -0,0 +1,70 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.chaewookim.accountbookformoms.global.security.jwt.JwtTokenProvider; +import com.chaewookim.accountbookformoms.global.security.oauth2.CustomOAuth2UserService; +import com.chaewookim.accountbookformoms.global.security.oauth2.OAuth2SuccessHandler; +import com.chaewookim.accountbookformoms.domain.grouppruchase.application.*; +import com.chaewookim.accountbookformoms.global.security.config.SecurityConfig; +import com.chaewookim.accountbookformoms.global.error.GlobalExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = { + GroupPurchaseController.class, + AdminGroupPurchaseController.class, + GroupPurchaseApplicationController.class, + ProductController.class, + GroupPurchaseCategoryController.class, + ReportController.class, + AdminReportController.class +}) +@Import({SecurityConfig.class, GlobalExceptionHandler.class}) +public abstract class ControllerTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @MockBean + protected JwtTokenProvider jwtTokenProvider; + + @MockBean + protected CustomOAuth2UserService customOAuth2UserService; + + @MockBean + protected OAuth2SuccessHandler oAuth2SuccessHandler; + + @MockBean + protected com.chaewookim.accountbookformoms.global.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @MockBean + protected GroupPurchaseService groupPurchaseService; + + @MockBean + protected GroupPurchaseLockFacade groupPurchaseLockFacade; + + @MockBean + protected GroupPurchaseApplicationService groupPurchaseApplicationService; + + @MockBean + protected ProductService productService; + + @MockBean + protected GroupPurchaseCategoryService groupPurchaseCategoryService; + + @MockBean + protected ReportService reportService; + + @MockBean + protected com.chaewookim.accountbookformoms.global.security.config.CorsProperties corsProperties; +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationControllerTest.java new file mode 100644 index 0000000..a8ab263 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseApplicationControllerTest.java @@ -0,0 +1,137 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ApplicationStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseApplicationCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseApplicationResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class GroupPurchaseApplicationControllerTest extends ControllerTestSupport { + + private UserPrincipal adminPrincipal; + private UserPrincipal userPrincipal; + private GroupPurchaseApplicationResponse mockResponse; + + @BeforeEach + void setUp() { + User admin = User.forTestBuilder() + .id(1L) + .email("admin@test.com") + .role(UserRole.ROLE_ADMIN) + .build(); + adminPrincipal = UserPrincipal.create(admin); + + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + + mockResponse = new GroupPurchaseApplicationResponse( + 501L, 2L, "신청 제목", "신청 내용", "http://product.url", + 10000, ApplicationStatus.PENDING, null, LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("공동구매 신청서 등록 성공") + void createApplication_success() throws Exception { + // given + GroupPurchaseApplicationCreateRequest request = new GroupPurchaseApplicationCreateRequest( + "신청 제목", "신청 내용", "http://product.url", 10000 + ); + given(groupPurchaseApplicationService.createApplication(any(), any(GroupPurchaseApplicationCreateRequest.class))) + .willReturn(mockResponse); + + // when & then + mockMvc.perform(post("/api/v1/group-purchase-applications") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(501L)) + .andExpect(jsonPath("$.data.title").value("신청 제목")); + } + + @Test + @DisplayName("본인의 공동구매 신청서 목록 조회 성공") + void getMyApplications_success() throws Exception { + // given + given(groupPurchaseApplicationService.getUserApplications(any())).willReturn(List.of(mockResponse)); + + // when & then + mockMvc.perform(get("/api/v1/group-purchase-applications/me") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(501L)); + } + + @Test + @DisplayName("공동구매 신청서 상세 조회 성공") + void getApplicationDetail_success() throws Exception { + // given + given(groupPurchaseApplicationService.getApplication(any(), any())).willReturn(mockResponse); + + // when & then + mockMvc.perform(get("/api/v1/group-purchase-applications/501") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(501L)); + } + + @Test + @DisplayName("어드민 공동구매 신청서 상태 수정 성공 — ROLE_ADMIN 권한 소유") + void updateApplicationStatus_admin_success() throws Exception { + // given + GroupPurchaseApplicationResponse approvedResponse = new GroupPurchaseApplicationResponse( + 501L, 2L, "신청 제목", "신청 내용", "http://product.url", + 10000, ApplicationStatus.APPROVED, "승인 피드백", LocalDateTime.now(), LocalDateTime.now() + ); + given(groupPurchaseApplicationService.updateApplicationStatus(eq(501L), eq(ApplicationStatus.APPROVED), eq("승인 피드백"))) + .willReturn(approvedResponse); + + // when & then + mockMvc.perform(patch("/api/v1/group-purchase-applications/501/status") + .with(csrf()) + .with(user(adminPrincipal)) + .param("status", "APPROVED") + .param("feedback", "승인 피드백")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.adminFeedback").value("승인 피드백")); + } + + @Test + @DisplayName("어드민 공동구매 신청서 상태 수정 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void updateApplicationStatus_user_forbidden() throws Exception { + // when & then + mockMvc.perform(patch("/api/v1/group-purchase-applications/501/status") + .with(csrf()) + .with(user(userPrincipal)) + .param("status", "APPROVED") + .param("feedback", "승인 피드백")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryControllerTest.java new file mode 100644 index 0000000..e9823a5 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseCategoryControllerTest.java @@ -0,0 +1,168 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCategoryUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseCategoryResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class GroupPurchaseCategoryControllerTest extends ControllerTestSupport { + + private UserPrincipal adminPrincipal; + private UserPrincipal userPrincipal; + private GroupPurchaseCategoryResponse mockResponse; + + @BeforeEach + void setUp() { + User admin = User.forTestBuilder() + .id(1L) + .email("admin@test.com") + .role(UserRole.ROLE_ADMIN) + .build(); + adminPrincipal = UserPrincipal.create(admin); + + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + + mockResponse = new GroupPurchaseCategoryResponse(3L, "식료품", 1, LocalDateTime.now(), LocalDateTime.now()); + } + + @Test + @DisplayName("카테고리 등록 성공 — ROLE_ADMIN 권한 소유") + void createCategory_admin_success() throws Exception { + // given + GroupPurchaseCategoryCreateRequest request = new GroupPurchaseCategoryCreateRequest("식료품", 1); + given(groupPurchaseCategoryService.createCategory(any(GroupPurchaseCategoryCreateRequest.class))).willReturn(mockResponse); + + // when & then + mockMvc.perform(post("/api/v1/group-purchase-categories") + .with(csrf()) + .with(user(adminPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(3L)) + .andExpect(jsonPath("$.data.name").value("식료품")); + } + + @Test + @DisplayName("카테고리 등록 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void createCategory_user_forbidden() throws Exception { + // given + GroupPurchaseCategoryCreateRequest request = new GroupPurchaseCategoryCreateRequest("식료품", 1); + + // when & then + mockMvc.perform(post("/api/v1/group-purchase-categories") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("카테고리 단건 조회 성공") + void getOneCategory_success() throws Exception { + // given + given(groupPurchaseCategoryService.getCategory(3L)).willReturn(mockResponse); + + // when & then + mockMvc.perform(get("/api/v1/group-purchase-categories/3") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(3L)); + } + + @Test + @DisplayName("카테고리 전체 조회 성공") + void getAllCategories_success() throws Exception { + // given + given(groupPurchaseCategoryService.getAllCategories()).willReturn(List.of(mockResponse)); + + // when & then + mockMvc.perform(get("/api/v1/group-purchase-categories") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(3L)); + } + + @Test + @DisplayName("카테고리 수정 성공 — ROLE_ADMIN 권한 소유") + void updateCategory_admin_success() throws Exception { + // given + GroupPurchaseCategoryUpdateRequest request = new GroupPurchaseCategoryUpdateRequest("수정된 식료품", 2); + GroupPurchaseCategoryResponse updatedResponse = new GroupPurchaseCategoryResponse(3L, "수정된 식료품", 2, LocalDateTime.now(), LocalDateTime.now()); + given(groupPurchaseCategoryService.updateCategory(eq(3L), any(GroupPurchaseCategoryUpdateRequest.class))).willReturn(updatedResponse); + + // when & then + mockMvc.perform(put("/api/v1/group-purchase-categories/3") + .with(csrf()) + .with(user(adminPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.name").value("수정된 식료품")) + .andExpect(jsonPath("$.data.sortOrder").value(2)); + } + + @Test + @DisplayName("카테고리 수정 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void updateCategory_user_forbidden() throws Exception { + // given + GroupPurchaseCategoryUpdateRequest request = new GroupPurchaseCategoryUpdateRequest("수정된 식료품", 2); + + // when & then + mockMvc.perform(put("/api/v1/group-purchase-categories/3") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("카테고리 삭제 성공 — ROLE_ADMIN 권한 소유") + void deleteCategory_admin_success() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/group-purchase-categories/3") + .with(csrf()) + .with(user(adminPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("카테고리가 성공적으로 삭제되었습니다.")); + } + + @Test + @DisplayName("카테고리 삭제 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void deleteCategory_user_forbidden() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/group-purchase-categories/3") + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseControllerTest.java new file mode 100644 index 0000000..b20370e --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/GroupPurchaseControllerTest.java @@ -0,0 +1,215 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseParticipantRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseUpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class GroupPurchaseControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private GroupPurchaseCategoryRepository categoryRepository; + + @Autowired + private GroupPurchaseRepository groupPurchaseRepository; + + @Autowired + private GroupPurchaseParticipantRepository participantRepository; + + private User savedUser; + private UserPrincipal userPrincipal; + private Category savedCategory; + private GroupPurchase savedGroupPurchase; + + @BeforeEach + void setUp() { + // 실제 데이터 세팅 (외래 키 충돌 방지를 위해 고유한 이메일 사용) + String uniqueSuffix = String.valueOf(System.currentTimeMillis()); + User user = User.builder() + .email("test_" + uniqueSuffix + "@test.com") + .password("1234") + .username("테스트유저") + .role(UserRole.ROLE_USER) + .build(); + savedUser = userRepository.save(user); + userPrincipal = UserPrincipal.create(savedUser); + + Category category = Category.builder().name("테스트카테고리_" + uniqueSuffix).build(); + savedCategory = categoryRepository.save(category); + + GroupPurchase gp = GroupPurchase.builder() + .creatorId(savedUser.getId()) + .categoryId(savedCategory.getId()) + .title("테스트 공동구매") + .content("상세 설명") + .price(15000) + .minParticipants(3) + .maxParticipants(5) + .deadline(LocalDateTime.now().plusDays(5)) + .pickupLocation("테스트 장소") + .build(); + savedGroupPurchase = groupPurchaseRepository.save(gp); + } + + @AfterEach + void tearDown() { + // FK 제약 조건 때문에 일괄 삭제 생략 또는 필요한 부분만 정리 + } + + @Test + @DisplayName("공동구매 개설 성공") + void createGroupPurchase_success() throws Exception { + GroupPurchaseCreateRequest request = new GroupPurchaseCreateRequest( + savedCategory.getId(), "새로운 공구", "새로운 설명", 20000, 2, 10, + LocalDateTime.now().plusDays(5), "새로운 장소", "http://image.com/test.jpg" + ); + + mockMvc.perform(post("/api/v1/group-purchases") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("새로운 공구")); + } + + @Test + @DisplayName("공동구매 단건 조회 성공") + void getOneGroupPurchase_success() throws Exception { + mockMvc.perform(get("/api/v1/group-purchases/" + savedGroupPurchase.getId()) + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(savedGroupPurchase.getId())) + .andExpect(jsonPath("$.data.title").value("테스트 공동구매")); + } + + @Test + @DisplayName("공동구매 목록 조회 성공") + void getAllGroupPurchases_success() throws Exception { + mockMvc.perform(get("/api/v1/group-purchases") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + @DisplayName("공동구매 수정 성공") + void updateGroupPurchase_success() throws Exception { + GroupPurchaseUpdateRequest request = new GroupPurchaseUpdateRequest( + savedCategory.getId(), "수정된 공구", "수정된 설명", 18000, 4, 8, + LocalDateTime.now().plusDays(7), "수정된 장소", "http://image.com/updated.jpg" + ); + + mockMvc.perform(put("/api/v1/group-purchases/" + savedGroupPurchase.getId()) + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("수정된 공구")); + } + + @Test + @DisplayName("공동구매 삭제 성공") + void deleteGroupPurchase_success() throws Exception { + mockMvc.perform(delete("/api/v1/group-purchases/" + savedGroupPurchase.getId()) + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("공동구매 찜하기 토글 성공") + void toggleWish_success() throws Exception { + mockMvc.perform(post("/api/v1/group-purchases/" + savedGroupPurchase.getId() + "/wish") + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(true)); // 처음 누르면 true + } + + @Test + @DisplayName("찜한 공동구매 목록 페이징 조회 성공") + void getWishes_success() throws Exception { + // 미리 찜하기 + mockMvc.perform(post("/api/v1/group-purchases/" + savedGroupPurchase.getId() + "/wish") + .with(csrf()) + .with(user(userPrincipal))); + + mockMvc.perform(get("/api/v1/group-purchases/wishes") + .param("page", "0") + .param("size", "10") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("공동구매 참여 성공") + void joinGroupPurchase_success() throws Exception { + mockMvc.perform(post("/api/v1/group-purchases/" + savedGroupPurchase.getId() + "/join") + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.groupPurchase.id").value(savedGroupPurchase.getId())); + } + + @Test + @DisplayName("공동구매 참여 취소 성공") + void leaveGroupPurchase_success() throws Exception { + // 미리 참여 + mockMvc.perform(post("/api/v1/group-purchases/" + savedGroupPurchase.getId() + "/join") + .with(csrf()) + .with(user(userPrincipal))); + + // 취소 + mockMvc.perform(post("/api/v1/group-purchases/" + savedGroupPurchase.getId() + "/leave") + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductControllerTest.java new file mode 100644 index 0000000..60f2a14 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ProductControllerTest.java @@ -0,0 +1,174 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ProductUpdateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ProductResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ProductControllerTest extends ControllerTestSupport { + + private UserPrincipal adminPrincipal; + private UserPrincipal userPrincipal; + private ProductResponse mockResponse; + + @BeforeEach + void setUp() { + User admin = User.forTestBuilder() + .id(1L) + .email("admin@test.com") + .role(UserRole.ROLE_ADMIN) + .build(); + adminPrincipal = UserPrincipal.create(admin); + + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + + mockResponse = new ProductResponse( + 301L, "맛있는 사과", 9900, "청송 꿀사과 1kg", "http://image.com/apple.jpg", + 3L, null, null, LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("상품 등록 성공 — ROLE_ADMIN 권한 소유") + void createProduct_admin_success() throws Exception { + // given + ProductCreateRequest request = new ProductCreateRequest("맛있는 사과", 9900, "청송 꿀사과 1kg", "http://image.com/apple.jpg", 3L, null, null); + given(productService.createProduct(any(ProductCreateRequest.class))).willReturn(mockResponse); + + // when & then + mockMvc.perform(post("/api/v1/products") + .with(csrf()) + .with(user(adminPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(301L)) + .andExpect(jsonPath("$.data.name").value("맛있는 사과")); + } + + @Test + @DisplayName("상품 등록 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void createProduct_user_forbidden() throws Exception { + // given + ProductCreateRequest request = new ProductCreateRequest("맛있는 사과", 9900, "청송 꿀사과 1kg", "http://image.com/apple.jpg", 3L, null, null); + + // when & then + mockMvc.perform(post("/api/v1/products") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("상품 단건 조회 성공") + void getOneProduct_success() throws Exception { + // given + given(productService.getProduct(301L)).willReturn(mockResponse); + + // when & then + mockMvc.perform(get("/api/v1/products/301") + .with(user(userPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(301L)); + } + + @Test + @DisplayName("상품 목록 조회 성공") + void getAllProducts_success() throws Exception { + // given + given(productService.getAllProducts(3L)).willReturn(List.of(mockResponse)); + + // when & then + mockMvc.perform(get("/api/v1/products") + .with(user(userPrincipal)) + .param("categoryId", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(301L)); + } + + @Test + @DisplayName("상품 수정 성공 — ROLE_ADMIN 권한 소유") + void updateProduct_admin_success() throws Exception { + // given + ProductUpdateRequest request = new ProductUpdateRequest("수정된 사과", 18900, "청송 꿀사과 2kg", "http://image.com/apple2.jpg", 3L, null, null); + ProductResponse updatedResponse = new ProductResponse( + 301L, "수정된 사과", 18900, "청송 꿀사과 2kg", "http://image.com/apple2.jpg", + 3L, null, null, LocalDateTime.now(), LocalDateTime.now() + ); + given(productService.updateProduct(eq(301L), any(ProductUpdateRequest.class))).willReturn(updatedResponse); + + // when & then + mockMvc.perform(put("/api/v1/products/301") + .with(csrf()) + .with(user(adminPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.name").value("수정된 사과")); + } + + @Test + @DisplayName("상품 수정 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void updateProduct_user_forbidden() throws Exception { + // given + ProductUpdateRequest request = new ProductUpdateRequest("수정된 사과", 18900, "청송 꿀사과 2kg", "http://image.com/apple2.jpg", 3L, null, null); + + // when & then + mockMvc.perform(put("/api/v1/products/301") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("상품 삭제 성공 — ROLE_ADMIN 권한 소유") + void deleteProduct_admin_success() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/products/301") + .with(csrf()) + .with(user(adminPrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("상품이 성공적으로 삭제되었습니다.")); + } + + @Test + @DisplayName("상품 삭제 실패 — ROLE_USER 권한 소유 (403 Forbidden)") + void deleteProduct_user_forbidden() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/products/301") + .with(csrf()) + .with(user(userPrincipal))) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportControllerTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportControllerTest.java new file mode 100644 index 0000000..6546269 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/api/ReportControllerTest.java @@ -0,0 +1,53 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.api; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportCreateRequest; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.global.security.principal.UserPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ReportControllerTest extends ControllerTestSupport { + + private UserPrincipal userPrincipal; + + @BeforeEach + void setUp() { + User user = User.forTestBuilder() + .id(2L) + .email("user@test.com") + .role(UserRole.ROLE_USER) + .build(); + userPrincipal = UserPrincipal.create(user); + } + + @Test + @DisplayName("신고 접수 성공") + void createReport_success() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.GROUP_PURCHASE, 101L, "스팸 및 광고 게시글입니다."); + given(reportService.createReport(any(), any(ReportCreateRequest.class))).willReturn(901L); + + // when & then + mockMvc.perform(post("/api/v1/reports") + .with(csrf()) + .with(user(userPrincipal)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(901L)); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseConcurrencyTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseConcurrencyTest.java new file mode 100644 index 0000000..3f0484a --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseConcurrencyTest.java @@ -0,0 +1,131 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseParticipantRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class GroupPurchaseConcurrencyTest { + + @Autowired + private GroupPurchaseLockFacade groupPurchaseLockFacade; + + @Autowired + private GroupPurchaseRepository groupPurchaseRepository; + + @Autowired + private GroupPurchaseParticipantRepository groupPurchaseParticipantRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private GroupPurchaseCategoryRepository groupPurchaseCategoryRepository; + + private Long savedGroupPurchaseId; + private Long categoryId; + private Long creatorId; + + @BeforeEach + void setUp() { + // 1. 유저 100명 미리 생성 + for (int i = 0; i < 100; i++) { + User user = User.builder() + .email("test_concurrent_" + i + "_" + System.currentTimeMillis() + "@test.com") + .password("1234") + .username("user_concurrent_" + i) + .build(); + userRepository.save(user); + } + + // 2. 카테고리 생성 + Category category = Category.builder().name("테스트식품" + System.currentTimeMillis()).build(); + Category savedCategory = groupPurchaseCategoryRepository.save(category); + categoryId = savedCategory.getId(); + + // 3. 개설자 가져오기 + User creator = userRepository.findByEmail("test_concurrent_0_" + System.currentTimeMillis() + "@test.com").orElseGet(() -> userRepository.findAll().get(0)); + creatorId = creator.getId(); + + // 4. 공동구매 생성(정원 100명) + GroupPurchase gp = GroupPurchase.builder() + .creatorId(creatorId) + .categoryId(categoryId) + .title("동시성 테스트 공구") + .content("동시성 테스트 공구입니다.") + .price(10000) + .minParticipants(10) + .maxParticipants(100) + .deadline(LocalDateTime.now().plusDays(7)) + .pickupLocation("테스트 장소") + .build(); + GroupPurchase savedGp = groupPurchaseRepository.save(gp); + savedGroupPurchaseId = savedGp.getId(); + } + + @AfterEach + void tearDown() { + // FK 제약 조건 때문에 일괄 삭제 생략 + } + + @Test + @DisplayName("동시성 제어 테스트: 100명의 유저가 동시에 참여 버튼을 누른다") + void joinGroupPurchase_concurrency_test() throws InterruptedException { + // given + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + // 유저 100명 가져오기(각각 다른 유저가 참여) + var users = userRepository.findAll(); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + Long userId = users.get(i).getId(); + executorService.submit(() -> { + try { + groupPurchaseLockFacade.joinGroupPurchase(userId, savedGroupPurchaseId, new com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.GroupPurchaseJoinRequest(1L)); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + GroupPurchase gp = groupPurchaseRepository.findById(savedGroupPurchaseId).orElseThrow(); + + System.out.println("성공한 요청 수: " + successCount.get()); + System.out.println("실패한 요청 수: " + failCount.get()); + System.out.println("실제 DB에 기록된 참여자 수: " + gp.getCurrentParticipants()); + + // 성공한 요청 수와 DB에 기록된 참여자 수가 일치해야 함 + assertThat(gp.getCurrentParticipants()).isEqualTo(successCount.get()); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseServiceTest.java new file mode 100644 index 0000000..4660fa3 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/GroupPurchaseServiceTest.java @@ -0,0 +1,589 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.budget.dao.BudgetRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionRepository; +import com.chaewookim.accountbookformoms.domain.asset.enums.TransactionType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseJoinResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseParticipantRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.ReportRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchaseParticipant; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.WishlistRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Wishlist; +import com.chaewookim.accountbookformoms.domain.asset.application.TransactionService; +import com.chaewookim.accountbookformoms.domain.asset.dao.AccountRepository; +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.asset.entity.Account; +import com.chaewookim.accountbookformoms.domain.asset.entity.TransactionCategory; +import com.chaewookim.accountbookformoms.domain.asset.dto.request.TransactionRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.PurchaseStatus; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseDashboardResponse; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.GroupPurchaseAdminResponse; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; +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 java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class GroupPurchaseServiceTest { + + @Mock + private GroupPurchaseRepository groupPurchaseRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private GroupPurchaseCategoryRepository groupPurchaseCategoryRepository; + + @Mock + private ReportRepository reportRepository; + + @Mock + private WishlistRepository wishlistRepository; + + @Mock + private GroupPurchaseParticipantRepository groupPurchaseParticipantRepository; + + @Mock + private BudgetRepository budgetRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private TransactionService transactionService; + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionCategoryRepository transactionCategoryRepository; + + @InjectMocks + private GroupPurchaseService groupPurchaseService; + + @Test + @DisplayName("대시보드 요약 조회 성공 — 정확한 비율 연산 확인") + void getDashboardSummary_success() { + // given + given(groupPurchaseRepository.countByCreatedAtBetween(any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(5L); // 오늘 개설된 공구 수 5개 + + given(groupPurchaseRepository.sumCurrentParticipantsByStatus(PurchaseStatus.RECRUITING)) + .willReturn(15L); // 실시간 참여 중인 인원 수 15명 + + // 상태별 개수 모킹 (총 9개: RECRUITING=3, SUCCESS=2, CLOSED=1, FAILED=3) + // 진행 비율: 3/9 = 33.33% + // 성공 비율: (2+1)/9 = 33.33% + // 무산 비율: 3/9 = 33.33% + given(groupPurchaseRepository.countByStatus(PurchaseStatus.RECRUITING)).willReturn(3L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.SUCCESS)).willReturn(2L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.CLOSED)).willReturn(1L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.FAILED)).willReturn(3L); + + // when + GroupPurchaseDashboardResponse response = groupPurchaseService.getDashboardSummary(); + + // then + assertThat(response.todayCreatedCount()).isEqualTo(5L); + assertThat(response.activeParticipantsCount()).isEqualTo(15L); + assertThat(response.recruitingCount()).isEqualTo(3L); + assertThat(response.successCount()).isEqualTo(3L); // SUCCESS(2) + CLOSED(1) = 3 + assertThat(response.failedCount()).isEqualTo(3L); + assertThat(response.recruitingRatio()).isEqualTo(33.33); + assertThat(response.successRatio()).isEqualTo(33.33); + assertThat(response.failedRatio()).isEqualTo(33.33); + } + + @Test + @DisplayName("대시보드 요약 조회 성공 — 등록된 공구가 하나도 없을 때 (0 나누기 예방)") + void getDashboardSummary_empty() { + // given + given(groupPurchaseRepository.countByCreatedAtBetween(any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(0L); + given(groupPurchaseRepository.sumCurrentParticipantsByStatus(PurchaseStatus.RECRUITING)) + .willReturn(0L); + + given(groupPurchaseRepository.countByStatus(PurchaseStatus.RECRUITING)).willReturn(0L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.SUCCESS)).willReturn(0L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.CLOSED)).willReturn(0L); + given(groupPurchaseRepository.countByStatus(PurchaseStatus.FAILED)).willReturn(0L); + + // when + GroupPurchaseDashboardResponse response = groupPurchaseService.getDashboardSummary(); + + // then + assertThat(response.todayCreatedCount()).isZero(); + assertThat(response.activeParticipantsCount()).isZero(); + assertThat(response.recruitingRatio()).isZero(); + assertThat(response.successRatio()).isZero(); + assertThat(response.failedRatio()).isZero(); + } + + @Test + @DisplayName("어드민 모니터링 목록 조회 성공 — DTO 매핑 및 조인 정보 검증") + void getGroupPurchasesForAdmin_success() { + // given + Pageable pageable = PageRequest.of(0, 10); + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .categoryId(3L) + .title("공구 게시글") + .build(); + ReflectionTestUtils.setField(gp, "createdAt", LocalDateTime.now()); + + Page gpPage = new PageImpl<>(List.of(gp), pageable, 1); + given(groupPurchaseRepository.findAllForAdmin("RECRUITING", pageable)).willReturn(gpPage); + + User user = User.forTestBuilder() + .id(2L) + .username("작성자") + .build(); + given(userRepository.findById(2L)).willReturn(Optional.of(user)); + + Category category = Category.builder() + .id(3L) + .name("식료품") + .build(); + given(groupPurchaseCategoryRepository.findById(3L)).willReturn(Optional.of(category)); + + given(reportRepository.countByTargetTypeAndTargetId(ReportTargetType.GROUP_PURCHASE, 101L)).willReturn(4L); + + // when + Page responsePage = groupPurchaseService.getGroupPurchasesForAdmin("RECRUITING", pageable); + + // then + assertThat(responsePage.getContent()).hasSize(1); + GroupPurchaseAdminResponse dto = responsePage.getContent().get(0); + assertThat(dto.id()).isEqualTo(101L); + assertThat(dto.creatorUsername()).isEqualTo("작성자"); + assertThat(dto.categoryName()).isEqualTo("식료품"); + assertThat(dto.reportCount()).isEqualTo(4L); + } + + @Test + @DisplayName("찜 토글 성공 — 찜하기 등록") + void toggleWish_create_success() { + // given + given(groupPurchaseRepository.existsById(101L)).willReturn(true); + given(wishlistRepository.findByUserIdAndGroupPurchaseId(2L, 101L)).willReturn(Optional.empty()); + + // when + boolean result = groupPurchaseService.toggleWish(2L, 101L); + + // then + assertThat(result).isTrue(); + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("찜 토글 성공 — 찜하기 해제") + void toggleWish_delete_success() { + // given + given(groupPurchaseRepository.existsById(101L)).willReturn(true); + Wishlist wish = Wishlist.builder().userId(2L).groupPurchaseId(101L).build(); + given(wishlistRepository.findByUserIdAndGroupPurchaseId(2L, 101L)).willReturn(Optional.of(wish)); + + // when + boolean result = groupPurchaseService.toggleWish(2L, 101L); + + // then + assertThat(result).isFalse(); + verify(wishlistRepository).delete(wish); + } + + @Test + @DisplayName("찜 토글 실패 — 존재하지 않는 공동구매 글") + void toggleWish_fail_not_found() { + // given + given(groupPurchaseRepository.existsById(101L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> groupPurchaseService.toggleWish(2L, 101L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_NOT_FOUND); + } + + @Test + @DisplayName("찜한 목록 페이징 조회 성공") + void getWishedGroupPurchases_success() { + // given + Pageable pageable = PageRequest.of(0, 10); + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .categoryId(3L) + .title("공구 게시글") + .build(); + ReflectionTestUtils.setField(gp, "createdAt", LocalDateTime.now()); + + Page gpPage = new PageImpl<>(List.of(gp), pageable, 1); + given(groupPurchaseRepository.findWishedGroupPurchases(2L, pageable)).willReturn(gpPage); + + // when + Page responsePage = groupPurchaseService.getWishedGroupPurchases(2L, pageable); + + // then + assertThat(responsePage.getContent()).hasSize(1); + assertThat(responsePage.getContent().get(0).id()).isEqualTo(101L); + } + + @Test + @DisplayName("공동구매 상세 단건 조회 성공 — 조회수 증가, 닉네임 및 참여율 계산 확인") + void getGroupPurchase_success() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .categoryId(3L) + .title("공구 상세 정보") + .minParticipants(5) + .maxParticipants(10) + .pickupLocation("서울시 마포구") + .imageUrl("http://image.com/test.jpg") + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 2); // 2/10 = 20.0% + ReflectionTestUtils.setField(gp, "viewCount", 0); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + + User user = User.forTestBuilder() + .id(2L) + .username("개설자닉네임") + .build(); + given(userRepository.findById(2L)).willReturn(Optional.of(user)); + + // when + GroupPurchaseResponse response = groupPurchaseService.getGroupPurchase(101L); + + // then + assertThat(response.id()).isEqualTo(101L); + assertThat(response.creatorNickname()).isEqualTo("개설자닉네임"); + assertThat(response.achievementRate()).isEqualTo(20.0); + assertThat(response.imageUrl()).isEqualTo("http://image.com/test.jpg"); + assertThat(gp.getViewCount()).isEqualTo(1); // 엔티티의 조회수가 1 증가했는지 검증 + } + + @Test + @DisplayName("공동구매 상세 단건 조회 실패 — 존재하지 않는 공동구매 ID") + void getGroupPurchase_notFound() { + // given + given(groupPurchaseRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> groupPurchaseService.getGroupPurchase(999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_NOT_FOUND); + } + + @Test + @DisplayName("공동구매 참여 성공 — 정상 참여 및 참여자 수 증가, 100% 미만 시 RECRUITING 유지") + void joinGroupPurchase_success() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .maxParticipants(10) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 1); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(false); + given(budgetRepository.findByUserIdAndYearMonth(any(), any())).willReturn(List.of()); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + // when + GroupPurchaseJoinResponse response = groupPurchaseService.joinGroupPurchase(3L, 101L); + + // then + assertThat(response.groupPurchase().currentParticipants()).isEqualTo(2); + assertThat(response.groupPurchase().status()).isEqualTo(PurchaseStatus.RECRUITING); + verify(groupPurchaseParticipantRepository).save(any(GroupPurchaseParticipant.class)); + } + + @Test + @DisplayName("공동구매 참여 성공 — 참여로 정원 도달 시 SUCCESS 상태 변경") + void joinGroupPurchase_reach_max_success() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .maxParticipants(5) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 4); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(false); + given(budgetRepository.findByUserIdAndYearMonth(any(), any())).willReturn(List.of()); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + // 자동 지출 기입용 Mock 설정 (조용히 스킵되도록 자산 계좌를 빈 목록으로 설정) + given(accountRepository.findByUserId(any())).willReturn(List.of()); + + // when + GroupPurchaseJoinResponse response = groupPurchaseService.joinGroupPurchase(3L, 101L); + + // then + assertThat(response.groupPurchase().currentParticipants()).isEqualTo(5); + assertThat(response.groupPurchase().status()).isEqualTo(PurchaseStatus.SUCCESS); + } + + @Test + @DisplayName("공동구매 참여 실패 — 정원 가득 참") + void joinGroupPurchase_full_fail() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .maxParticipants(5) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 5); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + + // when & then + assertThatThrownBy(() -> groupPurchaseService.joinGroupPurchase(3L, 101L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_FULL); + } + + @Test + @DisplayName("공동구매 참여 실패 — 이미 참여한 회원") + void joinGroupPurchase_alreadyJoined_fail() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .maxParticipants(10) + .build(); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(true); + + // when & then + assertThatThrownBy(() -> groupPurchaseService.joinGroupPurchase(3L, 101L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GROUP_PURCHASE_ALREADY_JOINED); + } + + @Test + @DisplayName("공동구매 참여 취소 성공 — 취소 시 RECRUITING 상태 복구 검증") + void leaveGroupPurchase_success() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .maxParticipants(5) + .deadline(LocalDateTime.now().plusDays(2)) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 5); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.SUCCESS); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + + GroupPurchaseParticipant participant = GroupPurchaseParticipant.builder() + .groupPurchaseId(101L) + .userId(3L) + .build(); + given(groupPurchaseParticipantRepository.findByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(Optional.of(participant)); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + // when + GroupPurchaseResponse response = groupPurchaseService.leaveGroupPurchase(3L, 101L); + + // then + assertThat(response.currentParticipants()).isEqualTo(4); + assertThat(response.status()).isEqualTo(PurchaseStatus.RECRUITING); + verify(groupPurchaseParticipantRepository).delete(participant); + } + + @Test + @DisplayName("공동구매 참여 성공 — 가계부 예산 설정되어 있으나 잔액 부족 시 budgetWarning = true 확인") + void joinGroupPurchase_budgetWarning_true() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .price(50000) + .maxParticipants(10) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 1); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(false); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + com.chaewookim.accountbookformoms.domain.budget.entity.Budget budget = + com.chaewookim.accountbookformoms.domain.budget.entity.Budget.builder() + .totalBudget(BigDecimal.valueOf(80000)) + .expectedExpense(BigDecimal.valueOf(20000)) + .build(); + + String currentYearMonth = YearMonth.now().toString(); + given(budgetRepository.findByUserIdAndYearMonth(3L, currentYearMonth)).willReturn(List.of(budget)); + + given(transactionRepository.sumByUserAndType(eq(3L), eq(TransactionType.EXPENSE), any(LocalDate.class), any(LocalDate.class))) + .willReturn(BigDecimal.valueOf(90000)); + + // when + GroupPurchaseJoinResponse response = groupPurchaseService.joinGroupPurchase(3L, 101L); + + // then + assertThat(response.budgetWarning()).isTrue(); + assertThat(response.remainingBudget()).isEqualByComparingTo(BigDecimal.valueOf(10000)); + assertThat(response.groupPurchase().currentParticipants()).isEqualTo(2); + } + + @Test + @DisplayName("공동구매 참여 성공 — 가계부 예산이 충분하여 budgetWarning = false 확인") + void joinGroupPurchase_budgetWarning_false() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) + .price(10000) + .maxParticipants(10) + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 1); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(false); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + com.chaewookim.accountbookformoms.domain.budget.entity.Budget budget = + com.chaewookim.accountbookformoms.domain.budget.entity.Budget.builder() + .totalBudget(BigDecimal.valueOf(80000)) + .expectedExpense(BigDecimal.valueOf(20000)) + .build(); + + String currentYearMonth = YearMonth.now().toString(); + given(budgetRepository.findByUserIdAndYearMonth(3L, currentYearMonth)).willReturn(List.of(budget)); + + given(transactionRepository.sumByUserAndType(eq(3L), eq(TransactionType.EXPENSE), any(LocalDate.class), any(LocalDate.class))) + .willReturn(BigDecimal.valueOf(50000)); + + // when + GroupPurchaseJoinResponse response = groupPurchaseService.joinGroupPurchase(3L, 101L); + + // then + assertThat(response.budgetWarning()).isFalse(); + assertThat(response.remainingBudget()).isEqualByComparingTo(BigDecimal.valueOf(50000)); + assertThat(response.groupPurchase().currentParticipants()).isEqualTo(2); + } + + @Test + @DisplayName("공동구매 참여 성공 — 정원 도달로 SUCCESS 시 참여자 전원 가계부 자동 지출 내역 기입 검증") + void joinGroupPurchase_reach_max_success_and_create_transactions() { + // given + GroupPurchase gp = GroupPurchase.builder() + .id(101L) + .creatorId(2L) // 개설자 ID + .categoryId(3L) + .price(15000) + .maxParticipants(3) + .title("맛있는 밀키트 공구") + .build(); + ReflectionTestUtils.setField(gp, "currentParticipants", 2); + ReflectionTestUtils.setField(gp, "status", PurchaseStatus.RECRUITING); + + given(groupPurchaseRepository.findById(101L)).willReturn(Optional.of(gp)); + given(groupPurchaseParticipantRepository.existsByGroupPurchaseIdAndUserId(101L, 3L)).willReturn(false); + given(budgetRepository.findByUserIdAndYearMonth(any(), any())).willReturn(List.of()); + + User creator = User.forTestBuilder().id(2L).username("개설자").build(); + given(userRepository.findById(2L)).willReturn(Optional.of(creator)); + + // 1. 카테고리 정보 모킹 + Category category = Category.builder().id(3L).name("밀키트").build(); + given(groupPurchaseCategoryRepository.findById(3L)).willReturn(Optional.of(category)); + + // 2. 참여자 리스트 모킹 (참가자 1명) + GroupPurchaseParticipant participant = GroupPurchaseParticipant.builder() + .groupPurchaseId(101L) + .userId(3L) // 참가자 ID + .build(); + given(groupPurchaseParticipantRepository.findByGroupPurchaseId(101L)).willReturn(List.of(participant)); + + // 3. 계좌 모킹 + Account creatorAccount = Account.builder().initialBalance(BigDecimal.valueOf(50000)).build(); + ReflectionTestUtils.setField(creatorAccount, "id", 201L); + Account participantAccount = Account.builder().initialBalance(BigDecimal.valueOf(30000)).build(); + ReflectionTestUtils.setField(participantAccount, "id", 301L); + + given(accountRepository.findByUserId(2L)).willReturn(List.of(creatorAccount)); + given(accountRepository.findByUserId(3L)).willReturn(List.of(participantAccount)); + + // 4. 가계부 카테고리 모킹 + TransactionCategory tCategory = TransactionCategory.builder() + .name("밀키트") + .type(TransactionType.EXPENSE) + .build(); + ReflectionTestUtils.setField(tCategory, "id", 401L); + given(transactionCategoryRepository.findAllByUserOrSystem(2L)).willReturn(List.of(tCategory)); + given(transactionCategoryRepository.findAllByUserOrSystem(3L)).willReturn(List.of(tCategory)); + + // when + GroupPurchaseJoinResponse response = groupPurchaseService.joinGroupPurchase(3L, 101L); + + // then + assertThat(response.groupPurchase().currentParticipants()).isEqualTo(3); + assertThat(response.groupPurchase().status()).isEqualTo(PurchaseStatus.SUCCESS); + + // 5. 개설자(2L)와 참가자(3L) 각각에 대해 지출 생성 서비스 메서드가 정확히 1번씩 호출되었는지 검증 + verify(transactionService, times(1)).createTransaction(eq(2L), any(TransactionRequest.class)); + verify(transactionService, times(1)).createTransaction(eq(3L), any(TransactionRequest.class)); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportServiceTest.java new file mode 100644 index 0000000..59cf3e2 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/grouppruchase/application/ReportServiceTest.java @@ -0,0 +1,303 @@ +package com.chaewookim.accountbookformoms.domain.grouppruchase.application; + +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.entity.Comment; +import com.chaewookim.accountbookformoms.domain.comment.enums.ReferenceType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.ReportRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.GroupPurchase; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Report; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportProcessResult; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.enums.ReportTargetType; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportCreateRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.request.ReportProcessRequest; +import com.chaewookim.accountbookformoms.domain.grouppruchase.dto.response.ReportResponse; +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.error.ErrorCode; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private GroupPurchaseRepository groupPurchaseRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private ReportService reportService; + + private static final Long REPORTER_ID = 1L; + private static final Long TARGET_ID = 10L; + private static final Long REPORT_ID = 100L; + private static final Long CREATOR_ID = 2L; + + @Test + @DisplayName("신고 등록 성공 — 공동구매 글") + void createReport_groupPurchase_success() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.GROUP_PURCHASE, TARGET_ID, "부적절한 게시글입니다."); + given(groupPurchaseRepository.existsById(TARGET_ID)).willReturn(true); + + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.GROUP_PURCHASE) + .targetId(TARGET_ID) + .reason(request.reason()) + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + given(reportRepository.save(any(Report.class))).willReturn(report); + + // when + Long savedId = reportService.createReport(REPORTER_ID, request); + + // then + assertThat(savedId).isEqualTo(REPORT_ID); + verify(groupPurchaseRepository).existsById(TARGET_ID); + verify(reportRepository).save(any(Report.class)); + } + + @Test + @DisplayName("신고 등록 실패 — 존재하지 않는 공동구매 글인 경우") + void createReport_groupPurchase_fail_not_found() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.GROUP_PURCHASE, TARGET_ID, "부적절한 게시글입니다."); + given(groupPurchaseRepository.existsById(TARGET_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> reportService.createReport(REPORTER_ID, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REPORT_TARGET); + } + + @Test + @DisplayName("신고 목록 조회 성공") + void getReports_success() { + // given + Pageable pageable = PageRequest.of(0, 10); + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.GROUP_PURCHASE) + .targetId(TARGET_ID) + .reason("이유") + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + ReflectionTestUtils.setField(report, "createdAt", LocalDateTime.now()); + + Page reportPage = new PageImpl<>(List.of(report), pageable, 1); + given(reportRepository.findReports(eq(false), eq(ReportTargetType.GROUP_PURCHASE), eq(pageable))) + .willReturn(reportPage); + + // when + Page result = reportService.getReports(false, ReportTargetType.GROUP_PURCHASE, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).id()).isEqualTo(REPORT_ID); + assertThat(result.getContent().get(0).reporterId()).isEqualTo(REPORTER_ID); + } + + @Test + @DisplayName("신고 처리 성공 — 경고 조치 및 알림 발송") + void processReport_warn_success() { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.WARNED, "경고 사유"); + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.GROUP_PURCHASE) + .targetId(TARGET_ID) + .reason("신고 사유") + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + + GroupPurchase gp = GroupPurchase.builder() + .id(TARGET_ID) + .creatorId(CREATOR_ID) + .title("공구 게시글 제목") + .build(); + + User creator = User.forTestBuilder() + .id(CREATOR_ID) + .email("creator@test.com") + .username("작성자") + .build(); + + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + given(groupPurchaseRepository.findById(TARGET_ID)).willReturn(Optional.of(gp)); + given(userRepository.findById(CREATOR_ID)).willReturn(Optional.of(creator)); + + // when + Long processedId = reportService.processReport(REPORT_ID, request); + + // then + assertThat(processedId).isEqualTo(REPORT_ID); + assertThat(report.isProcessed()).isTrue(); + assertThat(report.getProcessResult()).isEqualTo(ReportProcessResult.WARNED); + assertThat(report.getProcessReason()).isEqualTo("경고 사유"); + + verify(notificationService).createNotification( + eq(creator), + eq(NotificationType.SYSTEM), + eq("공동구매 글 경고 알림"), + contains("경고 사유"), + isNull(), + isNull() + ); + } + + @Test + @DisplayName("신고 처리 성공 — 공동구매 삭제 조치, 소프트 삭제 및 알림 발송") + void processReport_delete_groupPurchase_success() { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.DELETED, "규정 위반 삭제"); + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.GROUP_PURCHASE) + .targetId(TARGET_ID) + .reason("신고 사유") + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + + GroupPurchase gp = GroupPurchase.builder() + .id(TARGET_ID) + .creatorId(CREATOR_ID) + .title("공구 게시글 제목") + .build(); + + User creator = User.forTestBuilder() + .id(CREATOR_ID) + .email("creator@test.com") + .username("작성자") + .build(); + + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + given(groupPurchaseRepository.findById(TARGET_ID)).willReturn(Optional.of(gp)); + given(userRepository.findById(CREATOR_ID)).willReturn(Optional.of(creator)); + + // when + Long processedId = reportService.processReport(REPORT_ID, request); + + // then + assertThat(processedId).isEqualTo(REPORT_ID); + assertThat(report.isProcessed()).isTrue(); + assertThat(report.getProcessResult()).isEqualTo(ReportProcessResult.DELETED); + + verify(groupPurchaseRepository).delete(gp); + verify(notificationService).createNotification( + eq(creator), + eq(NotificationType.SYSTEM), + eq("공동구매 글 삭제 알림"), + contains("규정 위반 삭제"), + isNull(), + isNull() + ); + } + + @Test + @DisplayName("신고 처리 성공 — 댓글 삭제 조치, markAsAdminDeleted, 소프트 삭제 및 알림 발송") + void processReport_delete_comment_success() { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.DELETED, "욕설로 인한 삭제"); + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.COMMENT) + .targetId(TARGET_ID) + .reason("신고 사유") + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + + Comment comment = Comment.builder() + .userId(CREATOR_ID) + .referenceId(101L) + .referenceType(ReferenceType.GROUPPURCHASE) + .content("나쁜 댓글 내용") + .build(); + ReflectionTestUtils.setField(comment, "id", TARGET_ID); + + User author = User.forTestBuilder() + .id(CREATOR_ID) + .email("author@test.com") + .username("작성자") + .build(); + + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + given(commentRepository.findById(TARGET_ID)).willReturn(Optional.of(comment)); + given(userRepository.findById(CREATOR_ID)).willReturn(Optional.of(author)); + + // when + Long processedId = reportService.processReport(REPORT_ID, request); + + // then + assertThat(processedId).isEqualTo(REPORT_ID); + assertThat(report.isProcessed()).isTrue(); + assertThat(report.getProcessResult()).isEqualTo(ReportProcessResult.DELETED); + assertThat(comment.isAdminDeleted()).isTrue(); + + verify(commentRepository).delete(comment); + verify(notificationService).createNotification( + eq(author), + eq(NotificationType.SYSTEM), + eq("댓글 삭제 알림"), + contains("욕설로 인한 삭제"), + isNull(), + isNull() + ); + } + + @Test + @DisplayName("신고 처리 실패 — 이미 처리 완료된 신고인 경우") + void processReport_fail_already_processed() { + // given + ReportProcessRequest request = new ReportProcessRequest(ReportProcessResult.WARNED, "중복 처리 테스트"); + Report report = Report.builder() + .reporterId(REPORTER_ID) + .targetType(ReportTargetType.GROUP_PURCHASE) + .targetId(TARGET_ID) + .reason("신고 사유") + .build(); + ReflectionTestUtils.setField(report, "id", REPORT_ID); + // 이미 처리 완료된 상태로 강제 설정 + report.process(ReportProcessResult.DISMISSED, "기존 처리 완료"); + + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when & then + assertThatThrownBy(() -> reportService.processReport(REPORT_ID, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REPORT_ALREADY_PROCESSED); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/image/application/ImageServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/image/application/ImageServiceTest.java new file mode 100644 index 0000000..0f06be1 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/image/application/ImageServiceTest.java @@ -0,0 +1,128 @@ +package com.chaewookim.accountbookformoms.domain.image.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.entity.Board; +import com.chaewookim.accountbookformoms.domain.board.enums.BOARD_TYPE; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.image.dao.ImageRepository; +import com.chaewookim.accountbookformoms.domain.image.dto.request.AttachImageRequest; +import com.chaewookim.accountbookformoms.domain.image.dto.response.ImageResponse; +import com.chaewookim.accountbookformoms.domain.image.entity.Image; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ImageServiceTest { + + @Mock private ImageRepository imageRepository; + @Mock private BoardRepository boardRepository; + + @InjectMocks + private ImageService imageService; + + private static final Long BOARD_ID = 100L; + private static final Long OWNER = 1L; + private static final Long INTRUDER = 2L; + + private Board board(Long id, Long userId) { + Board b = Board.builder().userId(userId).categoryId(1L).title("t").content("c").type(BOARD_TYPE.QNA).build(); + ReflectionTestUtils.setField(b, "id", id); + return b; + } + + private Image image(Long id, String url, int order) { + Image img = Image.builder() + .imageUrl(url).s3Key(null) + .referenceType(Image.ReferenceType.BOARD).referenceId(BOARD_ID).sortOrder(order) + .build(); + ReflectionTestUtils.setField(img, "id", id); + return img; + } + + @Test + @DisplayName("attachToBoard — 소유자 요청 시 sortOrder=기존 갯수로 저장") + void attach_success() { + Board board = board(BOARD_ID, OWNER); + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board)); + given(imageRepository.findByReferenceTypeAndReferenceIdOrderBySortOrderAsc(Image.ReferenceType.BOARD, BOARD_ID)) + .willReturn(List.of(image(1L, "a", 0))); // 이미 1장 → 다음 sortOrder=1 + given(imageRepository.save(any(Image.class))).willAnswer(inv -> { + Image img = inv.getArgument(0); + ReflectionTestUtils.setField(img, "id", 2L); + return img; + }); + + ImageResponse response = imageService.attachToBoard(BOARD_ID, new AttachImageRequest("https://x", null), OWNER); + + assertThat(response.imageUrl()).isEqualTo("https://x"); + assertThat(response.sortOrder()).isEqualTo(1); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Image.class); + verify(imageRepository).save(captor.capture()); + assertThat(captor.getValue().getReferenceType()).isEqualTo(Image.ReferenceType.BOARD); + assertThat(captor.getValue().getReferenceId()).isEqualTo(BOARD_ID); + } + + @Test + @DisplayName("attachToBoard 실패 — 소유자 아닌 사용자면 BOARD_ACCESS_DENIED") + void attach_fail_not_owner() { + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board(BOARD_ID, OWNER))); + + assertThatThrownBy(() -> imageService.attachToBoard(BOARD_ID, new AttachImageRequest("https://x", null), INTRUDER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_ACCESS_DENIED); + verify(imageRepository, never()).save(any()); + } + + @Test + @DisplayName("attachToBoard 실패 — 게시물 없으면 BOARD_NOT_FOUND") + void attach_fail_board_not_found() { + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> imageService.attachToBoard(BOARD_ID, new AttachImageRequest("https://x", null), OWNER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + } + + @Test + @DisplayName("deleteImage 실패 — 게시물 소유자가 아니면 BOARD_ACCESS_DENIED") + void delete_fail_not_owner() { + Image img = image(9L, "u", 0); + given(imageRepository.findById(9L)).willReturn(Optional.of(img)); + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board(BOARD_ID, OWNER))); + + assertThatThrownBy(() -> imageService.deleteImage(9L, INTRUDER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_ACCESS_DENIED); + verify(imageRepository, never()).delete(any()); + } + + @Test + @DisplayName("deleteImage 성공 — 소유자 요청 시 DB delete") + void delete_success() { + Image img = image(9L, "u", 0); + given(imageRepository.findById(9L)).willReturn(Optional.of(img)); + given(boardRepository.findById(BOARD_ID)).willReturn(Optional.of(board(BOARD_ID, OWNER))); + + imageService.deleteImage(9L, OWNER); + + verify(imageRepository).delete(img); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheServiceTest.java new file mode 100644 index 0000000..2102554 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/LikeCountCacheServiceTest.java @@ -0,0 +1,126 @@ +package com.chaewookim.accountbookformoms.domain.like.application; + +import com.chaewookim.accountbookformoms.domain.like.dao.PostLikeRepository; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LikeCountCacheServiceTest { + + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations valueOps; + @Mock private PostLikeRepository likeRepository; + + @InjectMocks + private LikeCountCacheService likeCountCacheService; + + private static final String KEY_BOARD_1 = "like:count:BOARD:1"; + + @Nested + @DisplayName("getCount") + class GetCount { + + @Test + @DisplayName("Redis hit 시 DB 조회 없이 값 반환") + void hit() { + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(valueOps.get(KEY_BOARD_1)).willReturn("42"); + + long result = likeCountCacheService.getCount(LikeTargetType.BOARD, 1L); + + assertThat(result).isEqualTo(42L); + verify(likeRepository, never()).countByTargetIdAndTargetType(any(), any()); + } + + @Test + @DisplayName("Redis miss 시 DB COUNT 후 캐시에 SET (TTL 포함)") + void miss_fills_from_db() { + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(valueOps.get(KEY_BOARD_1)).willReturn(null); + given(likeRepository.countByTargetIdAndTargetType(1L, LikeTargetType.BOARD)).willReturn(7L); + + long result = likeCountCacheService.getCount(LikeTargetType.BOARD, 1L); + + assertThat(result).isEqualTo(7L); + verify(valueOps).set(eq(KEY_BOARD_1), eq("7"), any(Duration.class)); + } + } + + @Nested + @DisplayName("applyDelta") + class ApplyDelta { + + @Test + @DisplayName("캐시 키 존재 시 INCR/DECR 원자적 증감") + void increments_when_present() { + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(redisTemplate.hasKey(KEY_BOARD_1)).willReturn(true); + + likeCountCacheService.applyDelta(LikeTargetType.BOARD, 1L, 1L); + + verify(valueOps).increment(KEY_BOARD_1, 1L); + } + + @Test + @DisplayName("캐시 키 없으면 INCR 실행하지 않음 (drift 방지)") + void skips_when_absent() { + given(redisTemplate.hasKey(KEY_BOARD_1)).willReturn(false); + + likeCountCacheService.applyDelta(LikeTargetType.BOARD, 1L, 1L); + + verify(redisTemplate, never()).opsForValue(); + } + } + + @Nested + @DisplayName("getCounts") + class GetCounts { + + @Test + @DisplayName("전체 hit 이면 DB 미조회") + void all_hit() { + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(valueOps.multiGet(any())).willReturn(List.of("3", "5")); + + Map result = likeCountCacheService.getCounts(LikeTargetType.BOARD, List.of(1L, 2L)); + + assertThat(result).containsEntry(1L, 3L).containsEntry(2L, 5L); + verify(likeRepository, never()).countByTargets(any(), any()); + } + + @Test + @DisplayName("빈 collection 이면 빈 map 즉시 반환") + void empty_input() { + Map result = likeCountCacheService.getCounts(LikeTargetType.BOARD, List.of()); + + assertThat(result).isEmpty(); + verify(redisTemplate, never()).opsForValue(); + } + } + + @Test + @DisplayName("evict — DEL 실행") + void evict() { + likeCountCacheService.evict(LikeTargetType.BOARD, 1L); + verify(redisTemplate).delete(KEY_BOARD_1); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeServiceTest.java new file mode 100644 index 0000000..c7f5af3 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/like/application/PostLikeServiceTest.java @@ -0,0 +1,131 @@ +package com.chaewookim.accountbookformoms.domain.like.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.board.error.BoardErrorCode; +import com.chaewookim.accountbookformoms.domain.comment.dao.CommentRepository; +import com.chaewookim.accountbookformoms.domain.comment.error.CommentErrorCode; +import com.chaewookim.accountbookformoms.domain.like.dao.PostLikeRepository; +import com.chaewookim.accountbookformoms.domain.like.dto.response.LikeToggleResponse; +import com.chaewookim.accountbookformoms.domain.like.entity.PostLike; +import com.chaewookim.accountbookformoms.domain.like.enums.LikeTargetType; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PostLikeServiceTest { + + @Mock private PostLikeRepository likeRepository; + @Mock private BoardRepository boardRepository; + @Mock private CommentRepository commentRepository; + @Mock private LikeCountCacheService likeCountCache; + + @InjectMocks + private PostLikeService postLikeService; + + private static final Long USER_ID = 1L; + private static final Long TARGET_ID = 100L; + + @Nested + @DisplayName("좋아요 토글") + class Toggle { + + @Test + @DisplayName("성공 — 좋아요 없던 상태에서 like 저장 + cache +1") + void toggle_add() { + given(boardRepository.existsById(TARGET_ID)).willReturn(true); + given(likeRepository.findByUserIdAndTargetIdAndTargetType(USER_ID, TARGET_ID, LikeTargetType.BOARD)) + .willReturn(Optional.empty()); + given(likeCountCache.getCount(LikeTargetType.BOARD, TARGET_ID)).willReturn(1L); + + LikeToggleResponse response = postLikeService.toggle(LikeTargetType.BOARD, TARGET_ID, USER_ID); + + assertThat(response.liked()).isTrue(); + assertThat(response.likeCount()).isEqualTo(1L); + verify(likeRepository).save(any(PostLike.class)); + verify(likeCountCache).applyDelta(LikeTargetType.BOARD, TARGET_ID, 1L); + } + + @Test + @DisplayName("성공 — 좋아요 있던 상태에서 삭제 + cache -1") + void toggle_remove() { + PostLike existing = PostLike.builder().userId(USER_ID).targetId(TARGET_ID).targetType(LikeTargetType.BOARD).build(); + given(boardRepository.existsById(TARGET_ID)).willReturn(true); + given(likeRepository.findByUserIdAndTargetIdAndTargetType(USER_ID, TARGET_ID, LikeTargetType.BOARD)) + .willReturn(Optional.of(existing)); + given(likeCountCache.getCount(LikeTargetType.BOARD, TARGET_ID)).willReturn(0L); + + LikeToggleResponse response = postLikeService.toggle(LikeTargetType.BOARD, TARGET_ID, USER_ID); + + assertThat(response.liked()).isFalse(); + assertThat(response.likeCount()).isZero(); + verify(likeRepository).delete(existing); + verify(likeCountCache).applyDelta(LikeTargetType.BOARD, TARGET_ID, -1L); + } + + @Test + @DisplayName("실패 — BOARD 대상이 존재하지 않으면 BOARD_NOT_FOUND") + void toggle_fail_board_not_found() { + given(boardRepository.existsById(TARGET_ID)).willReturn(false); + + assertThatThrownBy(() -> postLikeService.toggle(LikeTargetType.BOARD, TARGET_ID, USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", BoardErrorCode.BOARD_NOT_FOUND); + verify(likeRepository, never()).save(any()); + } + + @Test + @DisplayName("실패 — COMMENT 대상이 존재하지 않으면 COMMENT_NOT_FOUND") + void toggle_fail_comment_not_found() { + given(commentRepository.existsById(TARGET_ID)).willReturn(false); + + assertThatThrownBy(() -> postLikeService.toggle(LikeTargetType.COMMENT, TARGET_ID, USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", CommentErrorCode.COMMENT_NOT_FOUND); + } + } + + @Nested + @DisplayName("좋아요 여부 조회") + class IsLiked { + + @Test + @DisplayName("viewerId 가 null 이면 항상 false 반환 (DB 조회 없음)") + void isLiked_null_viewer() { + assertThat(postLikeService.isLiked(LikeTargetType.BOARD, TARGET_ID, null)).isFalse(); + verify(likeRepository, never()).findByUserIdAndTargetIdAndTargetType(any(), any(), any()); + } + + @Test + @DisplayName("DB에 like 존재하면 true") + void isLiked_true() { + PostLike like = PostLike.builder().userId(USER_ID).targetId(TARGET_ID).targetType(LikeTargetType.BOARD).build(); + given(likeRepository.findByUserIdAndTargetIdAndTargetType(USER_ID, TARGET_ID, LikeTargetType.BOARD)) + .willReturn(Optional.of(like)); + + assertThat(postLikeService.isLiked(LikeTargetType.BOARD, TARGET_ID, USER_ID)).isTrue(); + } + } + + @Test + @DisplayName("evictCount — 캐시 서비스 evict 위임") + void evictCount_delegates() { + postLikeService.evictCount(LikeTargetType.BOARD, TARGET_ID); + verify(likeCountCache).evict(eq(LikeTargetType.BOARD), eq(TARGET_ID)); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmServiceTest.java new file mode 100644 index 0000000..3f03e4e --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/FcmServiceTest.java @@ -0,0 +1,49 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +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.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FcmServiceTest { + + @InjectMocks + private FcmService fcmService; + + @Test + @DisplayName("FCM 발송 - 성공") + void sendNotificationTest() throws Exception { + + // given + String token = "test-token"; + String title = "제목"; + String body = "본문"; + Map data = Map.of("key", "value"); + + try (MockedStatic firebaseMessaging = mockStatic(FirebaseMessaging.class); + MockedStatic firebaseApp = mockStatic(FirebaseApp.class)) { + + firebaseApp.when(FirebaseApp::getApps).thenReturn(List.of(mock(FirebaseApp.class))); + + FirebaseMessaging mockMessaging = mock(FirebaseMessaging.class); + firebaseMessaging.when(FirebaseMessaging::getInstance).thenReturn(mockMessaging); + + // when + fcmService.sendNotification(token, title, body, data); + + // then + verify(mockMessaging, times(1)).send(any(Message.class)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationServiceTest.java new file mode 100644 index 0000000..1bf0dd1 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/NotificationServiceTest.java @@ -0,0 +1,178 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.chaewookim.accountbookformoms.domain.notification.dao.NotificationRepository; +import com.chaewookim.accountbookformoms.domain.notification.dao.UserDeviceRepository; +import com.chaewookim.accountbookformoms.domain.notification.dto.response.NotificationResponse; +import com.chaewookim.accountbookformoms.domain.notification.entity.Notification; +import com.chaewookim.accountbookformoms.domain.notification.entity.UserDevice; +import com.chaewookim.accountbookformoms.domain.notification.enums.NotificationType; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private FcmService fcmService; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("알림 생성 - 성공") + void createNotification_success() { + + // given + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + UserDevice device = UserDevice.builder().fcmToken("token").build(); + when(userDeviceRepository.findByUserId(1L)).thenReturn(Optional.of(device)); + + // when + notificationService.createNotification(user, NotificationType.SYSTEM, "제목", "본문", "/url", 1L); + + // then + verify(notificationRepository, times(1)).save(any(Notification.class)); + verify(fcmService, times(1)).sendNotification(eq("token"), anyString(), anyString(), any(Map.class)); + } + + @Test + @DisplayName("알림 생성 - FCM 실패해도 DB 저장은 유지") + void createNotification_persistsWhenFcmFails() { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + UserDevice device = UserDevice.builder().fcmToken("token").build(); + when(userDeviceRepository.findByUserId(1L)).thenReturn(Optional.of(device)); + doThrow(new IllegalStateException("Firebase not initialized")).when(fcmService) + .sendNotification(eq("token"), anyString(), anyString(), any(Map.class)); + + notificationService.createNotification(user, NotificationType.GOAL, "제목", "본문", "/url", 1L); + + verify(notificationRepository, times(1)).save(any(Notification.class)); + } + + @Test + @DisplayName("알림 목록 조회 - 성공") + void getNotifications_success() { + + // given + User user = mock(User.class); + Pageable pageable = mock(Pageable.class); + Notification notification = mock(Notification.class); + when(notification.getType()).thenReturn(NotificationType.SYSTEM); + when(notification.getId()).thenReturn(1L); + when(notification.getTitle()).thenReturn("제목"); + when(notificationRepository.findByUser(user, pageable)).thenReturn(new PageImpl<>(List.of(notification))); + + // when + Page result = notificationService.getNotifications(user, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + } + + @Test + @DisplayName("읽지 않은 알림 개수 조회 - 성공") + void getUnreadCount_success() { + + // given + User user = mock(User.class); + when(notificationRepository.countByUserAndIsReadFalse(user)).thenReturn(5L); + + // when + Long count = notificationService.getUnreadCount(user); + + // when & then + assertThat(count).isEqualTo(5L); + } + + @Test + @DisplayName("알림 읽음 처리 - 성공") + void markAsRead_success() { + + // given + Notification notification = mock(Notification.class); + User user = mock(User.class); + when(notification.getType()).thenReturn(NotificationType.SYSTEM); + when(user.getId()).thenReturn(1L); + when(notification.getUser()).thenReturn(user); + when(notificationRepository.findById(1L)).thenReturn(Optional.of(notification)); + + // when + notificationService.markAsRead(1L, 1L); + + // then + verify(notification, times(1)).markAsRead(); + } + + @Test + @DisplayName("알림 읽음 처리 - 타인의 알림을 읽음 처리 시 예외 발생") + void markAsRead_accessDenied() { + + // given + Notification notification = mock(Notification.class); + User user = mock(User.class); + when(user.getId()).thenReturn(2L); + when(notification.getUser()).thenReturn(user); + when(notificationRepository.findById(1L)).thenReturn(Optional.of(notification)); + + // when & then + assertThrows(CustomException.class, () -> notificationService.markAsRead(1L, 1L)); + } + + @Test + @DisplayName("모든 알림 읽음 처리 - 성공") + void markAllAsRead_success() { + + // given + Long userId = 1L; + + // when + notificationService.markAllAsRead(userId); + + // then + verify(notificationRepository, times(1)).markAllAsReadByUserId(userId); + } + + @Test + @DisplayName("알림 삭제 - 성공") + void deleteNotification_success() { + + // given + Notification notification = mock(Notification.class); + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + when(notification.getUser()).thenReturn(user); + when(notificationRepository.findById(1L)).thenReturn(Optional.of(notification)); + + // when + notificationService.deleteNotification(1L, 1L); + + // then + verify(notificationRepository, times(1)).delete(notification); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceServiceTest.java new file mode 100644 index 0000000..156451c --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/notification/application/UserDeviceServiceTest.java @@ -0,0 +1,79 @@ +package com.chaewookim.accountbookformoms.domain.notification.application; + +import com.chaewookim.accountbookformoms.domain.notification.dao.UserDeviceRepository; +import com.chaewookim.accountbookformoms.domain.notification.entity.UserDevice; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserDeviceServiceTest { + + private static final String VALID_TOKEN = "a".repeat(150); + + @Mock + private UserDeviceRepository userDeviceRepository; + + @InjectMocks + private UserDeviceService userDeviceService; + + @Test + @DisplayName("기존 기기 토큰이 없으면 새로 저장") + void registerToken_save() { + + // given + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + when(userDeviceRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + // when + userDeviceService.registerToken(user, VALID_TOKEN); + + // then + verify(userDeviceRepository, times(1)).save(any(UserDevice.class)); + } + + @Test + @DisplayName("기존 기기 토큰이 있으면 업데이트") + void registerToken_update() { + + // given + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + UserDevice device = mock(UserDevice.class); + + when(userDeviceRepository.findByUserId(1L)).thenReturn(Optional.of(device)); + + // when + userDeviceService.registerToken(user, VALID_TOKEN); + + // then + verify(device, times(1)).updateToken(VALID_TOKEN); + verify(userDeviceRepository, never()).save(any(UserDevice.class)); + } + + @Test + @DisplayName("FCM 토큰 형식 검증 실패 시 예외") + void registerToken_invalidFormat() { + User user = mock(User.class); + + assertThatThrownBy(() -> userDeviceService.registerToken(user, "short")) + .isInstanceOf(CustomException.class); + assertThatThrownBy(() -> userDeviceService.registerToken(user, "a".repeat(150) + " space")) + .isInstanceOf(CustomException.class); + assertThatThrownBy(() -> userDeviceService.registerToken(user, null)) + .isInstanceOf(CustomException.class); + verify(userDeviceRepository, never()).save(any(UserDevice.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioServiceTest.java new file mode 100644 index 0000000..960b027 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/portfolio/application/PortfolioServiceTest.java @@ -0,0 +1,430 @@ +package com.chaewookim.accountbookformoms.domain.portfolio.application; + +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetCompareService; +import com.chaewookim.accountbookformoms.domain.budget.dto.request.BudgetCompareRequest; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.BudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.MyBudgetResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.PairBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetCompareResponse; +import com.chaewookim.accountbookformoms.domain.budget.dto.response.UserBudgetDetailResponse; +import com.chaewookim.accountbookformoms.domain.budget.enums.BudgetCompareType; +import com.chaewookim.accountbookformoms.domain.expense.application.ExpenseCompareService; +import com.chaewookim.accountbookformoms.domain.expense.dto.request.ExpenseCompareRequest; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.ExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.MyExpenseResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.PairExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseCompareResponse; +import com.chaewookim.accountbookformoms.domain.expense.dto.response.UserExpenseDetailResponse; +import com.chaewookim.accountbookformoms.domain.expense.enums.ExpenseCompareType; +import com.chaewookim.accountbookformoms.domain.income.application.IncomeCompareService; +import com.chaewookim.accountbookformoms.domain.income.dto.request.IncomeCompareRequest; +import com.chaewookim.accountbookformoms.domain.income.dto.response.IncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.MyIncomeResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.PairIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeCompareResponse; +import com.chaewookim.accountbookformoms.domain.income.dto.response.UserIncomeDetailResponse; +import com.chaewookim.accountbookformoms.domain.income.enums.IncomeCompareType; +import com.chaewookim.accountbookformoms.domain.portfolio.dao.PortfolioAggregationRepository; +import com.chaewookim.accountbookformoms.domain.portfolio.dao.PortfolioAggregationRepository.PublicPortfolioRow; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioCompareRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.request.PortfolioFilterRequest; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.MyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PairPortfolioDetailResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.PublicMonthlyPortfolioResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.dto.response.UserPortfolioCompareResponse; +import com.chaewookim.accountbookformoms.domain.portfolio.enums.PortfolioCompareType; +import com.chaewookim.accountbookformoms.domain.portfolio.error.PortfolioErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PortfolioServiceTest { + + @Mock + private IncomeCompareService incomeCompareService; + + @Mock + private ExpenseCompareService expenseCompareService; + + @Mock + private BudgetCompareService budgetCompareService; + + @Mock + private PortfolioAggregationRepository portfolioAggregationRepository; + + @InjectMocks + private PortfolioService portfolioService; + + private MyIncomeResponse myIncome(String yearMonth, String total) { + return new MyIncomeResponse(yearMonth, new BigDecimal(total), + BigDecimal.ZERO, new BigDecimal(total), List.of(), List.of()); + } + + private MyExpenseResponse myExpense(String yearMonth, String total) { + return new MyExpenseResponse(yearMonth, new BigDecimal(total), + BigDecimal.ZERO, new BigDecimal(total), List.of(), List.of()); + } + + private MyBudgetResponse myBudget(String yearMonth, String total) { + return new MyBudgetResponse(yearMonth, new BigDecimal(total), List.of()); + } + + @Test + @DisplayName("내 포트폴리오 조회 - 수입/지출/예산 합산 및 잔액(=수입-지출) 반환") + void getMyPortfolio_success() { + + // given + given(incomeCompareService.getMyMonthlyIncome(1L, "2026-06")) + .willReturn(myIncome("2026-06", "5000000")); + given(expenseCompareService.getMyMonthlyExpense(1L, "2026-06")) + .willReturn(myExpense("2026-06", "3000000")); + given(budgetCompareService.getMyMonthlyBudget(1L, "2026-06")) + .willReturn(myBudget("2026-06", "3500000")); + + // when + MyPortfolioResponse response = portfolioService.getMyPortfolio(1L, "2026-06"); + + // then + assertThat(response.yearMonth()).isEqualTo("2026-06"); + assertThat(response.totalIncome()).isEqualByComparingTo("5000000"); + assertThat(response.totalExpense()).isEqualByComparingTo("3000000"); + assertThat(response.totalBudget()).isEqualByComparingTo("3500000"); + assertThat(response.balance()).isEqualByComparingTo("2000000"); + } + + @Test + @DisplayName("내 포트폴리오 조회 - 잘못된 yearMonth 포맷 예외") + void getMyPortfolio_invalid_year_month() { + + // when & then + assertThatThrownBy(() -> portfolioService.getMyPortfolio(1L, "2026/06")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.INVALID_YEAR_MONTH); + } + + @Test + @DisplayName("공개 사용자 포트폴리오 목록 - 통합 집계 결과를 응답으로 매핑하고 잔액(=수입-지출)을 계산") + void getPublicPortfolios_merge_users() { + + // given + PortfolioFilterRequest filter = new PortfolioFilterRequest( + 2026, 6, null, null, null, null, null, null); + + given(portfolioAggregationRepository.findPublicPortfolios( + eq(LocalDate.of(2026, 6, 1)), + eq(LocalDate.of(2026, 6, 30)), + eq("2026-06"), + eq(null), eq(null), eq(null), eq(null), eq(null), eq(null))) + .willReturn(List.of( + new PublicPortfolioRow(10L, "alice", + new BigDecimal("5000000"), new BigDecimal("2000000"), new BigDecimal("3000000")), + new PublicPortfolioRow(20L, "bob", + new BigDecimal("4000000"), BigDecimal.ZERO, new BigDecimal("2500000")), + new PublicPortfolioRow(30L, "carol", + BigDecimal.ZERO, new BigDecimal("1500000"), BigDecimal.ZERO))); + + // when + List result = portfolioService.getPublicPortfolios(filter); + + // then + assertThat(result).extracting(PublicMonthlyPortfolioResponse::userId) + .containsExactly(10L, 20L, 30L); + + PublicMonthlyPortfolioResponse alice = result.get(0); + assertThat(alice.totalIncome()).isEqualByComparingTo("5000000"); + assertThat(alice.totalExpense()).isEqualByComparingTo("2000000"); + assertThat(alice.totalBudget()).isEqualByComparingTo("3000000"); + assertThat(alice.balance()).isEqualByComparingTo("3000000"); + + PublicMonthlyPortfolioResponse bob = result.get(1); + assertThat(bob.totalExpense()).isEqualByComparingTo("0"); + assertThat(bob.balance()).isEqualByComparingTo("4000000"); + + PublicMonthlyPortfolioResponse carol = result.get(2); + assertThat(carol.totalIncome()).isEqualByComparingTo("0"); + assertThat(carol.totalBudget()).isEqualByComparingTo("0"); + assertThat(carol.balance()).isEqualByComparingTo("-1500000"); + } + + @Test + @DisplayName("공개 사용자 포트폴리오 목록 - 금액 구간 필터를 그대로 Repository 에 전달") + void getPublicPortfolios_filter_by_amount_ranges() { + + // given + PortfolioFilterRequest filter = new PortfolioFilterRequest( + 2026, 6, + new BigDecimal("4500000"), null, + null, new BigDecimal("2500000"), + null, null); + + given(portfolioAggregationRepository.findPublicPortfolios( + eq(LocalDate.of(2026, 6, 1)), + eq(LocalDate.of(2026, 6, 30)), + eq("2026-06"), + eq(new BigDecimal("4500000")), eq(null), + eq(null), eq(new BigDecimal("2500000")), + eq(null), eq(null))) + .willReturn(List.of( + new PublicPortfolioRow(10L, "alice", + new BigDecimal("5000000"), new BigDecimal("2000000"), BigDecimal.ZERO))); + + // when + List result = portfolioService.getPublicPortfolios(filter); + + // then + assertThat(result).extracting(PublicMonthlyPortfolioResponse::userId).containsExactly(10L); + verify(portfolioAggregationRepository).findPublicPortfolios( + eq(LocalDate.of(2026, 6, 1)), + eq(LocalDate.of(2026, 6, 30)), + eq("2026-06"), + eq(new BigDecimal("4500000")), eq(null), + eq(null), eq(new BigDecimal("2500000")), + eq(null), eq(null)); + } + + @Test + @DisplayName("공개 사용자 목록 - 연/월 누락 시 예외") + void getPublicPortfolios_year_month_required() { + + // given + PortfolioFilterRequest filter = new PortfolioFilterRequest( + 2026, null, null, null, null, null, null, null); + + // when & then + assertThatThrownBy(() -> portfolioService.getPublicPortfolios(filter)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.INVALID_YEAR_MONTH); + } + + @Test + @DisplayName("공개 사용자 목록 - 금액 구간 역전 시 예외") + void getPublicPortfolios_invalid_amount_range() { + + // given + PortfolioFilterRequest filter = new PortfolioFilterRequest( + 2026, 6, null, null, + new BigDecimal("3000000"), new BigDecimal("1000000"), + null, null); + + // when & then + assertThatThrownBy(() -> portfolioService.getPublicPortfolios(filter)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.INVALID_AMOUNT_RANGE); + } + + @Test + @DisplayName("그룹 평균 비교 - 탭별 수입/지출/예산 비교 결과를 묶어 반환하고 각 도메인 요청을 매핑") + void compareWithGroup_delegates_with_mapped_type() { + + // given + PortfolioCompareRequest request = new PortfolioCompareRequest( + PortfolioCompareType.AMOUNT, "2026-06", + new BigDecimal("1000000"), new BigDecimal("6000000"), + new BigDecimal("500000"), new BigDecimal("4000000"), + new BigDecimal("800000"), new BigDecimal("5000000"), + null, null); + + IncomeCompareResponse incomeResp = IncomeCompareResponse.of( + IncomeCompareType.AMOUNT, "2026-06", + BigDecimal.ZERO, new BigDecimal("5000000"), + BigDecimal.ZERO, new BigDecimal("4000000"), 3L); + ExpenseCompareResponse expenseResp = ExpenseCompareResponse.of( + ExpenseCompareType.AMOUNT, "2026-06", + BigDecimal.ZERO, new BigDecimal("3000000"), + BigDecimal.ZERO, new BigDecimal("2500000"), 3L); + BudgetCompareResponse budgetResp = BudgetCompareResponse.of( + BudgetCompareType.AMOUNT, "2026-06", + new BigDecimal("3500000"), new BigDecimal("3000000"), 3L); + + given(incomeCompareService.compareWithGroup(eq(1L), any(IncomeCompareRequest.class))) + .willReturn(incomeResp); + given(expenseCompareService.compareWithGroup(eq(1L), any(ExpenseCompareRequest.class))) + .willReturn(expenseResp); + given(budgetCompareService.compareWithGroup(eq(1L), any(BudgetCompareRequest.class))) + .willReturn(budgetResp); + + // when + PortfolioCompareResponse response = portfolioService.compareWithGroup(1L, request); + + // then - 묶인 응답 + assertThat(response.type()).isEqualTo(PortfolioCompareType.AMOUNT); + assertThat(response.yearMonth()).isEqualTo("2026-06"); + assertThat(response.income()).isSameAs(incomeResp); + assertThat(response.expense()).isSameAs(expenseResp); + assertThat(response.budget()).isSameAs(budgetResp); + + // then - 각 도메인에 자신의 min/max 가 전달되었는지 검증 + ArgumentCaptor incomeCap = ArgumentCaptor.forClass(IncomeCompareRequest.class); + verify(incomeCompareService).compareWithGroup(eq(1L), incomeCap.capture()); + assertThat(incomeCap.getValue().type()).isEqualTo(IncomeCompareType.AMOUNT); + assertThat(incomeCap.getValue().minAmount()).isEqualByComparingTo("1000000"); + assertThat(incomeCap.getValue().maxAmount()).isEqualByComparingTo("6000000"); + + ArgumentCaptor expenseCap = ArgumentCaptor.forClass(ExpenseCompareRequest.class); + verify(expenseCompareService).compareWithGroup(eq(1L), expenseCap.capture()); + assertThat(expenseCap.getValue().type()).isEqualTo(ExpenseCompareType.AMOUNT); + assertThat(expenseCap.getValue().minAmount()).isEqualByComparingTo("500000"); + assertThat(expenseCap.getValue().maxAmount()).isEqualByComparingTo("4000000"); + + ArgumentCaptor budgetCap = ArgumentCaptor.forClass(BudgetCompareRequest.class); + verify(budgetCompareService).compareWithGroup(eq(1L), budgetCap.capture()); + assertThat(budgetCap.getValue().type()).isEqualTo(BudgetCompareType.AMOUNT); + assertThat(budgetCap.getValue().minAmount()).isEqualByComparingTo("800000"); + assertThat(budgetCap.getValue().maxAmount()).isEqualByComparingTo("5000000"); + } + + @Test + @DisplayName("그룹 평균 비교 - 금액 구간 역전 시 예외") + void compareWithGroup_invalid_amount_range() { + + // given - 지출 min > max + PortfolioCompareRequest request = new PortfolioCompareRequest( + PortfolioCompareType.AMOUNT, "2026-06", + null, null, + new BigDecimal("3000000"), new BigDecimal("1000000"), + null, null, null, null); + + // when & then + assertThatThrownBy(() -> portfolioService.compareWithGroup(1L, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.INVALID_AMOUNT_RANGE); + } + + @Test + @DisplayName("두 사용자 포트폴리오 세부 조회 - 본인+대상자 수입/지출/예산 세부 및 잔액 반환") + void getPairPortfolioDetails_success() { + + // given + UserIncomeDetailResponse myIncome = new UserIncomeDetailResponse( + 1L, "me", "2026-06", + new BigDecimal("5000000"), BigDecimal.ZERO, new BigDecimal("5000000"), + List.of(), List.of()); + UserIncomeDetailResponse targetIncome = new UserIncomeDetailResponse( + 2L, "target", "2026-06", + new BigDecimal("4000000"), BigDecimal.ZERO, new BigDecimal("4000000"), + List.of(), List.of()); + UserExpenseDetailResponse myExpense = new UserExpenseDetailResponse( + 1L, "me", "2026-06", + new BigDecimal("2000000"), BigDecimal.ZERO, new BigDecimal("2000000"), + List.of(), List.of()); + UserExpenseDetailResponse targetExpense = new UserExpenseDetailResponse( + 2L, "target", "2026-06", + new BigDecimal("1500000"), BigDecimal.ZERO, new BigDecimal("1500000"), + List.of(), List.of()); + UserBudgetDetailResponse myBudget = new UserBudgetDetailResponse( + 1L, "me", "2026-06", new BigDecimal("2500000"), List.of()); + UserBudgetDetailResponse targetBudget = new UserBudgetDetailResponse( + 2L, "target", "2026-06", new BigDecimal("2000000"), List.of()); + + given(incomeCompareService.getPairIncomeDetails(1L, 2L, "2026-06")) + .willReturn(new PairIncomeDetailResponse(myIncome, targetIncome)); + given(expenseCompareService.getPairExpenseDetails(1L, 2L, "2026-06")) + .willReturn(new PairExpenseDetailResponse(myExpense, targetExpense)); + given(budgetCompareService.getPairBudgetDetails(1L, 2L, "2026-06")) + .willReturn(new PairBudgetDetailResponse(myBudget, targetBudget)); + + // when + PairPortfolioDetailResponse response = portfolioService.getPairPortfolioDetails(1L, 2L, "2026-06"); + + // then + assertThat(response.me().userId()).isEqualTo(1L); + assertThat(response.me().totalIncome()).isEqualByComparingTo("5000000"); + assertThat(response.me().totalExpense()).isEqualByComparingTo("2000000"); + assertThat(response.me().totalBudget()).isEqualByComparingTo("2500000"); + assertThat(response.me().balance()).isEqualByComparingTo("3000000"); + + assertThat(response.target().userId()).isEqualTo(2L); + assertThat(response.target().totalIncome()).isEqualByComparingTo("4000000"); + assertThat(response.target().totalExpense()).isEqualByComparingTo("1500000"); + assertThat(response.target().totalBudget()).isEqualByComparingTo("2000000"); + assertThat(response.target().balance()).isEqualByComparingTo("2500000"); + } + + @Test + @DisplayName("두 사용자 세부 조회 - 자기 자신과는 비교 불가") + void getPairPortfolioDetails_self_compare_forbidden() { + + // when & then + assertThatThrownBy(() -> portfolioService.getPairPortfolioDetails(1L, 1L, "2026-06")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.CANNOT_COMPARE_SELF); + } + + @Test + @DisplayName("선택 사용자 비교 - 탭별 수입/지출/예산 비교 결과를 묶어 반환") + void compareWithUser_success() { + + // given + UserIncomeCompareResponse incomeResp = UserIncomeCompareResponse.of( + IncomeCompareType.CATEGORY, "2026-06", + 1L, "카테고리 #10", BigDecimal.ZERO, new BigDecimal("3000000"), + 2L, "카테고리 #10", BigDecimal.ZERO, new BigDecimal("2500000")); + UserExpenseCompareResponse expenseResp = UserExpenseCompareResponse.of( + ExpenseCompareType.CATEGORY, "2026-06", + 1L, "카테고리 #10", BigDecimal.ZERO, new BigDecimal("1000000"), + 2L, "카테고리 #10", BigDecimal.ZERO, new BigDecimal("800000")); + UserBudgetCompareResponse budgetResp = UserBudgetCompareResponse.of( + BudgetCompareType.CATEGORY, "2026-06", + 1L, "카테고리 #10", new BigDecimal("1200000"), + 2L, "카테고리 #10", new BigDecimal("900000")); + + given(incomeCompareService.compareWithUser(1L, 2L, IncomeCompareType.CATEGORY, "2026-06", 10L)) + .willReturn(incomeResp); + given(expenseCompareService.compareWithUser(1L, 2L, ExpenseCompareType.CATEGORY, "2026-06", 10L)) + .willReturn(expenseResp); + given(budgetCompareService.compareWithUser(1L, 2L, BudgetCompareType.CATEGORY, "2026-06", 10L)) + .willReturn(budgetResp); + + // when + UserPortfolioCompareResponse response = portfolioService.compareWithUser( + 1L, 2L, PortfolioCompareType.CATEGORY, "2026-06", 10L); + + // then + assertThat(response.type()).isEqualTo(PortfolioCompareType.CATEGORY); + assertThat(response.myUserId()).isEqualTo(1L); + assertThat(response.targetUserId()).isEqualTo(2L); + assertThat(response.income()).isSameAs(incomeResp); + assertThat(response.expense()).isSameAs(expenseResp); + assertThat(response.budget()).isSameAs(budgetResp); + } + + @Test + @DisplayName("선택 사용자 비교 - 자기 자신과는 비교 불가") + void compareWithUser_self_compare_forbidden() { + + // when & then + assertThatThrownBy(() -> portfolioService.compareWithUser( + 1L, 1L, PortfolioCompareType.AMOUNT, "2026-06", null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.CANNOT_COMPARE_SELF); + } + + @Test + @DisplayName("선택 사용자 비교 - 잘못된 yearMonth 포맷 예외") + void compareWithUser_invalid_year_month() { + + // when & then + assertThatThrownBy(() -> portfolioService.compareWithUser( + 1L, 2L, PortfolioCompareType.AMOUNT, "26-06", null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PortfolioErrorCode.INVALID_YEAR_MONTH); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/tag/application/TagServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/tag/application/TagServiceTest.java new file mode 100644 index 0000000..4331f3f --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/tag/application/TagServiceTest.java @@ -0,0 +1,151 @@ +package com.chaewookim.accountbookformoms.domain.tag.application; + +import com.chaewookim.accountbookformoms.domain.tag.dao.BoardTagRepository; +import com.chaewookim.accountbookformoms.domain.tag.dao.TagRepository; +import com.chaewookim.accountbookformoms.domain.tag.entity.BoardTag; +import com.chaewookim.accountbookformoms.domain.tag.entity.Tag; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TagServiceTest { + + @Mock private TagRepository tagRepository; + @Mock private BoardTagRepository boardTagRepository; + + @InjectMocks + private TagService tagService; + + private static final Long BOARD_ID = 100L; + + private Tag tagWithId(Long id, String name) { + Tag t = Tag.builder().name(name).build(); + ReflectionTestUtils.setField(t, "id", id); + return t; + } + + @Test + @DisplayName("setTagsForBoard — 기존 태그 재사용 + 신규 태그 생성 후 BoardTag 저장") + void set_tags_reuses_existing_and_creates_new() { + Tag existing = tagWithId(1L, "재테크"); + given(tagRepository.findByNameIn(List.of("재테크", "신규"))).willReturn(List.of(existing)); + Tag newSaved = tagWithId(2L, "신규"); + given(tagRepository.save(any(Tag.class))).willReturn(newSaved); + + tagService.setTagsForBoard(BOARD_ID, List.of("재테크", "신규")); + + verify(boardTagRepository).deleteByBoardId(BOARD_ID); + verify(tagRepository, times(1)).save(any(Tag.class)); // "신규" 만 저장 + verify(boardTagRepository, times(2)).save(any(BoardTag.class)); + } + + @Test + @DisplayName("setTagsForBoard — null/빈 태그는 필터, 중복 제거") + void set_tags_filters_blanks_and_dedupes() { + Tag existing = tagWithId(1L, "절약"); + given(tagRepository.findByNameIn(List.of("절약"))).willReturn(List.of(existing)); + + tagService.setTagsForBoard(BOARD_ID, java.util.Arrays.asList("절약", " ", null, "절약")); + + verify(boardTagRepository).deleteByBoardId(BOARD_ID); + verify(boardTagRepository, times(1)).save(any(BoardTag.class)); + verify(tagRepository, never()).save(any(Tag.class)); + } + + @Test + @DisplayName("setTagsForBoard(null/empty) — 삭제만 하고 종료") + void set_tags_null_only_deletes() { + tagService.setTagsForBoard(BOARD_ID, null); + verify(boardTagRepository).deleteByBoardId(BOARD_ID); + verify(boardTagRepository, never()).save(any()); + } + + @Test + @DisplayName("tagsOfBoard — link 순서를 유지하며 태그 이름 반환") + void tags_of_board_preserves_order() { + BoardTag bt1 = BoardTag.builder().boardId(BOARD_ID).tagId(1L).build(); + BoardTag bt2 = BoardTag.builder().boardId(BOARD_ID).tagId(2L).build(); + given(boardTagRepository.findByBoardId(BOARD_ID)).willReturn(List.of(bt1, bt2)); + given(tagRepository.findAllById(List.of(1L, 2L))) + .willReturn(List.of(tagWithId(1L, "재테크"), tagWithId(2L, "절약"))); + + List result = tagService.tagsOfBoard(BOARD_ID); + + assertThat(result).containsExactly("재테크", "절약"); + } + + @Test + @DisplayName("tagsByBoards — board_id → 태그 이름 리스트 매핑") + void tags_by_boards_map() { + BoardTag bt1 = BoardTag.builder().boardId(10L).tagId(1L).build(); + BoardTag bt2 = BoardTag.builder().boardId(10L).tagId(2L).build(); + BoardTag bt3 = BoardTag.builder().boardId(20L).tagId(1L).build(); + given(boardTagRepository.findByBoardIdIn(List.of(10L, 20L))).willReturn(List.of(bt1, bt2, bt3)); + given(tagRepository.findAllById(any())) + .willReturn(List.of(tagWithId(1L, "재테크"), tagWithId(2L, "절약"))); + + Map> result = tagService.tagsByBoards(List.of(10L, 20L)); + + assertThat(result.get(10L)).containsExactlyInAnyOrder("재테크", "절약"); + assertThat(result.get(20L)).containsExactly("재테크"); + } + + @Test + @DisplayName("boardIdsWithTag — 태그 이름 없으면 빈 리스트") + void board_ids_with_unknown_tag() { + given(tagRepository.findByName("없는태그")).willReturn(Optional.empty()); + + assertThat(tagService.boardIdsWithTag("없는태그")).isEmpty(); + } + + @Test + @DisplayName("boardIdsWithTag — 태그 존재 시 boardTagRepository 위임") + void board_ids_with_existing_tag() { + Tag t = tagWithId(1L, "재테크"); + given(tagRepository.findByName("재테크")).willReturn(Optional.of(t)); + given(boardTagRepository.findBoardIdsByTagId(1L)).willReturn(List.of(10L, 20L)); + + List result = tagService.boardIdsWithTag("재테크"); + + assertThat(result).containsExactly(10L, 20L); + } + + @Test + @DisplayName("listAll — 저장된 태그 전체 반환") + void list_all() { + given(tagRepository.findAll()).willReturn(List.of(tagWithId(1L, "재테크"))); + + assertThat(tagService.listAll()).hasSize(1); + } + + @Test + @DisplayName("setTagsForBoard 저장 인자 검증 — 각 BoardTag 가 올바른 boardId/tagId 조합") + void set_tags_saves_correct_pairs() { + Tag existing = tagWithId(1L, "재테크"); + given(tagRepository.findByNameIn(List.of("재테크"))).willReturn(List.of(existing)); + + tagService.setTagsForBoard(BOARD_ID, List.of("재테크")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BoardTag.class); + verify(boardTagRepository).save(captor.capture()); + assertThat(captor.getValue().getBoardId()).isEqualTo(BOARD_ID); + assertThat(captor.getValue().getTagId()).isEqualTo(1L); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/FixedTransactionServiceTests.java b/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/FixedTransactionServiceTests.java deleted file mode 100644 index ab0c4ce..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/FixedTransactionServiceTests.java +++ /dev/null @@ -1,367 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.transaction.application; - -import com.chaewookim.accountbookformoms.domain.transaction.dao.FixedTransactionRepository; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionAccountUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionAmountUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionCategoryUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionMemoUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionRepeatDateUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionTitleUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.fixedtransaction.FixedTransactionTypeUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.response.transaction.FixedTransactionResponse; -import com.chaewookim.accountbookformoms.domain.transaction.entity.FixedTransaction; -import com.chaewookim.accountbookformoms.domain.transaction.enums.TransactionTypeEnum; -import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -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.test.util.ReflectionTestUtils; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class FixedTransactionServiceTests { - @InjectMocks - private FixedTransactionService fixedTransactionService; - - @Mock - private FixedTransactionRepository fixedTransactionRepository; - - Long userId; - Long assetId; - Long newAssetId; - Long fixedTransactionId; - Long categoryId; - Long newCategoryId; - BigDecimal amount; - BigDecimal newAmount; - - private FixedTransaction fixedTransaction; - private FixedTransactionRequest fixedTransactionRequest; - private FixedTransactionTitleUpdate fixedTransactionTitleUpdate; - private FixedTransactionMemoUpdate fixedTransactionMemoUpdate; - private FixedTransactionAccountUpdate fixedTransactionAccountUpdate; - private FixedTransactionCategoryUpdate fixedTransactionCategoryUpdate; - private FixedTransactionAmountUpdate fixedTransactionAmountUpdate; - private FixedTransactionRepeatDateUpdate fixedTransactionRepeatDateUpdate; - private FixedTransactionTypeUpdate fixedTransactionTypeUpdate; - - @BeforeEach - void setUp() { - userId = 1L; - assetId = 10L; - fixedTransactionId = 20L; - categoryId = 30L; - - amount = BigDecimal.valueOf(300000); - - newAssetId = 100L; - newCategoryId = 300L; - newAmount = BigDecimal.valueOf(100000000); - - fixedTransaction = FixedTransaction.builder() - .title("title") - .memo("memo") - .userId(userId) - .assetId(assetId) - .categoryId(categoryId) - .amount(amount) - .type(TransactionTypeEnum.EXPENSE) - .repeatDate(LocalDateTime.now()) - .build(); - ReflectionTestUtils.setField(fixedTransaction, "id", fixedTransactionId); - - fixedTransactionRequest = new FixedTransactionRequest( - "reqTitle", - "reqMemo", - userId, - assetId, - categoryId, - amount, - TransactionTypeEnum.INCOME, - LocalDateTime.now() - ); - - fixedTransactionTitleUpdate = new FixedTransactionTitleUpdate( - "updateTitle" - ); - - fixedTransactionMemoUpdate = new FixedTransactionMemoUpdate( - "updateMemo" - ); - - fixedTransactionAccountUpdate = new FixedTransactionAccountUpdate( - newAssetId - ); - - fixedTransactionCategoryUpdate = new FixedTransactionCategoryUpdate( - newCategoryId - ); - - fixedTransactionAmountUpdate = new FixedTransactionAmountUpdate( - newAmount - ); - - fixedTransactionRepeatDateUpdate = new FixedTransactionRepeatDateUpdate( - LocalDateTime.now() - ); - - fixedTransactionTypeUpdate = new FixedTransactionTypeUpdate( - TransactionTypeEnum.INCOME - ); - } - - @Test - @DisplayName("고정지출 추가 - 성공") - void createFixedExpense_Success() { - // when - FixedTransactionResponse response = fixedTransactionService.createFix(fixedTransactionRequest, userId); - - // then - assertNotNull(response); - assertEquals("title", fixedTransaction.getTitle()); - } - - - @Test - @DisplayName("고정지출/고정수입 조회 - 성공") - void getAllFixedTransactions_Success() { - // given - given(fixedTransactionRepository.findAllByUserId(userId)).willReturn(List.of(fixedTransaction)); - - // when - List response = fixedTransactionService.getAllFixedTransactions(userId); - - // then - assertNotNull(response); - - assertEquals(1, response.size()); - assertEquals("title", response.get(0).title()); - } - @Test - @DisplayName("고정지출/고정수입 조회 - 실패 - 조회 결과 없음") - void getAllFixedTransactions_Failure() { - // given - given(fixedTransactionRepository.findAllByUserId(userId)).willReturn(Collections.emptyList()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.getAllFixedTransactions(userId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 타이틀 수정 - 성공") - void updateTitle_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionTitleUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 타이틀 수정 - 실패 - 조회 결과 없음") - void updateTitle_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionTitleUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 메모 수정 - 성공") - void updateMemo_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionMemoUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 메모 수정 - 실패 - 조회 결과 없음") - void updateMemo_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionMemoUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 계좌 수정 - 성공") - void updateAccount_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionAccountUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 계좌 수정 - 실패 - 조회 결과 없음") - void updateAccount_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionAccountUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 카테고리 수정 - 성공") - void updateCategory_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionCategoryUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 카테고리 수정 - 실패 - 조회 결과 없음") - void updateCategory_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionCategoryUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 금액 수정 - 성공") - void updateAmount_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionAmountUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 금액 수정 - 실패 - 조회 결과 없음") - void updateAmount_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionAmountUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 날짜 수정 - 성공") - void updateDate_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionRepeatDateUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 날짜 수정 - 실패 - 조회 결과 없음") - void updateDate_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionRepeatDateUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 타입 수정 - 성공") - void updateType_Success() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.of(fixedTransaction)); - - // when - FixedTransactionResponse response = fixedTransactionService.updateFix(fixedTransactionTypeUpdate, userId, fixedTransactionId); - - // then - assertNotNull(response); - assertEquals("updateTitle", fixedTransactionTitleUpdate.title()); - } - @Test - @DisplayName("고정 트랜잭션 타입 수정 - 실패 - 조회 결과 없음") - void updateType_Failure() { - // given - given(fixedTransactionRepository.findByIdAndUserId(fixedTransactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.updateFix(fixedTransactionTypeUpdate, userId, fixedTransactionId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } - - - @Test - @DisplayName("고정 트랜잭션 삭제 - 성공") - void deleteFixedTransaction_Success() { - // given - given(fixedTransactionRepository.deleteByIdAndUserId(fixedTransactionId, userId)).willReturn(1L); - - // when - fixedTransactionService.deleteFix(fixedTransactionId, userId); - - // then - verify(fixedTransactionRepository, times(1)).deleteByIdAndUserId(fixedTransactionId, userId); - } - @Test - @DisplayName("고정 트랜잭션 삭제 - 실패 - 삭제할 행이 없음") - void deleteFixedTransaction_Failure() { - // given - given(fixedTransactionRepository.deleteByIdAndUserId(fixedTransactionId, userId)).willReturn(0L); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> fixedTransactionService.deleteFix(fixedTransactionId, userId)); - assertEquals(ErrorCode.NO_FIXED_TRANSACTIONS, exception.getErrorCode()); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/TransactionServiceTests.java b/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/TransactionServiceTests.java deleted file mode 100644 index 4425702..0000000 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/transaction/application/TransactionServiceTests.java +++ /dev/null @@ -1,372 +0,0 @@ -package com.chaewookim.accountbookformoms.domain.transaction.application; - -import com.chaewookim.accountbookformoms.domain.asset.dao.AssetRepository; -import com.chaewookim.accountbookformoms.domain.asset.entity.Asset; -import com.chaewookim.accountbookformoms.domain.asset.entity.BankEnum; -import com.chaewookim.accountbookformoms.domain.transaction.dao.TransactionRepository; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionAccountRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionAmountRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionMemoRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionTitleUpdate; -import com.chaewookim.accountbookformoms.domain.transaction.dto.request.transaction.TransactionTypeRequest; -import com.chaewookim.accountbookformoms.domain.transaction.dto.response.transaction.TransactionResponse; -import com.chaewookim.accountbookformoms.domain.transaction.entity.Transaction; -import com.chaewookim.accountbookformoms.domain.transaction.enums.TransactionTypeEnum; -import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -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.test.util.ReflectionTestUtils; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class TransactionServiceTests { - @InjectMocks - private TransactionService transactionService; - - @Mock - private TransactionRepository transactionRepository; - - @Mock - private AssetRepository assetRepository; - - @Mock - private UserRepository userRepository; - - String username; - - Long userId; - Long assetId; - Long newAssetId; - Long transactionId; - Long categoryId; - - BigDecimal amount; - BigDecimal newAmount; - - private TransactionRequest transactionRequest; - private TransactionTitleUpdate requestTitle; - private TransactionMemoRequest requestMemo; - private TransactionAccountRequest requestAccount; - private TransactionAmountRequest requestAmount; - private TransactionTypeRequest requestType; - private Transaction transaction; - - private User user; - private Asset asset; - - @BeforeEach - void setUp() { - username = "username"; - - userId = 1L; - assetId = 10L; - newAssetId = 11L; - transactionId = 20L; - categoryId = 30L; - - amount = BigDecimal.valueOf(30000); - newAmount = BigDecimal.valueOf(100000); - - transactionRequest = new TransactionRequest( - "reqTitle", - "reqMemo", - userId, - assetId, - categoryId, - BigDecimal.valueOf(30000), - TransactionTypeEnum.EXPENSE, - false, - null, - LocalDateTime.now() - ); - - requestTitle = new TransactionTitleUpdate( - "newTitle" - ); - - requestMemo = new TransactionMemoRequest( - "newMemo" - ); - - requestAccount = new TransactionAccountRequest( - newAssetId - ); - - requestAmount = new TransactionAmountRequest( - newAmount, - TransactionTypeEnum.EXPENSE, - assetId - ); - - requestType = new TransactionTypeRequest( - TransactionTypeEnum.EXPENSE - ); - - transaction = Transaction.builder() - .title("title") - .memo("memo") - .userId(userId) - .assetId(assetId) - .categoryId(categoryId) - .amount(amount) - .type(TransactionTypeEnum.INCOME) - .isFixed(false) - .build(); - ReflectionTestUtils.setField(transaction, "id", transactionId); - - user = User.builder() - .username(username) - .build(); - ReflectionTestUtils.setField(user, "id", userId); - - asset = Asset.builder() - .bank(BankEnum.KB) - .accountNumber("1234") - .balance(amount) - .username(username) - .build(); - ReflectionTestUtils.setField(asset, "id", assetId); - } - - @Test - @DisplayName("트랜잭션 생성 - 성공") - void createTransaction() { - // given - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - - given(assetRepository.findByIdAndUsername(assetId, username)).willReturn(Optional.of(asset)); - - // when - TransactionResponse response = transactionService.createTransaction(transactionRequest, userId); - - // then - assertNotNull(response); - - assertEquals(userId, response.userId()); - assertEquals(assetId, response.assetId()); - assertEquals("reqTitle", response.title()); - } - - - @Test - @DisplayName("트랜잭션 전체 조회 - 성공") - void getAllTransaction_Success() { - // given - given(transactionRepository.findAllByUserId(userId)).willReturn(List.of(transaction)); - - // when - List responses = transactionService.getAllTransactions(userId); - - // then - assertNotNull(responses); - - assertEquals(1, responses.size()); - - assertEquals("title", responses.get(0).title()); - } - - - @Test - @DisplayName("트랜잭션 타이틀 수정 - 성공") - void updateTransactionTitle_Success() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.of(transaction)); - - // when - TransactionResponse response = transactionService.updateTransactionTitle(transactionId, requestTitle, userId); - - // then - assertNotNull(response); - - assertEquals("newTitle", response.title()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 타이틀 수정 - 실패 - TRANSACTION_NOT_FOUND") - void updateTransactionTitle_Failure() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.updateTransactionTitle(transactionId, requestTitle, userId)); - - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - - - @Test - @DisplayName("트랜잭션 메모 수정 - 성공") - void updateTransactionMemo_Success() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.of(transaction)); - - // when - TransactionResponse response = transactionService.updateTransactionMemo(transactionId, requestMemo, userId); - - // then - assertNotNull(response); - - assertEquals("newMemo", response.memo()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 메모 수정 - 실패 - TRANSACTION_NOT_FOUND") - void updateTransactionMemo_Failure() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.updateTransactionMemo(transactionId, requestMemo, userId)); - - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - - - @Test - @DisplayName("트랜잭션 계좌 수정 - 성공") - void updateTransactionAccount_Success() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.of(transaction)); - - // when - TransactionResponse response = transactionService.updateTransactionAccount(transactionId, requestAccount, userId); - - // then - assertNotNull(response); - - assertEquals(newAssetId, response.assetId()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 계좌 수정 - 실패 - TRANSACTION_NOT_FOUND") - void updateTransactionAccount_Failure() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.updateTransactionAccount(transactionId, requestAccount, userId)); - - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - - - @Test - @DisplayName("트랜잭션 금액 수정 - 성공") - void updateTransactionAmount_Success() { - // given - given(assetRepository.findByIdAndUsername(assetId, username)).willReturn(Optional.of(asset)); - - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.of(transaction)); - - // when - TransactionResponse response = transactionService.updateTransactionAmount(transactionId, requestAmount, userId); - - // then - assertNotNull(response); - - assertEquals(newAmount, response.amount()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 금액 수정 - 실패 - TRANSACTION_NOT_FOUND") - void updateTransactionAmount_Failure() { - // given - given(assetRepository.findByIdAndUsername(assetId, username)).willReturn(Optional.of(asset)); - - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.updateTransactionAmount(transactionId, requestAmount, userId)); - - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - - - @Test - @DisplayName("트랜잭션 타입 수정 - 성공") - void updateTransactionType_Success() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.of(transaction)); - - // when - TransactionResponse response = transactionService.updateTransactionType(transactionId, requestType, userId); - - // then - assertNotNull(response); - - assertEquals(TransactionTypeEnum.EXPENSE, response.type()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 타입 수정 - 실패 - TRANSACTION_NOT_FOUND") - void updateTransactionType_Failure() { - // given - given(transactionRepository.findByIdAndUserId(transactionId, userId)).willReturn(Optional.empty()); - - // when & then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.updateTransactionType(transactionId, requestType, userId)); - - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - verify(transactionRepository, times(1)).findByIdAndUserId(transactionId, userId); - } - - - @Test - @DisplayName("트랜잭션 삭제 - 성공") - void deleteTransaction_Success() { - // given - given(transactionRepository.deleteByIdAndUserId(transactionId, userId)).willReturn(1L); - - // when - transactionService.deleteTransaction(transactionId, userId); - - // then - verify(transactionRepository, times(1)).deleteByIdAndUserId(transactionId, userId); - } - @Test - @DisplayName("트랜잭션 삭제 - 실패") - void deleteTransaction_Failure() { - // given - given(transactionRepository.deleteByIdAndUserId(transactionId, userId)).willReturn(0L); - - // then - CustomException exception = assertThrows(CustomException.class, () -> - transactionService.deleteTransaction(transactionId, userId)); - assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode()); - - verify(transactionRepository, times(1)).deleteByIdAndUserId(transactionId, userId); - } -} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/AuthServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/AuthServiceTest.java index c55d3cb..d9b6e82 100644 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/AuthServiceTest.java +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/AuthServiceTest.java @@ -2,171 +2,207 @@ import com.chaewookim.accountbookformoms.domain.user.dao.RefreshTokenRepository; import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; -import com.chaewookim.accountbookformoms.domain.user.domain.RefreshToken; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import com.chaewookim.accountbookformoms.domain.user.dto.request.LogoutRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.TokenReissueRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.LoginRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.ReissueRequest; import com.chaewookim.accountbookformoms.domain.user.dto.response.TokenResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.RefreshToken; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; -import com.chaewookim.accountbookformoms.global.jwt.JwtTokenProvider; +import com.chaewookim.accountbookformoms.global.security.jwt.JwtTokenProvider; 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.security.core.Authentication; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class AuthServiceTest { - @InjectMocks - private AuthService authService; + @Mock + private EmailVerificationService emailVerificationService; @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder encoder; + @Mock private JwtTokenProvider jwtTokenProvider; @Mock - private UserRepository userRepository; + private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + @InjectMocks + private AuthService authService; @Test - @DisplayName("토큰 재발급 성공 테스트") - void refreshToken_Success() { + @DisplayName("로그인 - 성공") + void login_success() { // given - // 가짜 요청 객체 - String requestToken = "token"; - TokenReissueRequest request = new TokenReissueRequest(requestToken); - - // 기존 객체 정보 - Long userId = 1L; - String email = "email@gmail.com"; - String newAccessToken = "newAccessToken"; - String newRefreshToken = "newRefreshToken"; - - // User 객체 - User user = User.forTestBuilder() - .id(userId) - .username("username") - .email(email) - .password("passowrd") - .isAdmin(false) - .address("집") - .build(); - - // 명시적으로 userId 넣어줌 - ReflectionTestUtils.setField(user, "id", userId); - - // 저장된 객체 생성 - RefreshToken savedToken = RefreshToken.forTestBuilder() - .userId(userId) - .token(requestToken) - .build(); - - // 토큰 유효성 검사 통과 - given(jwtTokenProvider.validateToken(requestToken)).willReturn(true); - - // 토큰에서 이메일 추출 - given(jwtTokenProvider.getSubject(requestToken)).willReturn(email); - - // 이메일로 유저 찾기 - given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + LoginRequest request = new LoginRequest("test@email.com", "password123"); + User user = User.builder().email("test@email.com").password("encoded").role(UserRole.ROLE_USER).build(); - // 유저 id로 저장된 토큰 검색 - given(refreshTokenRepository.findByUserId(userId)).willReturn(Optional.of(savedToken)); + given(userRepository.findByEmail(request.email())).willReturn(Optional.of(user)); + given(encoder.matches(request.password(), user.getPassword())).willReturn(true); + given(jwtTokenProvider.createAccessToken(any(), any())).willReturn("new-access"); + given(jwtTokenProvider.createRefreshToken(any())).willReturn("new-refresh"); - // 새 토큰 생성 결과 설정 - given(jwtTokenProvider.createAccessToken(any(Authentication.class))).willReturn(newAccessToken); - given(jwtTokenProvider.createRefreshToken(any(Authentication.class))).willReturn(newRefreshToken); + // when + TokenResponse response = authService.login(request); + + // then + assertThat(response.accessToken()).isEqualTo("new-access"); + assertThat(response.refreshToken()).isEqualTo("new-refresh"); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("토큰 재발급 - 성공") + void reissue_success() { + + // given + String oldToken = "old-refresh"; + String email = "test@email.com"; + ReissueRequest request = new ReissueRequest(oldToken); + RefreshToken savedToken = new RefreshToken(email, oldToken); + User user = User.builder().email(email).role(UserRole.ROLE_USER).build(); + + given(jwtTokenProvider.validateToken(oldToken)).willReturn(true); + given(refreshTokenRepository.findByToken(oldToken)).willReturn(Optional.of(savedToken)); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + given(jwtTokenProvider.createAccessToken(any(), any())).willReturn("new-access"); + given(jwtTokenProvider.createRefreshToken(any())).willReturn("new-refresh"); // when TokenResponse response = authService.reissue(request); // then - // 응답에 새 토큰 잘 들어갔는지 - assertEquals(newAccessToken, response.accessToken()); - assertEquals(newRefreshToken, response.refreshToken()); - - // dirty checking으로 기존 토큰 객체 값이 새 토큰으로 바뀌었는지 확인 - assertEquals(newRefreshToken, savedToken.getToken()); + assertThat(response.accessToken()).isEqualTo("new-access"); + assertThat(response.refreshToken()).isEqualTo("new-refresh"); + verify(refreshTokenRepository).delete(savedToken); + verify(refreshTokenRepository).save(any(RefreshToken.class)); } @Test - @DisplayName("토큰 재발급 실패 테스트 - 유효하지 않은 토큰") - void refreshToken_Failure_invalidToken() { + @DisplayName("토큰 재발급 - 유효하지 않은 토큰 예외 발생") + void reissue_fail_invalid_token() { // given - String invalidToken = "invalidToken"; - TokenReissueRequest request = new TokenReissueRequest(invalidToken); - - // validateToken이 false를 return - given(jwtTokenProvider.validateToken(invalidToken)).willReturn(false); + ReissueRequest request = new ReissueRequest("invalid-token"); + given(jwtTokenProvider.validateToken(any())).willReturn(false); // when & then - // 예외 발생 검증 - CustomException exception = assertThrows(CustomException.class, () -> authService.reissue(request)); - - // 예외 상세 검증 - assertEquals(ErrorCode.INVALID_REFRESH_TOKEN, exception.getErrorCode()); + assertThatThrownBy(() -> authService.reissue(request)).isInstanceOf(CustomException.class); } + @Test + @DisplayName("비밀번호 재설정 요청 - 성공") + void requestPasswordReset_success() { + + // given + String email = "test@email.com"; + User user = User.builder().email(email).build(); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + + // when + authService.requestPasswordReset(email, "127.0.0.1"); + + // then + verify(emailVerificationService).sendVerificationCode(email, VerificationType.RESET, "127.0.0.1"); + } @Test - @DisplayName("로그아웃 성공 테스트") - void logout_Success() { + @DisplayName("비밀번호 재설정 - 이미 인증된 경우 성공") + void resetPassword_success_when_already_verified() { // given - // 요청 객체 - String requestToken = "token"; - LogoutRequest request = new LogoutRequest(requestToken); - - // 사용자 객체 - User user = User.forTestBuilder() - .id(1L) - .username("username") - .email("email") - .password("password") - .isAdmin(false) - .address("address") - .build(); - ReflectionTestUtils.setField(user, "id", 1L); - - // 토큰 true 반환 - given(jwtTokenProvider.validateToken(requestToken)).willReturn(true); + String email = "test@email.com"; + String code = "123456"; + String newPassword = "newPassword123!"; + String encodedPassword = "encodedPassword"; + User user = User.builder().email(email).password("old").build(); + + given(emailVerificationService.isVerified(email, VerificationType.RESET)).willReturn(true); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + given(encoder.encode(newPassword)).willReturn(encodedPassword); // when - authService.logout(request); + authService.resetPassword(email, code, newPassword); // then - verify(refreshTokenRepository, times(1)).deleteByToken(requestToken); + assertThat(user.getPassword()).isEqualTo(encodedPassword); + verify(emailVerificationService, never()).verifyCode(email, code, VerificationType.RESET); + verify(emailVerificationService).deleteVerification(email, VerificationType.RESET); } @Test - @DisplayName("로그아웃 실패 테스트 - 유효하지 않은 토큰") - void logout_Failure_invalidToken() { + @DisplayName("비밀번호 재설정 - 성공") + void resetPassword_success() { // given - String invalidToken = "invalidToken"; + String email = "test@email.com"; + String code = "123456"; + String newPassword = "newPassword123"; + String encodedPassword = "encodedPassword"; + User user = User.builder().email(email).password("old").build(); + + given(emailVerificationService.isVerified(email, VerificationType.RESET)).willReturn(false); + given(emailVerificationService.verifyCode(email, code, VerificationType.RESET)).willReturn(true); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + given(encoder.encode(newPassword)).willReturn(encodedPassword); - // validate 반환값을 false로 설정 - given(jwtTokenProvider.validateToken(invalidToken)).willReturn(false); + // when + authService.resetPassword(email, code, newPassword); + + // then + assertThat(user.getPassword()).isEqualTo(encodedPassword); + verify(emailVerificationService).deleteVerification(email, VerificationType.RESET); + } + + @Test + @DisplayName("비밀번호 재설정 - 인증번호 불일치 예외") + void resetPassword_fail_invalid_code() { + + // given + String email = "test@email.com"; + String code = "wrong-code"; + given(emailVerificationService.isVerified(email, VerificationType.RESET)).willReturn(false); + given(emailVerificationService.verifyCode(email, code, VerificationType.RESET)).willReturn(false); // when & then - CustomException exception = assertThrows(CustomException.class, () -> authService.logout(new LogoutRequest(invalidToken))); - assertEquals(ErrorCode.INVALID_REFRESH_TOKEN, exception.getErrorCode()); + assertThatThrownBy(() -> authService.resetPassword(email, code, "newPassword")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("로그아웃 - 성공") + void logout_success() { + + // given + String email = "test@email.com"; + + // when + authService.logout(email); + + // then + verify(refreshTokenRepository).deleteById(email); } } \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationServiceTest.java new file mode 100644 index 0000000..5d44376 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/EmailVerificationServiceTest.java @@ -0,0 +1,153 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; +import com.chaewookim.accountbookformoms.global.error.CustomException; +import com.chaewookim.accountbookformoms.global.mail.AsyncVerificationEmailSender; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class EmailVerificationServiceTest { + + private static final String CLIENT_IP = "203.0.113.10"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private AsyncVerificationEmailSender asyncVerificationEmailSender; + + @InjectMocks + private EmailVerificationService emailVerificationService; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + @DisplayName("인증번호 발송 - 성공") + void sendVerificationCode_success() { + given(redisTemplate.hasKey(startsWith("LOCK:"))).willReturn(false); + given(valueOperations.get(startsWith("RL:EMAIL:"))).willReturn(null); + given(valueOperations.get(startsWith("RL:IP:"))).willReturn(null); + given(valueOperations.increment(startsWith("RL:"))).willReturn(1L); + + emailVerificationService.sendVerificationCode("test@email.com", VerificationType.SIGNUP, CLIENT_IP); + + verify(valueOperations).set(startsWith("VERIFY:"), anyString(), eq(Duration.ofMinutes(3))); + verify(valueOperations).set(startsWith("LOCK:"), eq("locked"), eq(Duration.ofMinutes(1))); + verify(valueOperations, times(2)).increment(startsWith("RL:")); + verify(redisTemplate, times(2)).expire(startsWith("RL:"), eq(Duration.ofDays(2))); + verify(asyncVerificationEmailSender).send(eq("test@email.com"), anyString(), eq(VerificationType.SIGNUP)); + } + + @Test + @DisplayName("인증번호 발송 - 쿨다운 중이면 거부") + void sendVerificationCode_rejected_when_cooldown() { + given(redisTemplate.hasKey(startsWith("LOCK:"))).willReturn(true); + + assertThatThrownBy(() -> + emailVerificationService.sendVerificationCode("test@email.com", VerificationType.SIGNUP, CLIENT_IP) + ).isInstanceOf(CustomException.class); + + verify(asyncVerificationEmailSender, never()).send(anyString(), anyString(), any()); + } + + @Test + @DisplayName("인증번호 발송 - 이메일 일일 한도 초과 시 거부") + void sendVerificationCode_rejected_when_email_daily_limit() { + given(redisTemplate.hasKey(startsWith("LOCK:"))).willReturn(false); + given(valueOperations.get(startsWith("RL:EMAIL:"))).willReturn("5"); + + assertThatThrownBy(() -> + emailVerificationService.sendVerificationCode("test@email.com", VerificationType.SIGNUP, CLIENT_IP) + ).isInstanceOf(CustomException.class); + + verify(asyncVerificationEmailSender, never()).send(anyString(), anyString(), any()); + } + + @Test + @DisplayName("인증번호 발송 - IP 일일 한도 초과 시 거부") + void sendVerificationCode_rejected_when_ip_daily_limit() { + given(redisTemplate.hasKey(startsWith("LOCK:"))).willReturn(false); + given(valueOperations.get(startsWith("RL:EMAIL:"))).willReturn("1"); + given(valueOperations.get(startsWith("RL:IP:"))).willReturn("20"); + + assertThatThrownBy(() -> + emailVerificationService.sendVerificationCode("test@email.com", VerificationType.SIGNUP, CLIENT_IP) + ).isInstanceOf(CustomException.class); + + verify(asyncVerificationEmailSender, never()).send(anyString(), anyString(), any()); + } + + @Test + @DisplayName("인증번호 검증 - 성공") + void verifyCode_success() { + given(valueOperations.get("VERIFY:SIGNUP:test@email.com")).willReturn("123456"); + + boolean result = emailVerificationService.verifyCode("test@email.com", "123456", VerificationType.SIGNUP); + + assertThat(result).isTrue(); + verify(redisTemplate).delete("VERIFY:SIGNUP:test@email.com"); + verify(redisTemplate).delete("VERIFY_ATTEMPT:SIGNUP:test@email.com"); + } + + @Test + @DisplayName("인증번호 검증 - 실패(코드 불일치, 시도 횟수 미만)") + void verifyCode_fail() { + given(valueOperations.get("VERIFY:SIGNUP:test@email.com")).willReturn("654321"); + given(valueOperations.increment("VERIFY_ATTEMPT:SIGNUP:test@email.com")).willReturn(1L); + + boolean result = emailVerificationService.verifyCode("test@email.com", "123456", VerificationType.SIGNUP); + + assertThat(result).isFalse(); + verify(redisTemplate).expire("VERIFY_ATTEMPT:SIGNUP:test@email.com", Duration.ofMinutes(3)); + verify(redisTemplate, never()).delete("VERIFY:SIGNUP:test@email.com"); + } + + @Test + @DisplayName("인증번호 검증 - 5회 실패 시 코드 폐기 후 재발급 유도") + void verifyCode_exceedsAttempts() { + given(valueOperations.get("VERIFY:SIGNUP:test@email.com")).willReturn("654321"); + given(valueOperations.increment("VERIFY_ATTEMPT:SIGNUP:test@email.com")).willReturn(5L); + + assertThatThrownBy(() -> + emailVerificationService.verifyCode("test@email.com", "123456", VerificationType.SIGNUP) + ).isInstanceOf(CustomException.class); + + verify(redisTemplate).delete("VERIFY:SIGNUP:test@email.com"); + verify(redisTemplate).delete("VERIFY_ATTEMPT:SIGNUP:test@email.com"); + } + + @Test + @DisplayName("인증 확인 - 성공") + void isVerified_success() { + given(redisTemplate.hasKey(anyString())).willReturn(true); + + boolean result = emailVerificationService.isVerified("test@email.com", VerificationType.SIGNUP); + + assertThat(result).isTrue(); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryServiceTest.java new file mode 100644 index 0000000..85d0736 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/InterestCategoryServiceTest.java @@ -0,0 +1,146 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.grouppruchase.dao.GroupPurchaseCategoryRepository; +import com.chaewookim.accountbookformoms.domain.grouppruchase.domain.Category; +import com.chaewookim.accountbookformoms.domain.user.dao.InterestCategoryRepository; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.dto.response.InterestCategoryResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.InterestCategory; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InterestCategoryServiceTest { + + @Mock + private InterestCategoryRepository repository; + + @Mock + private UserRepository userRepository; + + @Mock + private GroupPurchaseCategoryRepository categoryRepository; + + @InjectMocks + private InterestCategoryService interestCategoryService; + + @Test + @DisplayName("관심 카테고리 등록 - 성공") + void registerCategory_Success() { + + // given + Long userId = 1L, categoryId = 10L; + User user = mock(User.class); + Category category = Category.builder().id(categoryId).name("밀키트").build(); + InterestCategory interest = InterestCategory.builder().user(user).category(category).isAlarmEnabled(true).build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + given(repository.existsByUserAndCategory(user, category)).willReturn(false); + given(repository.save(any(InterestCategory.class))).willReturn(interest); + + // when + InterestCategoryResponse response = interestCategoryService.registerCategory(userId, categoryId); + + // then + assertThat(response.categoryName()).isEqualTo("밀키트"); + verify(repository, times(1)).save(any(InterestCategory.class)); + } + + @Test + @DisplayName("이미 등록된 카테고리 등록 시 예외 발생") + void registerCategory_AlreadyExist_ThrowException() { + + // given + Long userId = 1L, categoryId = 10L; + User user = mock(User.class); + Category category = mock(Category.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(categoryRepository.findById(categoryId)).willReturn(Optional.of(category)); + given(repository.existsByUserAndCategory(user, category)).willReturn(true); + + // when & then + assertThrows(CustomException.class, () -> interestCategoryService.registerCategory(userId, categoryId)); + } + + @Test + @DisplayName("관심 카테고리 목록 조회 - 성공") + void getUserCategories_Success() { + + // given + Long userId = 1L; + Category category = Category.builder().name("밀키트").build(); + InterestCategory interest = InterestCategory.builder().category(category).build(); + given(repository.findByUserId(userId)).willReturn(List.of(interest)); + + // when + List responses = interestCategoryService.getUserCategories(userId); + + // then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).categoryName()).isEqualTo("밀키트"); + } + + @Test + @DisplayName("알림 설정 상태 변경 - 성공") + void updateAlarmStatus_Success() { + + // given + Long id = 1L, userId = 1L; + InterestCategory interest = mock(InterestCategory.class); + given(repository.findByIdAndUserId(id, userId)).willReturn(Optional.of(interest)); + + // when + interestCategoryService.updateAlarmStatus(id, userId, false); + + // then + verify(interest, times(1)).updateAlarmStatus(false); + } + + @Test + @DisplayName("관심 카테고리 삭제 - 성공") + void deleteCategory_Success() { + + // given + Long id = 1L, userId = 1L; + InterestCategory interest = mock(InterestCategory.class); + given(repository.findByIdAndUserId(id, userId)).willReturn(Optional.of(interest)); + + // when + interestCategoryService.deleteCategory(id, userId); + + // then + verify(repository, times(1)).delete(interest); + } + + @Test + @DisplayName("사용자 관심 카테고리 전체 삭제 - 성공") + void deleteAllByUserId_Success() { + + // given + Long userId = 1L; + given(repository.softDeleteByUserId(userId)).willReturn(3); + + // when + interestCategoryService.deleteAllByUserId(userId); + + // then + verify(repository).softDeleteByUserId(userId); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonServiceTest.java new file mode 100644 index 0000000..76bd39a --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserCommonServiceTest.java @@ -0,0 +1,92 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.event.UserSignedUpEvent; +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.context.ApplicationEventPublisher; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserCommonServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private InterestCategoryService interestCategoryService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private UserCommonService userCommonService; + + @Test + @DisplayName("회원 저장 - 성공") + void saveUser_success() { + + // given + User user = User.builder().email("test@email.com").build(); + given(userRepository.save(any(User.class))).willReturn(user); + + // when + User result = userCommonService.saveLocalUser("test@email.com", "pw", "user", SocialProvider.LOCAL, LocalDate.now(), "address"); + + // then + assertThat(result).isNotNull(); + verify(userRepository).save(any(User.class)); + verify(eventPublisher).publishEvent(any(UserSignedUpEvent.class)); + } + + @Test + @DisplayName("소셜 회원 가입 - 성공") + void saveSocialUser_success() { + + // given + User user = User.builder().email("social@email.com").provider(SocialProvider.GOOGLE).build(); + given(userRepository.save(any(User.class))).willReturn(user); + + // when + User result = userCommonService.saveSocialUser("social@email.com", "socialUser", SocialProvider.GOOGLE); + + // then + assertThat(result.getEmail()).isEqualTo("social@email.com"); + verify(userRepository).save(any(User.class)); + verify(eventPublisher).publishEvent(any(UserSignedUpEvent.class)); + } + + @Test + @DisplayName("회원 복구 - 성공") + void restoreUser_success() { + + // given + User user = User.builder().email("test@email.com").build(); + user.delete(); + given(userRepository.findByEmailIncludingDeleted("test@email.com")).willReturn(java.util.Optional.of(user)); + given(userRepository.save(any(User.class))).willReturn(user); + + // when + User result = userCommonService.restoreUser(user, "newNickname", null, null, null); + + // then + assertThat(result.getDeletedAt()).isNull(); + assertThat(result.getUsername()).isEqualTo("newNickname"); + assertThat(result.getPassword()).isNull(); + verify(interestCategoryService).deleteAllByUserId(any()); + verify(userRepository).save(any(User.class)); + verify(eventPublisher).publishEvent(any(UserSignedUpEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationServiceTest.java new file mode 100644 index 0000000..ad66979 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserLocationServiceTest.java @@ -0,0 +1,276 @@ +package com.chaewookim.accountbookformoms.domain.user.application; + +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.dto.request.LocationUpdateRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.LocationResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.NearbyUserResponse; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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.data.geo.Distance; +import org.springframework.data.geo.GeoResult; +import org.springframework.data.geo.GeoResults; +import org.springframework.data.geo.Metrics; +import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.RedisGeoCommands; +import org.springframework.data.redis.core.GeoOperations; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserLocationServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private GeoOperations geoOperations; + + @InjectMocks + private UserLocationService userLocationService; + + @Test + @DisplayName("위치 등록 - 성공: DB 갱신 및 Redis GEO 저장") + void updateLocation_success() { + + // given + Long userId = 1L; + LocationUpdateRequest request = new LocationUpdateRequest(37.5665, 126.9780); + User user = User.forTestBuilder().id(userId).email("test@email.com").username("user").build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + + // when + LocationResponse response = userLocationService.updateLocation(userId, request); + + // then + assertThat(response.userId()).isEqualTo(userId); + assertThat(response.latitude()).isEqualTo(37.5665); + assertThat(response.longitude()).isEqualTo(126.9780); + assertThat(user.getLatitude()).isEqualTo(37.5665); + assertThat(user.getLongitude()).isEqualTo(126.9780); + verify(geoOperations).add(eq(UserLocationService.USER_GEO_KEY), any(Point.class), eq("1")); + } + + @Test + @DisplayName("위치 등록 - 사용자 없음 예외") + void updateLocation_fail_user_not_found() { + + // given + LocationUpdateRequest request = new LocationUpdateRequest(37.5665, 126.9780); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userLocationService.updateLocation(1L, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("내 위치 조회 - 성공") + void getMyLocation_success() { + + // given + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + given(geoOperations.position(UserLocationService.USER_GEO_KEY, "1")) + .willReturn(List.of(new Point(126.9780, 37.5665))); + + // when + LocationResponse response = userLocationService.getMyLocation(userId); + + // then + assertThat(response.userId()).isEqualTo(userId); + assertThat(response.latitude()).isEqualTo(37.5665); + assertThat(response.longitude()).isEqualTo(126.9780); + } + + @Test + @DisplayName("내 위치 조회 - 사용자 없음 예외") + void getMyLocation_fail_user_not_found() { + + // given + given(userRepository.existsById(1L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> userLocationService.getMyLocation(1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("내 위치 조회 - 등록된 위치 없음 예외") + void getMyLocation_fail_location_not_registered() { + + // given + given(userRepository.existsById(1L)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + given(geoOperations.position(UserLocationService.USER_GEO_KEY, "1")) + .willReturn(Collections.singletonList(null)); + + // when & then + assertThatThrownBy(() -> userLocationService.getMyLocation(1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.LOCATION_NOT_REGISTERED); + } + + @Test + @DisplayName("근처 사용자 조회 - 성공: 본인 제외") + @SuppressWarnings("unchecked") + void findNearbyUsers_success() { + + // given + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + + RedisGeoCommands.GeoLocation self = new RedisGeoCommands.GeoLocation<>("1", new Point(126.9780, 37.5665)); + RedisGeoCommands.GeoLocation other = new RedisGeoCommands.GeoLocation<>("2", new Point(126.9790, 37.5670)); + + GeoResult> selfResult = mock(GeoResult.class); + GeoResult> otherResult = mock(GeoResult.class); + given(selfResult.getContent()).willReturn(self); + given(selfResult.getDistance()).willReturn(new Distance(0.0, Metrics.KILOMETERS)); + given(otherResult.getContent()).willReturn(other); + given(otherResult.getDistance()).willReturn(new Distance(0.12, Metrics.KILOMETERS)); + + GeoResults> results = + new GeoResults<>(List.of(selfResult, otherResult)); + + given(geoOperations.radius(eq(UserLocationService.USER_GEO_KEY), eq("1"), any(Distance.class), any(RedisGeoCommands.GeoRadiusCommandArgs.class))) + .willReturn(results); + + // when + List nearby = userLocationService.findNearbyUsers(userId, 3.0); + + // then + assertThat(nearby).hasSize(1); + assertThat(nearby.get(0).userId()).isEqualTo(2L); + assertThat(nearby.get(0).latitude()).isEqualTo(37.5670); + assertThat(nearby.get(0).longitude()).isEqualTo(126.9790); + assertThat(nearby.get(0).distanceKm()).isEqualTo(0.12); + } + + @Test + @DisplayName("근처 사용자 조회 - 결과 없음 처리") + void findNearbyUsers_empty_when_radius_returns_null() { + + // given + given(userRepository.existsById(1L)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + given(geoOperations.radius(anyString(), anyString(), any(Distance.class), any(RedisGeoCommands.GeoRadiusCommandArgs.class))) + .willReturn(null); + + // when + List nearby = userLocationService.findNearbyUsers(1L, 3.0); + + // then + assertThat(nearby).isEmpty(); + } + + @Test + @DisplayName("근처 사용자 조회 - 사용자 없음 예외") + void findNearbyUsers_fail_user_not_found() { + + // given + given(userRepository.existsById(1L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> userLocationService.findNearbyUsers(1L, 3.0)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("findNearbyUserIds - 반경 내 사용자 ID 집합 반환 (본인 제외)") + void findNearbyUserIds_success() { + + // given + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + given(geoOperations.position(UserLocationService.USER_GEO_KEY, "1")) + .willReturn(List.of(new Point(126.9780, 37.5665))); + + RedisGeoCommands.GeoLocation self = new RedisGeoCommands.GeoLocation<>("1", new Point(126.9780, 37.5665)); + RedisGeoCommands.GeoLocation other2 = new RedisGeoCommands.GeoLocation<>("2", new Point(126.9790, 37.5670)); + RedisGeoCommands.GeoLocation other3 = new RedisGeoCommands.GeoLocation<>("3", new Point(126.9800, 37.5680)); + + @SuppressWarnings("unchecked") + GeoResult> r1 = mock(GeoResult.class); + @SuppressWarnings("unchecked") + GeoResult> r2 = mock(GeoResult.class); + @SuppressWarnings("unchecked") + GeoResult> r3 = mock(GeoResult.class); + given(r1.getContent()).willReturn(self); + given(r1.getDistance()).willReturn(new Distance(0.0, Metrics.KILOMETERS)); + given(r2.getContent()).willReturn(other2); + given(r2.getDistance()).willReturn(new Distance(0.12, Metrics.KILOMETERS)); + given(r3.getContent()).willReturn(other3); + given(r3.getDistance()).willReturn(new Distance(0.25, Metrics.KILOMETERS)); + + GeoResults> results = + new GeoResults<>(List.of(r1, r2, r3)); + given(geoOperations.radius(eq(UserLocationService.USER_GEO_KEY), eq("1"), any(Distance.class), any(RedisGeoCommands.GeoRadiusCommandArgs.class))) + .willReturn(results); + + // when + Set ids = userLocationService.findNearbyUserIds(userId, 3.0); + + // then + assertThat(ids).containsExactlyInAnyOrder(2L, 3L); + } + + @Test + @DisplayName("findNearbyUserIds - 위치 미등록 사용자 예외") + void findNearbyUserIds_fail_location_not_registered() { + + // given + given(userRepository.existsById(1L)).willReturn(true); + given(redisTemplate.opsForGeo()).willReturn(geoOperations); + given(geoOperations.position(UserLocationService.USER_GEO_KEY, "1")) + .willReturn(Collections.emptyList()); + + // when & then + assertThatThrownBy(() -> userLocationService.findNearbyUserIds(1L, 3.0)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.LOCATION_NOT_REGISTERED); + } + + @Test + @DisplayName("findNearbyUserIds - 사용자 없음 예외") + void findNearbyUserIds_fail_user_not_found() { + + // given + given(userRepository.existsById(1L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> userLocationService.findNearbyUserIds(1L, 3.0)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserServiceTest.java index 72425d1..0b2aa2e 100644 --- a/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserServiceTest.java +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/user/application/UserServiceTest.java @@ -1,39 +1,53 @@ package com.chaewookim.accountbookformoms.domain.user.application; -import com.chaewookim.accountbookformoms.domain.user.dao.RefreshTokenRepository; +import com.chaewookim.accountbookformoms.domain.notification.application.NotificationService; +import com.chaewookim.accountbookformoms.domain.notification.application.UserDeviceService; import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; -import com.chaewookim.accountbookformoms.domain.user.domain.RefreshToken; -import com.chaewookim.accountbookformoms.domain.user.domain.User; -import com.chaewookim.accountbookformoms.domain.user.dto.request.TokenReissueRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateRequest; -import com.chaewookim.accountbookformoms.domain.user.dto.request.WithdrawRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.SignupRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdatePasswordRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.request.UpdateProfileRequest; +import com.chaewookim.accountbookformoms.domain.user.dto.response.SignupResponse; +import com.chaewookim.accountbookformoms.domain.user.dto.response.UserProfileResponse; +import com.chaewookim.accountbookformoms.domain.user.enums.UserRole; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.entity.UserNotificationSetting; +import com.chaewookim.accountbookformoms.domain.user.entity.UserSetting; +import com.chaewookim.accountbookformoms.domain.user.enums.SocialProvider; +import com.chaewookim.accountbookformoms.domain.user.enums.VerificationType; import com.chaewookim.accountbookformoms.global.error.CustomException; -import com.chaewookim.accountbookformoms.global.error.ErrorCode; 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.security.core.userdetails.UserDetails; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDate; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class UserServiceTest { - // 이게 사용할 것. 사용할 것에 가짜 객체들 주입 - @InjectMocks - private UserService userService; + @Mock + private UserCommonService userCommonService; + + @Mock + private InterestCategoryService interestCategoryService; + + @Mock + private EmailVerificationService emailVerificationService; - // 가짜 레포지토리 @Mock private UserRepository userRepository; @@ -41,130 +55,183 @@ class UserServiceTest { private PasswordEncoder passwordEncoder; @Mock - private RefreshTokenRepository refreshTokenRepository; + private NotificationService notificationService; + + @Mock + private UserDeviceService userDeviceService; + + @Mock + private KakaoGeocodingClient kakaoGeocodingClient; + + @InjectMocks + private UserService userService; @Test - @DisplayName("회원 정보 수정(username, email, address) 성공 테스트") - void updateUser_Success() { - - // given: 준비 - // 가짜 객체에 넣을 값들을 준비 - String email = "updateEmail@gmail.com"; - String username = "updateUsername"; - String address = "updateAddress"; - Long userId = 1L; - - // 수정할 데이터 요청 객체 생성 - UpdateRequest updateRequest = new UpdateRequest(username, email, address); - - // 기존 유저 객체 생성 - String originEmail = "originEmail"; - User existingUser = User.forTestBuilder() - .id(userId) - .username("username") - .email(originEmail) - .password("password") - .address("address") - .build(); + @DisplayName("회원가입 - 성공") + void signUp_success_new_user() { + + // given + SignupRequest request = new SignupRequest("test@email.com", "pw", "user", LocalDate.now(), "address"); + User savedUser = User.builder().email("test@email.com").username("user").build(); - // 테스트 환경에서 id에 값을 넣어주지 못할 경우가 존재할 수 있기 때문에 강제 주입 - ReflectionTestUtils.setField(existingUser, "id", userId); + given(emailVerificationService.isVerified(request.email(), VerificationType.SIGNUP)).willReturn(true); + given(userRepository.findByEmailIncludingDeleted(request.email())).willReturn(Optional.empty()); + given(passwordEncoder.encode(request.password())).willReturn("encoded"); + given(userCommonService.saveLocalUser(any(), any(), any(), any(), any(), any())).willReturn(savedUser); - // userRepository.findByEmail() 호출 시 existingUser를 리턴하라고 조작 - given(userRepository.findByEmail(originEmail)).willReturn(Optional.of(existingUser)); + // when + SignupResponse response = userService.signUp(request); - // when: 실행 - Long updateUserId = userService.updateUser(originEmail, updateRequest); + // then + assertThat(response.email()).isEqualTo(request.email()); + verify(userCommonService).saveLocalUser(any(), any(), any(), any(), any(), any()); + } - // then: 검증 - // 리턴된 ID가 올바른지 확인 - assertEquals(userId, updateUserId); + @Test + @DisplayName("삭제된 유저 복구 - 성공") + void signUp_success_restore_user() { + + // given + SignupRequest request = new SignupRequest("test@email.com", "pw", "restoredUser", LocalDate.now(), "address"); + User deletedUser = User.builder().email("test@email.com").build(); + deletedUser.delete(); - // 실제 객체 내용이 바뀌었는지 확인 - assertEquals(email, existingUser.getEmail()); - assertEquals(address, existingUser.getAddress()); - assertEquals(username, existingUser.getUsername()); + given(emailVerificationService.isVerified(any(), any())).willReturn(true); + given(userRepository.findByEmailIncludingDeleted(request.email())).willReturn(Optional.of(deletedUser)); + given(userCommonService.restoreUser(any(), any(), any(), any(), any())).willReturn(deletedUser); + + // when + SignupResponse response = userService.signUp(request); + + // then + assertThat(response.email()).isEqualTo(request.email()); + verify(userCommonService).restoreUser(any(), any(), any(), any(), any()); } + @Test + @DisplayName("회원가입 - 이메일 중복 예외 발생") + void signUp_fail_duplicate_email() { + + // given + SignupRequest request = new SignupRequest("test@email.com", "pw", "user", LocalDate.now(), "address"); + User activeUser = User.builder().email("test@email.com").build(); + + given(userRepository.findByEmailIncludingDeleted(request.email())).willReturn(Optional.of(activeUser)); + + // when & then + assertThatThrownBy(() -> userService.signUp(request)).isInstanceOf(CustomException.class); + } @Test - @DisplayName("회원 탈퇴 성공 테스트") - void withdrawUser_Success() { + @DisplayName("프로필 조회 - 성공") + void getMyProfile_success() { // given - // 요청 객체 - String email = "originEmail"; - WithdrawRequest withdrawRequest = new WithdrawRequest("password", "reason"); - - // UserDetails - UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() - .username(email) - .password("password") - .roles("USER") - .build(); + User user = mock(User.class); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(user.getUserSetting()).willReturn(mock(UserSetting.class)); + given(user.getUserNotificationSetting()).willReturn(mock(UserNotificationSetting.class)); + given(user.getPassword()).willReturn("encoded"); + given(user.getRole()).willReturn(UserRole.ROLE_USER); + + // when + UserProfileResponse response = userService.getMyProfile(1L); + + // then + assertThat(response).isNotNull(); + assertThat(response.hasPassword()).isTrue(); + assertThat(response.role()).isEqualTo("ROLE_USER"); + } - // 사용자 객체 - Long userId = 1L; - User user = User.forTestBuilder() - .id(userId) - .username("username") - .email(email) - .password("password") - .isAdmin(false) - .address("address") - .build(); - ReflectionTestUtils.setField(user, "id", userId); + @Test + @DisplayName("프로필 수정 - 성공") + void updateMyProfile_success() { - // 비밀번호 검증 true - given(passwordEncoder.matches(withdrawRequest.password(), user.getPassword())).willReturn(true); + // given + User user = mock(User.class); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(user.getUserSetting()).willReturn(mock(UserSetting.class)); + given(user.getUserNotificationSetting()).willReturn(mock(UserNotificationSetting.class)); + UpdateProfileRequest request = new UpdateProfileRequest("new", LocalDate.now(), "newAddress", 50, true, true, true, true, true); - // user 조회 - given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + // when + userService.updateMyProfile(1L, request); - // when & then - userService.withdrawUser(userDetails, withdrawRequest); + // then + verify(user).updateProfile(any(), any(), any()); + } + + @Test + @DisplayName("비밀번호 변경 - 성공") + void updatePassword_success() { + + // given + User user = User.builder().password("encoded").build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(passwordEncoder.matches("old", "encoded")).willReturn(true); + given(passwordEncoder.encode("new")).willReturn("newEncoded"); + + // when + userService.updatePassword(1L, new UpdatePasswordRequest("old", "new")); - verify(refreshTokenRepository, times(1)).deleteByUserId(userId); - verify(userRepository, times(1)).delete(user); + // then + assertThat(user.getPassword()).isEqualTo("newEncoded"); } @Test - @DisplayName("회원 탈퇴 실패 테스트 - 비밀번호 불일치") - void withdrawUser_Failure_passwordNotMatch() { + @DisplayName("비밀번호 설정 - 소셜 가입 사용자 성공") + void updatePassword_success_socialUserWithoutPassword() { // given - // request obj - String email = "originEmail"; - String password = "password"; - WithdrawRequest withdrawRequest = new WithdrawRequest(password, "reason"); - - // UserDetails obj - UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() - .username(email) - .password(password) - .roles("USER") + User user = User.builder() + .password(null) + .provider(SocialProvider.KAKAO) .build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(passwordEncoder.encode("new")).willReturn("newEncoded"); - // User obj - Long userId = 1L; - User user = User.forTestBuilder() - .id(userId) - .username("username") - .email(email) - .password("password") - .isAdmin(false) - .address("address") - .build(); - ReflectionTestUtils.setField(user, "id", userId); + // when + userService.updatePassword(1L, new UpdatePasswordRequest(null, "new")); - // return User obj - given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + // then + assertThat(user.getPassword()).isEqualTo("newEncoded"); + } - // 비밀번호 검증 - 실패로 설정 - given(passwordEncoder.matches(withdrawRequest.password(), user.getPassword())).willReturn(false); + @Test + @DisplayName("비밀번호 변경 - 현재 비밀번호 불일치") + void updatePassword_fail_passwordNotMatch() { + + // given + User user = User.builder() + .password("encoded") + .provider(SocialProvider.KAKAO) + .build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(passwordEncoder.matches("wrong", "encoded")).willReturn(false); // when & then - CustomException exception = assertThrows(CustomException.class, () -> userService.withdrawUser(userDetails, withdrawRequest)); - assertEquals(ErrorCode.PASSWORD_NOT_MATCH, exception.getErrorCode()); + assertThatThrownBy(() -> userService.updatePassword(1L, new UpdatePasswordRequest("wrong", "new"))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("회원 탈퇴 - 성공") + void withdraw_success() { + + // given + User user = mock(User.class); + UserNotificationSetting notificationSetting = mock(UserNotificationSetting.class); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(user.getUserNotificationSetting()).willReturn(notificationSetting); + + // when + userService.withdraw(1L); + + // then + verify(notificationService).deleteAllByUserId(1L); + verify(notificationSetting).resetToDefaults(); + verify(userDeviceService).removeToken(user); + verify(userRepository).delete(user); + verify(interestCategoryService).deleteAllByUserId(1L); } } \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsServiceTest.java b/src/test/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsServiceTest.java new file mode 100644 index 0000000..c399e55 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/domain/userstats/application/UserStatsServiceTest.java @@ -0,0 +1,68 @@ +package com.chaewookim.accountbookformoms.domain.userstats.application; + +import com.chaewookim.accountbookformoms.domain.board.dao.BoardRepository; +import com.chaewookim.accountbookformoms.domain.follow.application.FollowService; +import com.chaewookim.accountbookformoms.domain.user.dao.UserRepository; +import com.chaewookim.accountbookformoms.domain.user.entity.User; +import com.chaewookim.accountbookformoms.domain.user.error.UserErrorCode; +import com.chaewookim.accountbookformoms.domain.userstats.dto.response.UserStatsResponse; +import com.chaewookim.accountbookformoms.global.error.CustomException; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class UserStatsServiceTest { + + @Mock private UserRepository userRepository; + @Mock private BoardRepository boardRepository; + @Mock private FollowService followService; + + @InjectMocks + private UserStatsService userStatsService; + + private static final Long TARGET = 10L; + private static final Long VIEWER = 1L; + + @Test + @DisplayName("getStats — 게시글/팔로워/팔로잉/팔로우 여부 집계") + void get_stats_aggregates() { + User target = mock(User.class); + given(target.getId()).willReturn(TARGET); + given(target.getUsername()).willReturn("타겟"); + given(userRepository.findById(TARGET)).willReturn(Optional.of(target)); + given(boardRepository.countByUserId(TARGET)).willReturn(12L); + given(followService.followerCount(TARGET)).willReturn(5L); + given(followService.followingCount(TARGET)).willReturn(3L); + given(followService.isFollowing(TARGET, VIEWER)).willReturn(true); + + UserStatsResponse response = userStatsService.getStats(TARGET, VIEWER); + + assertThat(response.userId()).isEqualTo(TARGET); + assertThat(response.nickname()).isEqualTo("타겟"); + assertThat(response.postCount()).isEqualTo(12L); + assertThat(response.followerCount()).isEqualTo(5L); + assertThat(response.followingCount()).isEqualTo(3L); + assertThat(response.following()).isTrue(); + } + + @Test + @DisplayName("getStats — 대상 유저 없으면 USER_NOT_FOUND") + void get_stats_user_not_found() { + given(userRepository.findById(TARGET)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> userStatsService.getStats(TARGET, VIEWER)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupSchedulerTest.java b/src/test/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupSchedulerTest.java new file mode 100644 index 0000000..7cac962 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/global/scheduler/CompareCacheWarmupSchedulerTest.java @@ -0,0 +1,97 @@ +package com.chaewookim.accountbookformoms.global.scheduler; + +import com.chaewookim.accountbookformoms.domain.asset.dao.TransactionCategoryRepository; +import com.chaewookim.accountbookformoms.domain.budget.application.BudgetGroupCacheService; +import com.chaewookim.accountbookformoms.domain.expense.application.ExpenseGroupCacheService; +import com.chaewookim.accountbookformoms.domain.income.application.IncomeGroupCacheService; +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 java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CompareCacheWarmupSchedulerTest { + + @Mock + private TransactionCategoryRepository categoryRepository; + + @Mock + private BudgetGroupCacheService budgetGroupCache; + + @Mock + private ExpenseGroupCacheService expenseGroupCache; + + @Mock + private IncomeGroupCacheService incomeGroupCache; + + @InjectMocks + private CompareCacheWarmupScheduler scheduler; + + @Test + @DisplayName("warm-up: 6개 나이대 × (Budget AGE + Expense AGE fixed/variable + Income AGE fixed/variable) 호출") + void warmup_calls_age_caches_for_all_decades() { + + // given + given(categoryRepository.findSystemCategoryIds()).willReturn(List.of()); + + // when + scheduler.warmupCompareGroupCaches(); + + // then - 20대~70대 6번 + verify(budgetGroupCache, times(6)).getAgeGroupSums(anyString(), any(LocalDate.class), any(LocalDate.class)); + verify(expenseGroupCache, times(6)).getAgeFixedSums(any(LocalDate.class), any(LocalDate.class), any(LocalDate.class), any(LocalDate.class)); + verify(expenseGroupCache, times(6)).getAgeVariableSums(any(LocalDate.class), any(LocalDate.class), any(LocalDate.class), any(LocalDate.class)); + verify(incomeGroupCache, times(6)).getAgeFixedSums(any(LocalDate.class), any(LocalDate.class), any(LocalDate.class), any(LocalDate.class)); + verify(incomeGroupCache, times(6)).getAgeVariableSums(any(LocalDate.class), any(LocalDate.class), any(LocalDate.class), any(LocalDate.class)); + } + + @Test + @DisplayName("warm-up: 시스템 카테고리 각 id 마다 Budget + Expense(fixed+variable) + Income(fixed+variable) 호출") + void warmup_calls_category_caches_per_system_category() { + + // given - 시스템 카테고리 3개 + given(categoryRepository.findSystemCategoryIds()).willReturn(List.of(10L, 20L, 30L)); + + // when + scheduler.warmupCompareGroupCaches(); + + // then - 카테고리 3 × 호출 5종 + verify(budgetGroupCache, times(3)).getCategoryAggregation(anyString(), anyLong()); + verify(expenseGroupCache, times(3)).getCategoryFixedSums(any(LocalDate.class), any(LocalDate.class), anyLong()); + verify(expenseGroupCache, times(3)).getCategoryVariableSums(any(LocalDate.class), any(LocalDate.class), anyLong()); + verify(incomeGroupCache, times(3)).getCategoryFixedSums(any(LocalDate.class), any(LocalDate.class), anyLong()); + verify(incomeGroupCache, times(3)).getCategoryVariableSums(any(LocalDate.class), any(LocalDate.class), anyLong()); + } + + @Test + @DisplayName("warm-up: 한 카테고리에서 예외 발생해도 나머지 진행") + void warmup_continues_on_error() { + + // given + given(categoryRepository.findSystemCategoryIds()).willReturn(List.of(10L, 20L)); + given(budgetGroupCache.getCategoryAggregation(anyString(), eqL(10L))) + .willThrow(new RuntimeException("intentional")); + + // when - 예외 잡혀서 throw 안 됨 + scheduler.warmupCompareGroupCaches(); + + // then - 두 번째 카테고리도 처리 + verify(budgetGroupCache).getCategoryAggregation(anyString(), eqL(20L)); + } + + private static long eqL(long value) { + return org.mockito.ArgumentMatchers.eq(value); + } +} diff --git a/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfoTest.java b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfoTest.java new file mode 100644 index 0000000..d030bfc --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/GoogleUserInfoTest.java @@ -0,0 +1,20 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class GoogleUserInfoTest { + + @Test + @DisplayName("구글 정보 매핑 테스트") + void googleUserInfoTest() { + Map attr = Map.of("email", "test@google.com", "name", "성은"); + GoogleUserInfo info = new GoogleUserInfo(attr); + assertThat(info.getEmail()).isEqualTo("test@google.com"); + assertThat(info.getName()).isEqualTo("성은"); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfoTest.java b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfoTest.java new file mode 100644 index 0000000..3c35e31 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/KakaoUserInfoTest.java @@ -0,0 +1,23 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class KakaoUserInfoTest { + + @Test + @DisplayName("카카오 정보 매핑 테스트") + void kakaoUserInfoTest() { + Map attr = Map.of( + "kakao_account", Map.of("email", "test@kakao.com"), + "properties", Map.of("nickname", "성은") + ); + KakaoUserInfo info = new KakaoUserInfo(attr); + assertThat(info.getEmail()).isEqualTo("test@kakao.com"); + assertThat(info.getName()).isEqualTo("성은"); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfoTest.java b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfoTest.java new file mode 100644 index 0000000..5e16e29 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/NaverUserInfoTest.java @@ -0,0 +1,20 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class NaverUserInfoTest { + + @Test + @DisplayName("네이버 정보 매핑 테스트") + void naverUserInfoTest() { + Map attr = Map.of("response", Map.of("email", "test@naver.com", "name", "성은")); + NaverUserInfo info = new NaverUserInfo(attr); + assertThat(info.getEmail()).isEqualTo("test@naver.com"); + assertThat(info.getName()).isEqualTo("성은"); + } +} \ No newline at end of file diff --git a/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidatorTest.java b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidatorTest.java new file mode 100644 index 0000000..21b12f0 --- /dev/null +++ b/src/test/java/com/chaewookim/accountbookformoms/global/security/oauth2/OAuth2RedirectUriValidatorTest.java @@ -0,0 +1,59 @@ +package com.chaewookim.accountbookformoms.global.security.oauth2; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2RedirectUriValidatorTest { + + private static final String APP_DEFAULT_URI = "com.jointliving.app://oauth2/redirect"; + private static final String WEB_DEFAULT_URI = "http://localhost:5173/oauth2/redirect"; + private static final List ALLOWED = List.of( + WEB_DEFAULT_URI, + "https://moneydiary.cloud/oauth2/redirect", + APP_DEFAULT_URI + ); + + private final OAuth2RedirectUriValidator validator = + new OAuth2RedirectUriValidator(ALLOWED, APP_DEFAULT_URI, WEB_DEFAULT_URI); + + @Test + @DisplayName("화이트리스트에 있는 URI 는 그대로 반환한다") + void allowsListedUri() { + assertThat(validator.resolve(APP_DEFAULT_URI)).isEqualTo(APP_DEFAULT_URI); + assertThat(validator.resolve("https://moneydiary.cloud/oauth2/redirect")) + .isEqualTo("https://moneydiary.cloud/oauth2/redirect"); + } + + @Test + @DisplayName("알 수 없는 http(s) URI 는 웹 기본값으로 fallback 한다") + void rejectsUnknownHttpUri() { + assertThat(validator.resolve("https://evil.com/steal")).isEqualTo(WEB_DEFAULT_URI); + } + + @Test + @DisplayName("Capacitor WebView 오리진은 앱 딥링크로 fallback 한다") + void capacitorMisredirectUsesAppDefault() { + assertThat(validator.resolve("https://localhost/oauth2/redirect")).isEqualTo(APP_DEFAULT_URI); + } + + @Test + @DisplayName("null 또는 빈 값은 앱 기본값으로 fallback 한다") + void fallbacksOnEmpty() { + assertThat(validator.resolve(null)).isEqualTo(APP_DEFAULT_URI); + assertThat(validator.resolve("")).isEqualTo(APP_DEFAULT_URI); + assertThat(validator.resolve(" ")).isEqualTo(APP_DEFAULT_URI); + } + + @Test + @DisplayName("URI 는 정확 일치 검증한다 (prefix / suffix 로는 통과 불가)") + void rejectsPartialMatch() { + assertThat(validator.resolve("https://moneydiary.cloud/oauth2/redirect?extra=1")) + .isEqualTo(WEB_DEFAULT_URI); + assertThat(validator.resolve("https://moneydiary.cloud.evil.com/oauth2/redirect")) + .isEqualTo(WEB_DEFAULT_URI); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..01880e4 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientAutoConfiguration + - org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration + +management: + health: + mail: + enabled: false