diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml index c8d90f94a..79250f20e 100644 --- a/.github/workflows/develop_build_deploy.yml +++ b/.github/workflows/develop_build_deploy.yml @@ -2,7 +2,8 @@ name: Build and Deploy to Develop on: push: - branches: ["develop"] + branches: + - develop permissions: id-token: write @@ -40,19 +41,19 @@ jobs: run: docker-compose -f ./docker-compose-test.yaml up -d # Gradle 빌드 - - name: Build with Gradle - id: gradle + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 with: - arguments: | - build - --configuration-cache cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} # feature 브랜치는 캐시를 읽기 전용으로 설정 cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} add-job-summary-as-pr-comment: always build-scan-publish: true - build-scan-terms-of-service-url: "https://gradle.com/terms-of-service" - build-scan-terms-of-service-agree: "yes" + build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" + build-scan-terms-of-use-agree: "yes" + + - name: Build with Gradle + id: gradle + run: ./gradlew build --configuration-cache # Dockerhub 로그인 - name: Login to Dockerhub @@ -98,19 +99,6 @@ jobs: - name: Copy docker-compose file to S3 run: aws s3 cp docker-compose.yml ${{ env.S3_COPY_PATH }} - # 디스코드 둘기봇으로 gradle build scan 결과 발송 - - name: Send Gradle Build Scan Result to Discord - uses: Ilshidur/action-discord@master - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - DISCORD_EMBEDS: | - [ - { - "title": "푸드덕푸드덕푸드덕", - "description": "구구구구국 구구...: ${{ steps.gradle.outputs.build-scan-url }}" - } - ] - # EC2로 배포 - name: Deploy to EC2 Server uses: appleboy/ssh-action@master @@ -128,3 +116,15 @@ jobs: docker pull ${{ env.IMAGE_FULL_URL }} docker compose up -d docker image prune -a -f + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: 둘기봇 + SLACK_ICON: https://github.com/GDSC-Hongik/gdsc-server/assets/91878695/1d3861bd-672d-4ee7-8de4-f06c9a06f514 + SLACK_TITLE: "Deploy Summary - Develop" + SLACK_MESSAGE: | + - image tag: `${{ steps.metadata.outputs.tags }}` + - build scan report: ${{ steps.gradle.outputs.build-scan-url }} diff --git a/.github/workflows/develop_deploy.yml b/.github/workflows/develop_deploy.yml index 0f3c6648d..567509734 100644 --- a/.github/workflows/develop_deploy.yml +++ b/.github/workflows/develop_deploy.yml @@ -11,12 +11,13 @@ jobs: deploy: runs-on: ubuntu-latest environment: develop + env: + IMAGE_FULL_URL: ${{ secrets.DOCKERHUB_USERNAME }}/gdsc-server:${{ github.event.inputs.commit_hash }} steps: - name: Deploy to EC2 Server uses: appleboy/ssh-action@master env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - IMAGE_FULL_URL: ${{ secrets.DOCKERHUB_USERNAME }}/gdsc-server:${{ github.event.inputs.commit_hash }} with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} @@ -27,3 +28,13 @@ jobs: docker pull ${{ env.IMAGE_FULL_URL }} docker compose up -d docker image prune -a -f + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: 둘기봇 + SLACK_ICON: https://github.com/GDSC-Hongik/gdsc-server/assets/91878695/1d3861bd-672d-4ee7-8de4-f06c9a06f514 + SLACK_TITLE: "Deploy Summary - Develop" + SLACK_MESSAGE: Manually deployed with `${{ env.IMAGE_FULL_URL }}` diff --git a/.github/workflows/production_build_deploy.yml b/.github/workflows/production_build_deploy.yml index 29651e98d..15ff83407 100644 --- a/.github/workflows/production_build_deploy.yml +++ b/.github/workflows/production_build_deploy.yml @@ -45,19 +45,19 @@ jobs: run: docker-compose -f ./docker-compose-test.yaml up -d # Gradle 빌드 - - name: Build with Gradle - id: gradle + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 with: - arguments: | - build - --configuration-cache cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} # feature 브랜치는 캐시를 읽기 전용으로 설정 cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} add-job-summary-as-pr-comment: always build-scan-publish: true - build-scan-terms-of-service-url: "https://gradle.com/terms-of-service" - build-scan-terms-of-service-agree: "yes" + build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" + build-scan-terms-of-use-agree: "yes" + + - name: Build with Gradle + id: gradle + run: ./gradlew build --configuration-cache # Dockerhub 로그인 - name: Login to Dockerhub @@ -114,19 +114,6 @@ jobs: - name: Copy docker-compose file to S3 run: aws s3 cp docker-compose.yml ${{ env.S3_COPY_PATH }} - # 디스코드 둘기봇으로 gradle build scan 결과 발송 - - name: Send Gradle Build Scan Result to Discord - uses: Ilshidur/action-discord@master - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - DISCORD_EMBEDS: | - [ - { - "title": "푸드덕푸드덕푸드덕", - "description": "구구구구국 구구...: ${{ steps.gradle.outputs.build-scan-url }}" - } - ] - - name: Deploy to EC2 Server uses: appleboy/ssh-action@master env: @@ -143,3 +130,15 @@ jobs: docker pull ${{ env.IMAGE_FULL_URL }} docker-compose up -d docker image prune -a -f + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: 둘기봇 + SLACK_ICON: https://github.com/GDSC-Hongik/gdsc-server/assets/91878695/1d3861bd-672d-4ee7-8de4-f06c9a06f514 + SLACK_TITLE: "Deploy Summary - Production" + SLACK_MESSAGE: | + - image tag: `${{ steps.metadata.outputs.tags }}` + - build scan report: ${{ steps.gradle.outputs.build-scan-url }} diff --git a/.github/workflows/production_deploy.yml b/.github/workflows/production_deploy.yml index ec4cf828d..f249ec62d 100644 --- a/.github/workflows/production_deploy.yml +++ b/.github/workflows/production_deploy.yml @@ -11,12 +11,13 @@ jobs: deploy: runs-on: ubuntu-latest environment: production + env: + IMAGE_FULL_URL: ${{ secrets.DOCKERHUB_USERNAME }}/gdsc-server:${{ github.event.inputs.semver }} steps: - name: Deploy to EC2 Server uses: appleboy/ssh-action@master env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - IMAGE_FULL_URL: ${{ secrets.DOCKERHUB_USERNAME }}/gdsc-server:${{ github.event.inputs.semver }} with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} @@ -27,3 +28,13 @@ jobs: docker pull ${{ env.IMAGE_FULL_URL }} docker-compose up -d docker image prune -a -f + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: 둘기봇 + SLACK_ICON: https://github.com/GDSC-Hongik/gdsc-server/assets/91878695/1d3861bd-672d-4ee7-8de4-f06c9a06f514 + SLACK_TITLE: "Deploy Summary - Production" + SLACK_MESSAGE: Manually deployed with `${{ env.IMAGE_FULL_URL }}` diff --git a/.github/workflows/pull_request_gradle_build.yml b/.github/workflows/pull_request_gradle_build.yml index dabd4105b..93673e0c0 100644 --- a/.github/workflows/pull_request_gradle_build.yml +++ b/.github/workflows/pull_request_gradle_build.yml @@ -28,16 +28,16 @@ jobs: - name: Start containers run: docker-compose -f ./docker-compose-test.yaml up -d - - name: Build with Gradle + - name: Setup Gradle id: gradle uses: gradle/actions/setup-gradle@v3 with: - arguments: | - check - --configuration-cache cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} # feature 브랜치는 캐시를 읽기 전용으로 설정 cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} add-job-summary-as-pr-comment: always build-scan-publish: true - build-scan-terms-of-service-url: "https://gradle.com/terms-of-service" - build-scan-terms-of-service-agree: "yes" + build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" + build-scan-terms-of-use-agree: "yes" + + - name: Check with Gradle + run: ./gradlew check --configuration-cache diff --git a/.gitignore b/.gitignore index 411f64aa8..f8679609c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +/src/main/generated/ +/src/test/generated_tests/ ### NetBeans ### /nbproject/private/ @@ -41,3 +43,4 @@ out/ ### Secrets ### .env +.env.* diff --git a/build.gradle b/build.gradle index 55883f404..dfddafb7e 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,13 @@ ext { set('snippetsDir', file("build/generated-snippets")) } +dependencyManagement { + imports { + mavenBom 'org.springframework.cloud:spring-cloud-dependencies:2023.0.2' + } +} + + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -53,10 +60,11 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + testImplementation 'org.testcontainers:testcontainers' // Querydsl - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" @@ -80,6 +88,11 @@ dependencies { // Monitoring implementation 'io.micrometer:micrometer-registry-prometheus' + + // OpenFeign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'io.github.openfeign:feign-jackson' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } tasks.named('test') { diff --git a/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseEntity.java similarity index 63% rename from src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java rename to src/main/java/com/gdschongik/gdsc/domain/common/model/BaseEntity.java index 08c0a9d9a..09664490c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseEntity.java @@ -5,20 +5,29 @@ import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.domain.AbstractAggregateRoot; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) -public abstract class BaseTimeEntity { +public abstract class BaseEntity extends AbstractAggregateRoot { @Column(updatable = false) @CreatedDate private LocalDateTime createdAt; - @Column @LastModifiedDate private LocalDateTime updatedAt; + + @Column(updatable = false) + @CreatedBy + private Long createdBy; + + @LastModifiedBy + private Long updatedBy; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseSemesterEntity.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseSemesterEntity.java new file mode 100644 index 000000000..829f813d1 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseSemesterEntity.java @@ -0,0 +1,29 @@ +package com.gdschongik.gdsc.domain.common.model; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseSemesterEntity extends BaseEntity { + + private Integer academicYear; + + @Enumerated(EnumType.STRING) + private SemesterType semesterType; + + protected void updateAcademicYear(Integer academicYear) { + this.academicYear = academicYear; + } + + protected void updateSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/RequirementStatus.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java similarity index 69% rename from src/main/java/com/gdschongik/gdsc/domain/member/domain/RequirementStatus.java rename to src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java index 9a174d4d4..a2b2fe48c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/RequirementStatus.java +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.domain.member.domain; +package com.gdschongik.gdsc.domain.common.model; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,7 +7,7 @@ @AllArgsConstructor public enum RequirementStatus { PENDING("PENDING"), - VERIFIED("VERIFIED"); + SATISFIED("SATISFIED"); private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/common/model/SemesterType.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/SemesterType.java new file mode 100644 index 000000000..72a0b990a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/SemesterType.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.domain.common.model; + +import java.time.MonthDay; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SemesterType { + FIRST(1, MonthDay.of(3, 1)), + SECOND(2, MonthDay.of(9, 1)); + + private final Integer value; + private final MonthDay startDate; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/common/vo/Money.java b/src/main/java/com/gdschongik/gdsc/domain/common/vo/Money.java new file mode 100644 index 000000000..b03f4fdd8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/common/vo/Money.java @@ -0,0 +1,96 @@ +package com.gdschongik.gdsc.domain.common.vo; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Embeddable; +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public final class Money { + + private BigDecimal amount; + + @Override + public boolean equals(Object obj) { + return obj instanceof Money other && amount.compareTo(other.amount) == 0; + } + + @Override + public int hashCode() { + return amount.stripTrailingZeros().hashCode(); + } + + @Builder(access = AccessLevel.PRIVATE) + private Money(BigDecimal amount) { + this.amount = amount; + } + + public static final Money ZERO = Money.from(BigDecimal.ZERO); + + public static Money from(BigDecimal amount) { + validateAmountNotNull(amount); + + return Money.builder().amount(amount).build(); + } + + public static Money from(Long amount) { + validateAmountNotNull(BigDecimal.valueOf(amount)); + + return Money.builder().amount(BigDecimal.valueOf(amount)).build(); + } + + private static void validateAmountNotNull(BigDecimal amount) { + if (amount == null) { + throw new CustomException(MONEY_AMOUNT_NOT_NULL); + } + } + + // 모든 로직은 BigDecimal에서 제공하는 메서드를 그대로 사용하여 구현 + + // 금액 사칙연산 로직 + + public Money add(@NonNull Money target) { + return Money.from(amount.add(target.amount)); + } + + public Money subtract(@NonNull Money target) { + return Money.from(amount.subtract(target.amount)); + } + + public Money multiply(@NonNull BigDecimal target) { + return Money.builder().amount(amount.multiply(target)).build(); + } + + public Money divide(@NonNull BigDecimal target) { + return Money.builder() + .amount(amount.divide(target, RoundingMode.HALF_UP)) + .build(); + } + + // 금액 비교 로직 + + public boolean isGreaterThan(@NonNull Money target) { + return amount.compareTo(target.amount) > 0; + } + + public boolean isGreaterThanOrEqual(@NonNull Money target) { + return amount.compareTo(target.amount) >= 0; + } + + public boolean isLessThan(@NonNull Money target) { + return amount.compareTo(target.amount) < 0; + } + + public boolean isLessThanOrEqual(@NonNull Money target) { + return amount.compareTo(target.amount) <= 0; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/api/AdminCouponController.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/api/AdminCouponController.java new file mode 100644 index 000000000..c6f97c706 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/api/AdminCouponController.java @@ -0,0 +1,68 @@ +package com.gdschongik.gdsc.domain.coupon.api; + +import com.gdschongik.gdsc.domain.coupon.application.CouponService; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponCreateRequest; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponIssueRequest; +import com.gdschongik.gdsc.domain.coupon.dto.request.IssuedCouponQueryOption; +import com.gdschongik.gdsc.domain.coupon.dto.response.CouponResponse; +import com.gdschongik.gdsc.domain.coupon.dto.response.IssuedCouponResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Coupon", description = "어드민 쿠폰 관리 API입니다.") +@RestController +@RequestMapping("/admin/coupons") +@RequiredArgsConstructor +public class AdminCouponController { + + private final CouponService couponService; + + @Operation(summary = "쿠폰 생성", description = "쿠폰을 생성합니다. 이름 및 할인금액을 가집니다.") + @PostMapping + public ResponseEntity createCoupon(@Valid @RequestBody CouponCreateRequest request) { + couponService.createCoupon(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "쿠폰 조회", description = "발급 가능한 모든 쿠폰을 조회합니다.") + @GetMapping + public ResponseEntity> getCoupons() { + List response = couponService.findAllCoupons(); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "발급쿠폰 조회", description = "발급된 쿠폰을 조회합니다.") + @GetMapping("/issued") + public ResponseEntity> getIssuedCoupons( + IssuedCouponQueryOption queryOption, Pageable pageable) { + Page response = couponService.findAllIssuedCoupons(queryOption, pageable); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "발급쿠폰 생성", description = "지정된 멤버들에게 쿠폰을 발급합니다. 존재하지 않는 멤버인 경우 무시됩니다.") + @PostMapping("/issued") + public ResponseEntity createIssuedCoupon(@Valid @RequestBody CouponIssueRequest request) { + couponService.createIssuedCoupon(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "발급쿠폰 회수", description = "발급된 쿠폰을 회수합니다.") + @DeleteMapping("/issued/{issuedCouponId}") + public ResponseEntity revokeIssuedCoupon(@PathVariable Long issuedCouponId) { + couponService.revokeIssuedCoupon(issuedCouponId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/api/OnboardingCouponController.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/api/OnboardingCouponController.java new file mode 100644 index 000000000..b5f2f8c99 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/api/OnboardingCouponController.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.coupon.api; + +import com.gdschongik.gdsc.domain.coupon.application.CouponService; +import com.gdschongik.gdsc.domain.coupon.dto.response.IssuedCouponResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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; + +@Tag(name = "Onboarding Coupon", description = "온보딩 쿠폰 API입니다.") +@RestController +@RequestMapping("/onboarding/coupons") +@RequiredArgsConstructor +public class OnboardingCouponController { + + private final CouponService couponService; + + @Operation(summary = "사용 가능한 내 발급쿠폰 조회", description = "나에게 발급된 쿠폰 중 사용 가능한 것만 조회합니다.") + @GetMapping("/issued/me") + public ResponseEntity> getMyUsableIssuedCoupons() { + var response = couponService.findMyUsableIssuedCoupons(); + return ResponseEntity.ok().body(response); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/application/CouponService.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/application/CouponService.java new file mode 100644 index 000000000..b182589c1 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/application/CouponService.java @@ -0,0 +1,90 @@ +package com.gdschongik.gdsc.domain.coupon.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.dao.CouponRepository; +import com.gdschongik.gdsc.domain.coupon.dao.IssuedCouponRepository; +import com.gdschongik.gdsc.domain.coupon.domain.Coupon; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponCreateRequest; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponIssueRequest; +import com.gdschongik.gdsc.domain.coupon.dto.request.IssuedCouponQueryOption; +import com.gdschongik.gdsc.domain.coupon.dto.response.CouponResponse; +import com.gdschongik.gdsc.domain.coupon.dto.response.IssuedCouponResponse; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.List; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponService { + + private final MemberUtil memberUtil; + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final MemberRepository memberRepository; + + @Transactional + public void createCoupon(CouponCreateRequest request) { + Coupon coupon = Coupon.createCoupon(request.name(), Money.from(request.discountAmount())); + couponRepository.save(coupon); + log.info("[CouponService] 쿠폰 생성: name={}, discountAmount={}", request.name(), request.discountAmount()); + } + + public List findAllCoupons() { + return couponRepository.findAll().stream().map(CouponResponse::from).toList(); + } + + public Page findAllIssuedCoupons(IssuedCouponQueryOption queryOption, Pageable pageable) { + Page issuedCoupons = issuedCouponRepository.findAllIssuedCoupons(queryOption, pageable); + return issuedCoupons.map(IssuedCouponResponse::from); + } + + @Transactional + public void createIssuedCoupon(CouponIssueRequest request) { + Coupon coupon = + couponRepository.findById(request.couponId()).orElseThrow(() -> new CustomException(COUPON_NOT_FOUND)); + + List members = memberRepository.findAllById(request.memberIds()); + + List issuedCoupons = members.stream() + .map(member -> IssuedCoupon.issue(coupon, member)) + .toList(); + + issuedCouponRepository.saveAll(issuedCoupons); + + log.info( + "[CouponService] 쿠폰 발급: issuedCouponIds={}", + issuedCoupons.stream().map(IssuedCoupon::getId).toList()); + } + + @Transactional + public void revokeIssuedCoupon(Long issuedCouponId) { + IssuedCoupon issuedCoupon = issuedCouponRepository + .findById(issuedCouponId) + .orElseThrow(() -> new CustomException(ISSUED_COUPON_NOT_FOUND)); + + issuedCoupon.revoke(); + log.info("[CouponService] 쿠폰 회수: issuedCouponId={}", issuedCouponId); + } + + public List findMyUsableIssuedCoupons() { + Member currentMember = memberUtil.getCurrentMember(); + + return issuedCouponRepository.findByMember(currentMember).stream() + .filter(IssuedCoupon::isUsable) + .map(IssuedCouponResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/CouponRepository.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/CouponRepository.java new file mode 100644 index 000000000..d8c6d87fc --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/CouponRepository.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.coupon.dao; + +import com.gdschongik.gdsc.domain.coupon.domain.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponRepository extends JpaRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepository.java new file mode 100644 index 000000000..32df4a7bf --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepository.java @@ -0,0 +1,11 @@ +package com.gdschongik.gdsc.domain.coupon.dao; + +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.coupon.dto.request.IssuedCouponQueryOption; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface IssuedCouponCustomRepository { + + Page findAllIssuedCoupons(IssuedCouponQueryOption queryOption, Pageable pageable); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepositoryImpl.java new file mode 100644 index 000000000..5b4777fba --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponCustomRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.coupon.dao; + +import static com.gdschongik.gdsc.domain.coupon.domain.QIssuedCoupon.issuedCoupon; + +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.coupon.dto.request.IssuedCouponQueryOption; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +@RequiredArgsConstructor +public class IssuedCouponCustomRepositoryImpl implements IssuedCouponCustomRepository, IssuedCouponQueryMethod { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllIssuedCoupons(IssuedCouponQueryOption queryOption, Pageable pageable) { + List fetch = queryFactory + .selectFrom(issuedCoupon) + .where(matchesQueryOption(queryOption)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(issuedCoupon.createdAt.desc()) + .fetch(); + + JPAQuery countQuery = + queryFactory.select(issuedCoupon.count()).from(issuedCoupon).where(matchesQueryOption(queryOption)); + + return PageableExecutionUtils.getPage(fetch, pageable, countQuery::fetchOne); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponQueryMethod.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponQueryMethod.java new file mode 100644 index 000000000..8ced8d892 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponQueryMethod.java @@ -0,0 +1,50 @@ +package com.gdschongik.gdsc.domain.coupon.dao; + +import static com.gdschongik.gdsc.domain.coupon.domain.QIssuedCoupon.issuedCoupon; + +import com.gdschongik.gdsc.domain.coupon.dto.request.IssuedCouponQueryOption; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; + +public interface IssuedCouponQueryMethod { + + default BooleanExpression eqStudentId(String studentId) { + return studentId != null ? issuedCoupon.member.studentId.containsIgnoreCase(studentId) : null; + } + + default BooleanExpression eqMemberName(String memberName) { + return memberName != null ? issuedCoupon.coupon.name.containsIgnoreCase(memberName) : null; + } + + default BooleanExpression eqPhone(String phone) { + return phone != null ? issuedCoupon.member.phone.contains(phone.replaceAll("-", "")) : null; + } + + default BooleanExpression eqCouponName(String couponName) { + return couponName != null ? issuedCoupon.coupon.name.containsIgnoreCase(couponName) : null; + } + + default BooleanExpression hasUsed(Boolean hasUsed) { + if (hasUsed == null) { + return null; + } + return hasUsed ? issuedCoupon.usedAt.isNotNull() : issuedCoupon.usedAt.isNull(); + } + + default BooleanExpression hasRevoked(Boolean hasRevoked) { + if (hasRevoked == null) { + return null; + } + return hasRevoked ? issuedCoupon.hasRevoked.isTrue() : issuedCoupon.hasRevoked.isFalse(); + } + + default BooleanBuilder matchesQueryOption(IssuedCouponQueryOption queryOption) { + return new BooleanBuilder() + .and(eqStudentId(queryOption.studentId())) + .and(eqMemberName(queryOption.memberName())) + .and(eqPhone(queryOption.phone())) + .and(eqCouponName(queryOption.couponName())) + .and(hasUsed(queryOption.hasUsed())) + .and(hasRevoked(queryOption.hasRevoked())); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponRepository.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponRepository.java new file mode 100644 index 000000000..c01a8c067 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dao/IssuedCouponRepository.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.domain.coupon.dao; + +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IssuedCouponRepository extends JpaRepository, IssuedCouponCustomRepository { + List findByMember(Member member); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/Coupon.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/Coupon.java new file mode 100644 index 000000000..939e6d0c2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/Coupon.java @@ -0,0 +1,52 @@ +package com.gdschongik.gdsc.domain.coupon.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Coupon extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "coupon_id") + private Long id; + + private String name; + + @Embedded + private Money discountAmount; + + @Builder(access = AccessLevel.PRIVATE) + private Coupon(String name, Money discountAmount) { + this.name = name; + this.discountAmount = discountAmount; + } + + public static Coupon createCoupon(String name, Money discountAmount) { + validateDiscountAmountPositive(discountAmount); + return Coupon.builder().name(name).discountAmount(discountAmount).build(); + } + + // 검증 로직 + + private static void validateDiscountAmountPositive(Money discountAmount) { + if (!discountAmount.isGreaterThan(Money.ZERO)) { + throw new CustomException(COUPON_DISCOUNT_AMOUNT_NOT_POSITIVE); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCoupon.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCoupon.java new file mode 100644 index 000000000..dd9794460 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCoupon.java @@ -0,0 +1,110 @@ +package com.gdschongik.gdsc.domain.coupon.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static java.lang.Boolean.*; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class IssuedCoupon extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "issued_coupon_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coupon_id") + private Coupon coupon; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Comment("회수 여부") + private Boolean hasRevoked; + + private LocalDateTime usedAt; + + @Builder(access = AccessLevel.PRIVATE) + private IssuedCoupon(Coupon coupon, Member member, Boolean hasRevoked) { + this.coupon = coupon; + this.member = member; + this.hasRevoked = hasRevoked; + } + + public static IssuedCoupon issue(Coupon coupon, Member member) { + return IssuedCoupon.builder() + .coupon(coupon) + .member(member) + .hasRevoked(false) + .build(); + } + + // 검증 로직 + + public void validateUsable() { + if (hasRevoked.equals(TRUE)) { + throw new CustomException(COUPON_NOT_USABLE_REVOKED); + } + + if (hasUsed()) { + throw new CustomException(COUPON_NOT_USABLE_ALREADY_USED); + } + } + + private void validateRevokable() { + if (hasRevoked.equals(TRUE)) { + throw new CustomException(COUPON_NOT_REVOKABLE_ALREADY_REVOKED); + } + + if (hasUsed()) { + throw new CustomException(COUPON_NOT_REVOKABLE_ALREADY_USED); + } + } + + // 상태 변경 로직 + + public void use() { + validateUsable(); + usedAt = LocalDateTime.now(); + } + + public void revoke() { + validateRevokable(); + hasRevoked = true; + } + + // 데이터 전달 로직 + + public boolean hasUsed() { + return usedAt != null; + } + + public boolean isUsable() { + try { + validateUsable(); + return true; + } catch (CustomException e) { + return false; + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponCreateRequest.java new file mode 100644 index 000000000..50c24383d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponCreateRequest.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.domain.coupon.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; + +public record CouponCreateRequest(@NotBlank String name, @Positive BigDecimal discountAmount) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponIssueRequest.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponIssueRequest.java new file mode 100644 index 000000000..cef965a4f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponIssueRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.coupon.dto.request; + +import java.util.List; + +public record CouponIssueRequest(Long couponId, List memberIds) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponQueryOption.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponQueryOption.java new file mode 100644 index 000000000..bf5173e44 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/CouponQueryOption.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.coupon.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CouponQueryOption(@Schema(description = "쿠폰 이름") String couponName) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/IssuedCouponQueryOption.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/IssuedCouponQueryOption.java new file mode 100644 index 000000000..2e5b65aca --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/request/IssuedCouponQueryOption.java @@ -0,0 +1,11 @@ +package com.gdschongik.gdsc.domain.coupon.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record IssuedCouponQueryOption( + @Schema(description = "학번") String studentId, + @Schema(description = "이름") String memberName, + @Schema(description = "전화번호") String phone, + @Schema(description = "쿠폰 이름") String couponName, + @Schema(description = "쿠폰 사용 여부") Boolean hasUsed, + @Schema(description = "쿠폰 회수 여부") Boolean hasRevoked) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/CouponResponse.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/CouponResponse.java new file mode 100644 index 000000000..2be2dc47d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/CouponResponse.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.domain.coupon.dto.response; + +import com.gdschongik.gdsc.domain.coupon.domain.Coupon; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record CouponResponse(Long couponId, String name, BigDecimal discountAmount, LocalDateTime createdAt) { + public static CouponResponse from(Coupon coupon) { + return new CouponResponse( + coupon.getId(), coupon.getName(), coupon.getDiscountAmount().getAmount(), coupon.getCreatedAt()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/IssuedCouponResponse.java b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/IssuedCouponResponse.java new file mode 100644 index 000000000..a56e6350c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/coupon/dto/response/IssuedCouponResponse.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.coupon.dto.response; + +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.dto.MemberDto; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record IssuedCouponResponse( + Long issuedCouponId, + MemberDto member, + String couponName, + BigDecimal discountAmount, + LocalDateTime usedAt, + LocalDateTime issuedAt, + Boolean hasUsed, + Boolean hasRevoked) { + public static IssuedCouponResponse from(IssuedCoupon issuedCoupon) { + return new IssuedCouponResponse( + issuedCoupon.getId(), + MemberDto.from(issuedCoupon.getMember()), + issuedCoupon.getCoupon().getName(), + issuedCoupon.getCoupon().getDiscountAmount().getAmount(), + issuedCoupon.getUsedAt(), + issuedCoupon.getCreatedAt(), + issuedCoupon.hasUsed(), + issuedCoupon.getHasRevoked()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/api/OnboardingDiscordController.java b/src/main/java/com/gdschongik/gdsc/domain/discord/api/OnboardingDiscordController.java index 70e07b106..bcb2150b6 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/api/OnboardingDiscordController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/api/OnboardingDiscordController.java @@ -1,15 +1,24 @@ package com.gdschongik.gdsc.domain.discord.api; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; + import com.gdschongik.gdsc.domain.discord.application.OnboardingDiscordService; import com.gdschongik.gdsc.domain.discord.dto.request.DiscordLinkRequest; +import com.gdschongik.gdsc.domain.discord.dto.response.DiscordCheckDuplicateResponse; +import com.gdschongik.gdsc.domain.discord.dto.response.DiscordCheckJoinResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Onboarding Discord", description = "온보딩 서비스의 디스코드 관련 API입니다.") @@ -26,4 +35,32 @@ public ResponseEntity linkDiscord(@Valid @RequestBody DiscordLinkRequest r onboardingDiscordService.verifyDiscordCode(request); return ResponseEntity.ok().build(); } + + @Operation(summary = "디스코드 사용자명 중복 확인하기", description = "디스코드 사용자명이 중복되는지 확인합니다.") + @GetMapping("/check-discord-username") + public ResponseEntity checkDiscordUsername( + @RequestParam("username") @NotBlank @Schema(description = "디스코드 유저네임") String discordUsername) { + DiscordCheckDuplicateResponse response = onboardingDiscordService.checkUsernameDuplicate(discordUsername); + return ResponseEntity.ok(response); + } + + @Operation(summary = "디스코드 닉네임 중복 확인하기", description = "디스코드 닉네임이 중복되는지 확인합니다.") + @GetMapping("/check-discord-nickname") + public ResponseEntity checkDiscordNickname( + @RequestParam("nickname") + @NotBlank + @Pattern(regexp = NICKNAME, message = "닉네임은 " + NICKNAME + " 형식이어야 합니다.") + @Schema(description = "커뮤니티 닉네임", pattern = NICKNAME) + String nickname) { + DiscordCheckDuplicateResponse response = onboardingDiscordService.checkNicknameDuplicate(nickname); + return ResponseEntity.ok(response); + } + + @Operation(summary = "디스코드 합류 확인하기", description = "해당 사용자명을 가진 유저가 디스코드 서버에 합류했는지 확인합니다.") + @GetMapping("/check-discord-join") + public ResponseEntity checkDiscordJoin( + @RequestParam("username") @NotBlank @Schema(description = "디스코드 유저네임") String discordUsername) { + DiscordCheckJoinResponse response = onboardingDiscordService.checkServerJoined(discordUsername); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/CommonDiscordService.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/CommonDiscordService.java index ad9dd6501..da1e1e62c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/application/CommonDiscordService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/CommonDiscordService.java @@ -1,15 +1,25 @@ package com.gdschongik.gdsc.domain.discord.application; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; +import com.gdschongik.gdsc.domain.discord.domain.DiscordValidator; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.DiscordUtil; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class CommonDiscordService { private final MemberRepository memberRepository; + private final DiscordUtil discordUtil; + private final DiscordValidator discordValidator; public String getNicknameByDiscordUsername(String discordUsername) { return memberRepository @@ -17,4 +27,21 @@ public String getNicknameByDiscordUsername(String discordUsername) { .map(Member::getNickname) .orElse(null); } + + @Transactional + public void batchDiscordId(String currentDiscordUsername, RequirementStatus discordStatus) { + Member currentMember = memberRepository + .findByDiscordUsername(currentDiscordUsername) + .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); + + discordValidator.validateAdminPermission(currentMember); + + List discordSatisfiedMembers = memberRepository.findAllByDiscordStatus(discordStatus); + + discordSatisfiedMembers.forEach(member -> { + String discordUsername = member.getDiscordUsername(); + String discordId = discordUtil.getMemberIdByUsername(discordUsername); + member.updateDiscordId(discordId); + }); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/OnboardingDiscordService.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/OnboardingDiscordService.java index 27a4f01a4..c3d7d52ae 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/application/OnboardingDiscordService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/OnboardingDiscordService.java @@ -4,30 +4,36 @@ import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.domain.discord.dao.DiscordVerificationCodeRepository; +import com.gdschongik.gdsc.domain.discord.domain.DiscordValidator; import com.gdschongik.gdsc.domain.discord.domain.DiscordVerificationCode; import com.gdschongik.gdsc.domain.discord.dto.request.DiscordLinkRequest; -import com.gdschongik.gdsc.domain.discord.dto.response.DiscordNicknameResponse; +import com.gdschongik.gdsc.domain.discord.dto.response.DiscordCheckDuplicateResponse; +import com.gdschongik.gdsc.domain.discord.dto.response.DiscordCheckJoinResponse; import com.gdschongik.gdsc.domain.discord.dto.response.DiscordVerificationCodeResponse; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.DiscordUtil; import com.gdschongik.gdsc.global.util.MemberUtil; import java.security.SecureRandom; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class OnboardingDiscordService { public static final long DISCORD_CODE_TTL_SECONDS = 300L; private final DiscordVerificationCodeRepository discordVerificationCodeRepository; private final MemberUtil memberUtil; + private final DiscordUtil discordUtil; private final MemberRepository memberRepository; + private final DiscordValidator discordValidator; @Transactional public DiscordVerificationCodeResponse createVerificationCode(String discordUsername) { @@ -55,44 +61,44 @@ public void verifyDiscordCode(DiscordLinkRequest request) { .findById(request.discordUsername()) .orElseThrow(() -> new CustomException(DISCORD_CODE_NOT_FOUND)); - validateDiscordCodeMatches(request, discordVerificationCode); - validateDiscordUsernameDuplicate(request.discordUsername()); - validateNicknameDuplicate(request.nickname()); + boolean isDiscordUsernameDuplicate = memberRepository.existsByDiscordUsername(request.discordUsername()); + boolean isNicknameDuplicate = memberRepository.existsByNickname(request.nickname()); + + discordValidator.validateVerifyDiscordCode( + request.code(), discordVerificationCode, isDiscordUsernameDuplicate, isNicknameDuplicate); discordVerificationCodeRepository.delete(discordVerificationCode); final Member currentMember = memberUtil.getCurrentMember(); currentMember.verifyDiscord(request.discordUsername(), request.nickname()); - } - private void validateDiscordUsernameDuplicate(String discordUsername) { - if (memberRepository.existsByDiscordUsername(discordUsername)) { - throw new CustomException(MEMBER_DISCORD_USERNAME_DUPLICATE); - } - } + updateDiscordId(request.discordUsername(), currentMember); - private void validateNicknameDuplicate(String nickname) { - if (memberRepository.existsByNickname(nickname)) { - throw new CustomException(MEMBER_NICKNAME_DUPLICATE); - } + memberRepository.save(currentMember); + + log.info("[OnboardingDiscordService] 디스코드 연동: memberId={}", currentMember.getId()); } - private void validateDiscordCodeMatches( - DiscordLinkRequest request, DiscordVerificationCode discordVerificationCode) { - if (!discordVerificationCode.matchesCode(request.code())) { - throw new CustomException(DISCORD_CODE_MISMATCH); - } + private void updateDiscordId(String discordUsername, Member currentMember) { + String discordId = discordUtil.getMemberIdByUsername(discordUsername); + currentMember.updateDiscordId(discordId); } - public DiscordNicknameResponse checkDiscordRoleAssignable(String discordUsername) { - Member member = memberRepository - .findByDiscordUsername(discordUsername) - .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); + @Transactional(readOnly = true) + public DiscordCheckDuplicateResponse checkUsernameDuplicate(String discordUsername) { + boolean isExist = memberRepository.existsByDiscordUsername(discordUsername); + return DiscordCheckDuplicateResponse.from(isExist); + } - if (!member.isGranted()) { - throw new CustomException(DISCORD_ROLE_UNASSIGNABLE); - } + @Transactional(readOnly = true) + public DiscordCheckDuplicateResponse checkNicknameDuplicate(String nickname) { + boolean isExist = memberRepository.existsByNickname(nickname); + return DiscordCheckDuplicateResponse.from(isExist); + } - return DiscordNicknameResponse.of(member.getNickname()); + public DiscordCheckJoinResponse checkServerJoined(String discordUsername) { + boolean isJoined = + discordUtil.getOptionalMemberByUsername(discordUsername).isPresent(); + return DiscordCheckJoinResponse.from(isJoined); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DelegateMemberDiscordEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DelegateMemberDiscordEventHandler.java new file mode 100644 index 000000000..aa45d727d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DelegateMemberDiscordEventHandler.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.discord.application.handler; + +import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; + +import com.gdschongik.gdsc.domain.member.domain.MemberAdvancedToRegularEvent; +import com.gdschongik.gdsc.global.util.DiscordUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DelegateMemberDiscordEventHandler implements SpringEventHandler { + + private final DiscordUtil discordUtil; + + @Override + public void delegate(Object context) { + MemberAdvancedToRegularEvent event = (MemberAdvancedToRegularEvent) context; + Guild guild = discordUtil.getCurrentGuild(); + Member member = discordUtil.getMemberById(event.discordId()); + Role role = discordUtil.findRoleByName(MEMBER_ROLE_NAME); + + guild.addRoleToMember(member, role).queue(); + + log.info( + "[DelegateMemberDiscordEventHandler] 디스코드 서버 정회원 역할 부여 완료: memberId={}, discordId={}", + event.memberId(), + event.discordId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/DiscordEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordEventHandler.java similarity index 56% rename from src/main/java/com/gdschongik/gdsc/domain/discord/handler/DiscordEventHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordEventHandler.java index 655748545..679f5219a 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/DiscordEventHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordEventHandler.java @@ -1,9 +1,8 @@ -package com.gdschongik.gdsc.domain.discord.handler; +package com.gdschongik.gdsc.domain.discord.application.handler; import net.dv8tion.jda.api.events.GenericEvent; public interface DiscordEventHandler { - // TODO: GenericEvent에 대한 어댑터 추가 void delegate(GenericEvent genericEvent); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordIdBatchCommandHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordIdBatchCommandHandler.java new file mode 100644 index 000000000..f0b537d1c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/DiscordIdBatchCommandHandler.java @@ -0,0 +1,31 @@ +package com.gdschongik.gdsc.domain.discord.application.handler; + +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.*; +import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; + +import com.gdschongik.gdsc.domain.discord.application.CommonDiscordService; +import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.events.GenericEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DiscordIdBatchCommandHandler implements DiscordEventHandler { + + private final CommonDiscordService commonDiscordService; + + @Override + public void delegate(GenericEvent genericEvent) { + SlashCommandInteractionEvent event = (SlashCommandInteractionEvent) genericEvent; + event.deferReply(true).setContent(DEFER_MESSAGE_BATCH_DISCORD_ID).queue(); + + String discordUsername = event.getUser().getName(); + commonDiscordService.batchDiscordId(discordUsername, SATISFIED); + + event.getHook() + .sendMessage(REPLY_MESSAGE_BATCH_DISCORD_ID) + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/IssuingCodeCommandHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/IssuingCodeCommandHandler.java similarity index 95% rename from src/main/java/com/gdschongik/gdsc/domain/discord/handler/IssuingCodeCommandHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/IssuingCodeCommandHandler.java index e08207a03..007d7e9d0 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/IssuingCodeCommandHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/IssuingCodeCommandHandler.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.domain.discord.handler; +package com.gdschongik.gdsc.domain.discord.application.handler; import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/NicknameModifyHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NicknameModifyHandler.java similarity index 96% rename from src/main/java/com/gdschongik/gdsc/domain/discord/handler/NicknameModifyHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NicknameModifyHandler.java index 18a0011a4..6ccf968e4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/NicknameModifyHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NicknameModifyHandler.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.domain.discord.handler; +package com.gdschongik.gdsc.domain.discord.application.handler; import com.gdschongik.gdsc.domain.discord.application.CommonDiscordService; import com.gdschongik.gdsc.global.exception.CustomException; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/NonCommandHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NonCommandHandler.java similarity index 94% rename from src/main/java/com/gdschongik/gdsc/domain/discord/handler/NonCommandHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NonCommandHandler.java index cf9e882de..3aa1d77b4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/NonCommandHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/NonCommandHandler.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.domain.discord.handler; +package com.gdschongik.gdsc.domain.discord.application.handler; import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/SpringEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/SpringEventHandler.java new file mode 100644 index 000000000..89bd72321 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/SpringEventHandler.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.discord.application.handler; + +public interface SpringEventHandler { + + void delegate(Object context); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DelegateMemberDiscordEventListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DelegateMemberDiscordEventListener.java new file mode 100644 index 000000000..1d919df4b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DelegateMemberDiscordEventListener.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.discord.application.listener; + +import com.gdschongik.gdsc.domain.discord.application.handler.DelegateMemberDiscordEventHandler; +import com.gdschongik.gdsc.domain.member.domain.MemberAdvancedToRegularEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DelegateMemberDiscordEventListener { + + private final DelegateMemberDiscordEventHandler delegateMemberDiscordEventHandler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void delegateMemberDiscordEvent(MemberAdvancedToRegularEvent event) { + log.info("[DelegateMemberDiscordEventListener] 정회원 승급 이벤트 수신: memberId={}", event.memberId()); + delegateMemberDiscordEventHandler.delegate(event); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DiscordIdBatchCommandListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DiscordIdBatchCommandListener.java new file mode 100644 index 000000000..ef67bdc9a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/DiscordIdBatchCommandListener.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.domain.discord.application.listener; + +import com.gdschongik.gdsc.domain.discord.application.handler.DiscordIdBatchCommandHandler; +import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; + +@Component +@Listener +@RequiredArgsConstructor +public class DiscordIdBatchCommandListener extends ListenerAdapter { + + private final DiscordIdBatchCommandHandler discordIdBatchCommandHandler; + + public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { + discordIdBatchCommandHandler.delegate(event); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/listener/IssuingCodeCommandListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/IssuingCodeCommandListener.java similarity index 79% rename from src/main/java/com/gdschongik/gdsc/global/discord/listener/IssuingCodeCommandListener.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/IssuingCodeCommandListener.java index ba2fa3256..03a117cb5 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/listener/IssuingCodeCommandListener.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/IssuingCodeCommandListener.java @@ -1,9 +1,8 @@ -package com.gdschongik.gdsc.global.discord.listener; +package com.gdschongik.gdsc.domain.discord.application.listener; import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; -import com.gdschongik.gdsc.domain.discord.handler.IssuingCodeCommandHandler; -import com.gdschongik.gdsc.global.discord.Listener; +import com.gdschongik.gdsc.domain.discord.application.handler.IssuingCodeCommandHandler; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/Listener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/Listener.java similarity index 82% rename from src/main/java/com/gdschongik/gdsc/global/discord/Listener.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/Listener.java index 6cd401f08..397892aa0 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/Listener.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/Listener.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord; +package com.gdschongik.gdsc.domain.discord.application.listener; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/ListenerBeanPostProcessor.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/ListenerBeanPostProcessor.java similarity index 88% rename from src/main/java/com/gdschongik/gdsc/global/discord/ListenerBeanPostProcessor.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/ListenerBeanPostProcessor.java index d260af0dd..6dc382fab 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/ListenerBeanPostProcessor.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/ListenerBeanPostProcessor.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord; +package com.gdschongik.gdsc.domain.discord.application.listener; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.JDA; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/listener/NicknameModifyListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NicknameModifyListener.java similarity index 75% rename from src/main/java/com/gdschongik/gdsc/global/discord/listener/NicknameModifyListener.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NicknameModifyListener.java index a7fe0735a..f1a31cc0a 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/listener/NicknameModifyListener.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NicknameModifyListener.java @@ -1,7 +1,6 @@ -package com.gdschongik.gdsc.global.discord.listener; +package com.gdschongik.gdsc.domain.discord.application.listener; -import com.gdschongik.gdsc.domain.discord.handler.NicknameModifyHandler; -import com.gdschongik.gdsc.global.discord.Listener; +import com.gdschongik.gdsc.domain.discord.application.handler.NicknameModifyHandler; import jakarta.annotation.Nonnull; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameEvent; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/listener/NonCommandListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NonCommandListener.java similarity index 81% rename from src/main/java/com/gdschongik/gdsc/global/discord/listener/NonCommandListener.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NonCommandListener.java index 5fe745342..3192b2eed 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/listener/NonCommandListener.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/NonCommandListener.java @@ -1,7 +1,6 @@ -package com.gdschongik.gdsc.global.discord.listener; +package com.gdschongik.gdsc.domain.discord.application.listener; -import com.gdschongik.gdsc.domain.discord.handler.NonCommandHandler; -import com.gdschongik.gdsc.global.discord.Listener; +import com.gdschongik.gdsc.domain.discord.application.handler.NonCommandHandler; import com.gdschongik.gdsc.global.property.DiscordProperty; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/listener/PingpongListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/PingpongListener.java similarity index 82% rename from src/main/java/com/gdschongik/gdsc/global/discord/listener/PingpongListener.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/PingpongListener.java index 2cda073f9..5dacbe56e 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/listener/PingpongListener.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/PingpongListener.java @@ -1,9 +1,8 @@ -package com.gdschongik.gdsc.global.discord.listener; +package com.gdschongik.gdsc.domain.discord.application.listener; import static com.gdschongik.gdsc.global.common.constant.EnvironmentConstant.*; import com.gdschongik.gdsc.global.annotation.ConditionalOnProfile; -import com.gdschongik.gdsc.global.discord.Listener; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; @@ -23,7 +22,7 @@ public void onMessageReceived(MessageReceivedEvent event) { Message message = event.getMessage(); String content = message.getContentRaw(); // get only textual content of message - log.info("Message from {} in {}: {}", author.getName(), channel.getName(), message.getContentDisplay()); + log.info("Message of {} in {}: {}", author.getName(), channel.getName(), message.getContentDisplay()); if (author.isBot()) return; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/domain/DiscordValidator.java b/src/main/java/com/gdschongik/gdsc/domain/discord/domain/DiscordValidator.java new file mode 100644 index 000000000..31dafe58c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/domain/DiscordValidator.java @@ -0,0 +1,39 @@ +package com.gdschongik.gdsc.domain.discord.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; + +@DomainService +public class DiscordValidator { + + public void validateVerifyDiscordCode( + Integer requestedCode, + DiscordVerificationCode discordVerificationCode, + boolean isDiscordUsernameDuplicate, + boolean isNicknameDuplicate) { + // 입력받은 코드가 일치하는지 검증 + if (!discordVerificationCode.matchesCode(requestedCode)) { + throw new CustomException(DISCORD_CODE_MISMATCH); + } + + // 디스코드 유저네임이 중복되는지 검증 + if (isDiscordUsernameDuplicate) { + throw new CustomException(MEMBER_DISCORD_USERNAME_DUPLICATE); + } + + // 닉네임이 중복되는지 검증 + if (isNicknameDuplicate) { + throw new CustomException(MEMBER_NICKNAME_DUPLICATE); + } + } + + public void validateAdminPermission(Member currentMember) { + if (!currentMember.getRole().equals(MemberRole.ADMIN)) { + throw new CustomException(INVALID_ROLE); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckDuplicateResponse.java b/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckDuplicateResponse.java new file mode 100644 index 000000000..679c27433 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckDuplicateResponse.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.domain.discord.dto.response; + +public record DiscordCheckDuplicateResponse(Boolean isDuplicate) { + public static DiscordCheckDuplicateResponse from(Boolean isDuplicate) { + return new DiscordCheckDuplicateResponse(isDuplicate); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckJoinResponse.java b/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckJoinResponse.java new file mode 100644 index 000000000..b8c6f53a5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordCheckJoinResponse.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.domain.discord.dto.response; + +public record DiscordCheckJoinResponse(boolean isJoined) { + public static DiscordCheckJoinResponse from(boolean isJoined) { + return new DiscordCheckJoinResponse(isJoined); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordNicknameResponse.java b/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordNicknameResponse.java deleted file mode 100644 index 8983711cc..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/dto/response/DiscordNicknameResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.gdschongik.gdsc.domain.discord.dto.response; - -public record DiscordNicknameResponse(String nickname) { - - public static DiscordNicknameResponse of(String nickname) { - return new DiscordNicknameResponse(nickname); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordEventHandlerAspect.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordEventHandlerAspect.java similarity index 73% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordEventHandlerAspect.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordEventHandlerAspect.java index c59b1d077..742ee843e 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordEventHandlerAspect.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordEventHandlerAspect.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord.exception; +package com.gdschongik.gdsc.domain.discord.exception; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.events.GenericEvent; @@ -15,10 +15,8 @@ public class DiscordEventHandlerAspect { private final DiscordExceptionDispatcher discordExceptionDispatcher; @Around( - "execution(* com.gdschongik.gdsc.domain.discord.handler.DiscordEventHandler.delegate(*)) && args(genericEvent)") + "execution(* com.gdschongik.gdsc.domain.discord.application.handler.DiscordEventHandler.delegate(*)) && args(genericEvent)") public Object doAround(ProceedingJoinPoint joinPoint, GenericEvent genericEvent) throws Throwable { - // TODO: 외부 의존성인 디스코드 클래스에 대한 어댑터 추가 - try { return joinPoint.proceed(); } catch (Exception e) { diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionDispatcher.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionDispatcher.java similarity index 81% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionDispatcher.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionDispatcher.java index 77afb77ac..21faa0e82 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionDispatcher.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionDispatcher.java @@ -1,8 +1,8 @@ -package com.gdschongik.gdsc.global.discord.exception; +package com.gdschongik.gdsc.domain.discord.exception; -import com.gdschongik.gdsc.global.discord.exception.handler.CommandExceptionHandler; -import com.gdschongik.gdsc.global.discord.exception.handler.DefaultExceptionHandler; -import com.gdschongik.gdsc.global.discord.exception.handler.DiscordExceptionHandler; +import com.gdschongik.gdsc.domain.discord.exception.handler.CommandExceptionHandler; +import com.gdschongik.gdsc.domain.discord.exception.handler.DefaultExceptionHandler; +import com.gdschongik.gdsc.domain.discord.exception.handler.DiscordExceptionHandler; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionMessageGenerator.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionMessageGenerator.java similarity index 86% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionMessageGenerator.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionMessageGenerator.java index 33a08ee5c..0b589c94f 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/DiscordExceptionMessageGenerator.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/DiscordExceptionMessageGenerator.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord.exception; +package com.gdschongik.gdsc.domain.discord.exception; import com.gdschongik.gdsc.global.exception.CustomException; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/exception/SpringEventHandlerAspect.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/SpringEventHandlerAspect.java new file mode 100644 index 000000000..801b0a24e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/SpringEventHandlerAspect.java @@ -0,0 +1,36 @@ +package com.gdschongik.gdsc.domain.discord.exception; + +import com.gdschongik.gdsc.global.util.DiscordUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class SpringEventHandlerAspect { + + private final DiscordUtil discordUtil; + + @Around( + "execution(* com.gdschongik.gdsc.domain.discord.application.handler.SpringEventHandler.delegate(*)) && args(ignoredContext)") + public Object doAround(ProceedingJoinPoint joinPoint, Object ignoredContext) throws Throwable { + try { + return joinPoint.proceed(); + } catch (Exception e) { + log.error("[SpringEventHandlerAspect] Exception occurred in SpringEventHandler", e); + sendErrorMessageToDiscord(e); + return null; + } + } + + private void sendErrorMessageToDiscord(Exception e) { + TextChannel channel = discordUtil.getAdminChannel(); + channel.sendMessage(e.getMessage()).queue(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/CommandExceptionHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/CommandExceptionHandler.java similarity index 80% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/CommandExceptionHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/CommandExceptionHandler.java index 17cbc657c..e0c64865c 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/CommandExceptionHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/CommandExceptionHandler.java @@ -1,6 +1,6 @@ -package com.gdschongik.gdsc.global.discord.exception.handler; +package com.gdschongik.gdsc.domain.discord.exception.handler; -import com.gdschongik.gdsc.global.discord.exception.DiscordExceptionMessageGenerator; +import com.gdschongik.gdsc.domain.discord.exception.DiscordExceptionMessageGenerator; import net.dv8tion.jda.api.events.interaction.command.GenericCommandInteractionEvent; public class CommandExceptionHandler implements DiscordExceptionHandler { diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DefaultExceptionHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DefaultExceptionHandler.java similarity index 74% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DefaultExceptionHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DefaultExceptionHandler.java index aa176651e..bd0e2b7d0 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DefaultExceptionHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DefaultExceptionHandler.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord.exception.handler; +package com.gdschongik.gdsc.domain.discord.exception.handler; public class DefaultExceptionHandler implements DiscordExceptionHandler { diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DiscordExceptionHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DiscordExceptionHandler.java similarity index 61% rename from src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DiscordExceptionHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DiscordExceptionHandler.java index 9f0d7226e..575fbc4c9 100644 --- a/src/main/java/com/gdschongik/gdsc/global/discord/exception/handler/DiscordExceptionHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/exception/handler/DiscordExceptionHandler.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.global.discord.exception.handler; +package com.gdschongik.gdsc.domain.discord.exception.handler; public interface DiscordExceptionHandler { diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/JoinCommandHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/handler/JoinCommandHandler.java deleted file mode 100644 index 95de0d674..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/handler/JoinCommandHandler.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.gdschongik.gdsc.domain.discord.handler; - -import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; - -import com.gdschongik.gdsc.domain.discord.application.OnboardingDiscordService; -import com.gdschongik.gdsc.domain.discord.dto.response.DiscordNicknameResponse; -import com.gdschongik.gdsc.global.util.DiscordUtil; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.events.GenericEvent; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class JoinCommandHandler implements DiscordEventHandler { - - private final OnboardingDiscordService onboardingDiscordService; - private final DiscordUtil discordUtil; - - @Override - public void delegate(GenericEvent genericEvent) { - SlashCommandInteractionEvent event = (SlashCommandInteractionEvent) genericEvent; - - event.deferReply().setEphemeral(true).setContent(DEFER_MESSAGE_JOIN).queue(); - - String discordUsername = event.getUser().getName(); - DiscordNicknameResponse response = onboardingDiscordService.checkDiscordRoleAssignable(discordUsername); - - Member member = event.getMember(); - Role role = discordUtil.findRoleByName(MEMBER_ROLE_NAME); - Guild guild = Objects.requireNonNull(event.getGuild()); - - guild.addRoleToMember(member, role).queue(); - guild.modifyNickname(member, response.nickname()).queue(); - - event.getHook().sendMessage(REPLY_MESSAGE_JOIN).setEphemeral(true).queue(); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java b/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java index 894ffa7c0..f48182aa5 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java @@ -1,20 +1,18 @@ package com.gdschongik.gdsc.domain.email.api; -import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFY_EMAIL_REQUEST_PARAMETER_KEY; - import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationLinkSendService; import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationService; import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationLinkSendRequest; +import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationRequest; 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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; 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; @Tag(name = "Univ Email", description = "학교 인증 메일 인증 API입니다.") @@ -35,10 +33,10 @@ public ResponseEntity sendUnivEmailVerificationLink( } @Operation(summary = "학교 인증 메일 인증하기", description = "학교 인증 메일을 인증합니다.") - @GetMapping("/verify-email") + @PatchMapping("/verify-email") public ResponseEntity sendUnivEmailVerificationLink( - @RequestParam(VERIFY_EMAIL_REQUEST_PARAMETER_KEY) String verificationCode) { - univEmailVerificationService.verifyMemberUnivEmail(verificationCode); + @RequestBody @Valid UnivEmailVerificationRequest request) { + univEmailVerificationService.verifyMemberUnivEmail(request); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java index d398625ef..3b0982bd5 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java @@ -3,22 +3,23 @@ import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFICATION_EMAIL_SUBJECT; import com.gdschongik.gdsc.domain.email.dao.UnivEmailVerificationRepository; +import com.gdschongik.gdsc.domain.email.domain.UnivEmailValidator; import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.global.exception.CustomException; -import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import com.gdschongik.gdsc.global.property.JwtProperty; import com.gdschongik.gdsc.global.util.MemberUtil; -import com.gdschongik.gdsc.global.util.email.HongikUnivEmailValidator; +import com.gdschongik.gdsc.global.util.email.EmailVerificationTokenUtil; import com.gdschongik.gdsc.global.util.email.MailSender; -import com.gdschongik.gdsc.global.util.email.VerificationCodeGenerator; import com.gdschongik.gdsc.global.util.email.VerificationLinkUtil; import java.time.Duration; -import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -28,11 +29,13 @@ public class UnivEmailVerificationLinkSendService { private final UnivEmailVerificationRepository univEmailVerificationRepository; private final MailSender mailSender; - private final HongikUnivEmailValidator hongikUnivEmailValidator; - private final VerificationCodeGenerator verificationCodeGenerator; + private final UnivEmailValidator univEmailValidator; + private final EmailVerificationTokenUtil emailVerificationTokenUtil; private final VerificationLinkUtil verificationLinkUtil; private final MemberUtil memberUtil; - public static final Duration VERIFICATION_CODE_TIME_TO_LIVE = Duration.ofMinutes(10); + private final JwtProperty jwtProperty; + + public static final Duration VERIFICATION_TOKEN_TIME_TO_LIVE = Duration.ofMinutes(30); private static final String NOTIFICATION_MESSAGE = """ @@ -47,33 +50,35 @@ public class UnivEmailVerificationLinkSendService { """; public void send(String univEmail) { - hongikUnivEmailValidator.validate(univEmail); - validateUnivEmailNotVerified(univEmail); + boolean isUnivEmailDuplicate = memberRepository.existsByUnivEmail(univEmail); + + univEmailValidator.validateSendUnivEmailVerificationLink(univEmail, isUnivEmailDuplicate); - String verificationCode = verificationCodeGenerator.generate(); - String verificationLink = verificationLinkUtil.createLink(verificationCode); + String verificationToken = generateVerificationToken(univEmail); + String verificationLink = verificationLinkUtil.createLink(verificationToken); String mailContent = writeMailContentWithVerificationLink(verificationLink); + mailSender.send(univEmail, VERIFICATION_EMAIL_SUBJECT, mailContent); - saveUnivEmailVerification(univEmail, verificationCode); + log.info("[UnivEmailVerificationLinkSendService] 학생 인증 메일 발송: univEmail={}", univEmail); } - private void validateUnivEmailNotVerified(String univEmail) { - Optional member = memberRepository.findByUnivEmail(univEmail); - if (member.isPresent()) { - throw new CustomException(ErrorCode.UNIV_EMAIL_ALREADY_VERIFIED); - } - } + private String generateVerificationToken(String univEmail) { + final Member currentMember = memberUtil.getCurrentMember(); + String verificationToken = + emailVerificationTokenUtil.generateEmailVerificationToken(currentMember.getId(), univEmail); - private String writeMailContentWithVerificationLink(String verificationLink) { - return NOTIFICATION_MESSAGE.formatted(VERIFICATION_CODE_TIME_TO_LIVE.toMinutes(), verificationLink); - } - - private void saveUnivEmailVerification(String univEmail, String verificationCode) { - Long currentMemberId = memberUtil.getCurrentMemberId(); - UnivEmailVerification univEmailVerification = new UnivEmailVerification( - verificationCode, univEmail, currentMemberId, VERIFICATION_CODE_TIME_TO_LIVE.toSeconds()); + JwtProperty.TokenProperty emailVerificationTokenProperty = + jwtProperty.getToken().get(JwtConstant.EMAIL_VERIFICATION_TOKEN); + UnivEmailVerification univEmailVerification = UnivEmailVerification.of( + currentMember.getId(), verificationToken, emailVerificationTokenProperty.expirationTime()); univEmailVerificationRepository.save(univEmailVerification); + + return verificationToken; + } + + private String writeMailContentWithVerificationLink(String verificationLink) { + return NOTIFICATION_MESSAGE.formatted(VERIFICATION_TOKEN_TIME_TO_LIVE.toMinutes(), verificationLink); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java index 6cafdca0f..e8c019a20 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java @@ -1,11 +1,16 @@ package com.gdschongik.gdsc.domain.email.application; import com.gdschongik.gdsc.domain.email.dao.UnivEmailVerificationRepository; +import com.gdschongik.gdsc.domain.email.domain.UnivEmailValidator; import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; +import com.gdschongik.gdsc.domain.email.dto.request.EmailVerificationTokenDto; +import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationRequest; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.email.EmailVerificationTokenUtil; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,20 +20,32 @@ @RequiredArgsConstructor public class UnivEmailVerificationService { + private final EmailVerificationTokenUtil emailVerificationTokenUtil; private final MemberRepository memberRepository; private final UnivEmailVerificationRepository univEmailVerificationRepository; + private final UnivEmailValidator univEmailValidator; @Transactional - public void verifyMemberUnivEmail(String verificationCode) { - UnivEmailVerification univEmailVerification = getUnivEmailVerification(verificationCode); - Member member = getMemberById(univEmailVerification.getMemberId()); - member.completeUnivEmailVerification(univEmailVerification.getUnivEmail()); + public void verifyMemberUnivEmail(UnivEmailVerificationRequest request) { + EmailVerificationTokenDto emailVerificationToken = getEmailVerificationToken(request.token()); + Member member = getMemberById(emailVerificationToken.memberId()); + member.completeUnivEmailVerification(emailVerificationToken.email()); + memberRepository.save(member); } - private UnivEmailVerification getUnivEmailVerification(String verificationCode) { - return univEmailVerificationRepository - .findById(verificationCode) - .orElseThrow(() -> new CustomException(ErrorCode.VERIFICATION_CODE_NOT_FOUND)); + public Optional getUnivEmailVerificationFromRedis(Long memberId) { + return univEmailVerificationRepository.findById(memberId); + } + + private EmailVerificationTokenDto getEmailVerificationToken(String verificationToken) { + EmailVerificationTokenDto emailVerificationTokenDto = + emailVerificationTokenUtil.parseEmailVerificationTokenDto(verificationToken); + final Optional univEmailVerification = + getUnivEmailVerificationFromRedis(emailVerificationTokenDto.memberId()); + + univEmailValidator.validateUnivEmailVerification(univEmailVerification, verificationToken); + + return emailVerificationTokenDto; } private Member getMemberById(Long id) { diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java b/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java index b81104cd2..be38eee87 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java @@ -3,4 +3,4 @@ import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; import org.springframework.data.repository.CrudRepository; -public interface UnivEmailVerificationRepository extends CrudRepository {} +public interface UnivEmailVerificationRepository extends CrudRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/domain/EmailVerificationStatusService.java b/src/main/java/com/gdschongik/gdsc/domain/email/domain/EmailVerificationStatusService.java new file mode 100644 index 000000000..9767fbb7e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/domain/EmailVerificationStatusService.java @@ -0,0 +1,21 @@ +package com.gdschongik.gdsc.domain.email.domain; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.dto.UnivVerificationStatus; +import com.gdschongik.gdsc.global.annotation.DomainService; +import java.util.Optional; + +@DomainService +public class EmailVerificationStatusService { + + public UnivVerificationStatus determineStatus( + Member member, Optional univEmailVerification) { + if (member.getAssociateRequirement().isUnivSatisfied()) { + return UnivVerificationStatus.SATISFIED; + } else { + return univEmailVerification.isPresent() + ? UnivVerificationStatus.IN_PROGRESS + : UnivVerificationStatus.PENDING; + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidator.java b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidator.java new file mode 100644 index 000000000..9a73358dd --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidator.java @@ -0,0 +1,43 @@ +package com.gdschongik.gdsc.domain.email.domain; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.HONGIK_UNIV_MAIL_DOMAIN; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.HONGIK_EMAIL; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.Optional; + +@DomainService +public class UnivEmailValidator { + + public void validateSendUnivEmailVerificationLink(String email, boolean isUnivEmailDuplicate) { + if (!email.contains(HONGIK_UNIV_MAIL_DOMAIN)) { + throw new CustomException(UNIV_EMAIL_DOMAIN_MISMATCH); + } + + if (!email.matches(HONGIK_EMAIL)) { + throw new CustomException(UNIV_EMAIL_FORMAT_MISMATCH); + } + + if (isUnivEmailDuplicate) { + throw new CustomException(UNIV_EMAIL_ALREADY_SATISFIED); + } + } + + /** + * redis 안의 존재하는 메일인증 정보로 검증 + * 1. 토큰이 비었는데 인증하려할 시 에러 (인증메일을 보내지 않았거나, 만료된 경우) + * 2. 토큰이 redis에 저장된 토큰과 다르면 만료되었다는 에러 (메일 여러번 보낸 경우) + */ + public void validateUnivEmailVerification( + Optional optionalUnivEmailVerification, String currentToken) { + if (optionalUnivEmailVerification.isEmpty()) { + throw new CustomException(EMAIL_NOT_SENT); + } + + if (!optionalUnivEmailVerification.get().getVerificationToken().equals(currentToken)) { + throw new CustomException(EXPIRED_EMAIL_VERIFICATION_TOKEN); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java index be0c6a6fb..97611b6ed 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java @@ -1,23 +1,36 @@ package com.gdschongik.gdsc.domain.email.domain; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; @Getter -@AllArgsConstructor @RedisHash(value = "univEmailVerification") public class UnivEmailVerification { @Id - private String verificationCode; - - private String univEmail; - private Long memberId; + private String verificationToken; + @TimeToLive - private long timeToLiveInSeconds; + private long ttl; + + @Builder(access = AccessLevel.PRIVATE) + private UnivEmailVerification(Long memberId, String verificationToken, long ttl) { + this.memberId = memberId; + this.verificationToken = verificationToken; + this.ttl = ttl; + } + + public static UnivEmailVerification of(Long memberId, String verificationToken, long ttl) { + return UnivEmailVerification.builder() + .memberId(memberId) + .verificationToken(verificationToken) + .ttl(ttl) + .build(); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/EmailVerificationTokenDto.java b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/EmailVerificationTokenDto.java new file mode 100644 index 000000000..5a4694d83 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/EmailVerificationTokenDto.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.email.dto.request; + +public record EmailVerificationTokenDto(Long memberId, String email) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationRequest.java b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationRequest.java new file mode 100644 index 000000000..115b597b9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationRequest.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.domain.email.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record UnivEmailVerificationRequest( + @NotBlank(message = "이메일 검증 토큰이 비었습니다.") @Schema(description = "이메일 검증 토큰") String token) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/api/AdminMemberController.java b/src/main/java/com/gdschongik/gdsc/domain/member/api/AdminMemberController.java index 28b747dd5..ce504ba80 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/api/AdminMemberController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/api/AdminMemberController.java @@ -1,31 +1,22 @@ package com.gdschongik.gdsc.domain.member.api; import com.gdschongik.gdsc.domain.member.application.AdminMemberService; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; -import com.gdschongik.gdsc.domain.member.dto.request.MemberGrantRequest; -import com.gdschongik.gdsc.domain.member.dto.request.MemberPaymentRequest; +import com.gdschongik.gdsc.domain.member.dto.request.MemberDemoteRequest; import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; import com.gdschongik.gdsc.domain.member.dto.request.MemberUpdateRequest; import com.gdschongik.gdsc.domain.member.dto.response.AdminMemberResponse; -import com.gdschongik.gdsc.domain.member.dto.response.MemberGrantResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.io.IOException; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ContentDisposition; import org.springframework.http.MediaType; 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.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "Admin Member", description = "어드민 회원 관리 API입니다.") @RestController @@ -35,10 +26,11 @@ public class AdminMemberController { private final AdminMemberService adminMemberService; - @Operation(summary = "전체 회원 목록 조회", description = "전체 회원 목록을 조회합니다.") + @Operation(summary = "회원 역할별 목록 조회", description = "정회원, 준회원, 게스트별로 조회합니다.") @GetMapping - public ResponseEntity> getMembers(MemberQueryOption queryOption, Pageable pageable) { - Page response = adminMemberService.findAll(queryOption, pageable); + public ResponseEntity> getMembers( + @ParameterObject MemberQueryOption queryOption, @ParameterObject Pageable pageable) { + Page response = adminMemberService.searchMembers(queryOption, pageable); return ResponseEntity.ok().body(response); } @@ -49,14 +41,6 @@ public ResponseEntity withdrawMember(@PathVariable Long memberId) { return ResponseEntity.ok().build(); } - @Operation(summary = "대기중인 회원 목록 조회", description = "대기중인 회원 목록을 조회합니다.") - @GetMapping("/pending") - public ResponseEntity> getPendingMembers( - MemberQueryOption queryOption, Pageable pageable) { - Page response = adminMemberService.findAllPendingMembers(queryOption, pageable); - return ResponseEntity.ok().body(response); - } - @Operation(summary = "회원 정보 수정", description = "회원 정보를 수정합니다.") @PutMapping("/{memberId}") public ResponseEntity updateMember( @@ -65,48 +49,6 @@ public ResponseEntity updateMember( return ResponseEntity.ok().build(); } - @Operation(summary = "회원 승인", description = "회원의 가입을 승인합니다.") - @PutMapping("/grant") - public ResponseEntity grantMember(@Valid @RequestBody MemberGrantRequest request) { - MemberGrantResponse response = adminMemberService.grantMember(request); - return ResponseEntity.ok().body(response); - } - - @Operation(summary = "승인 가능 회원 전체 조회", description = "승인 가능한 회원 전체를 조회합니다.") - @GetMapping("/grantable") - public ResponseEntity> getGrantableMembers( - MemberQueryOption queryOption, Pageable pageable) { - Page response = adminMemberService.getGrantableMembers(queryOption, pageable); - return ResponseEntity.ok().body(response); - } - - @Operation(summary = "회비 납부 상태에 따른 회원 전체 조회", description = "회비 납부 상태에 따라 회원 목록을 조회합니다.") - @GetMapping("/payment") - public ResponseEntity> getMembersByPaymentStatus( - MemberQueryOption queryOption, - @RequestParam(name = "status", required = false) RequirementStatus paymentStatus, - Pageable pageable) { - Page response = - adminMemberService.getMembersByPaymentStatus(queryOption, paymentStatus, pageable); - return ResponseEntity.ok().body(response); - } - - @Operation(summary = "회비 납부 상태 변경", description = "회비 납부 상태를 변경합니다.") - @PutMapping("/payment/{memberId}") - public ResponseEntity updatePayment( - @PathVariable Long memberId, @Valid @RequestBody MemberPaymentRequest request) { - adminMemberService.updatePaymentStatus(memberId, request); - return ResponseEntity.ok().build(); - } - - @Operation(summary = "승인된 회원 전체 조회", description = "승인된 회원 전체를 조회합니다.") - @GetMapping("/granted") - public ResponseEntity> getGrantedMembers( - MemberQueryOption queryOption, Pageable pageable) { - Page response = adminMemberService.findAllGrantedMembers(queryOption, pageable); - return ResponseEntity.ok().body(response); - } - @Operation(summary = "회원 정보 엑셀 다운로드", description = "회원 정보를 엑셀로 다운로드합니다.") @GetMapping("/excel") public ResponseEntity createWorkbook() throws IOException { @@ -121,4 +63,11 @@ public ResponseEntity createWorkbook() throws IOException { }) .body(response); } + + @Operation(summary = "정회원 일괄 강등", description = "모든 정회원을 준회원으로 일괄 강등합니다. 리쿠르팅 시작 전에 사용합니다.") + @PatchMapping("/demotion") + public ResponseEntity demoteAllMembersToAssociate(MemberDemoteRequest request) { + adminMemberService.demoteAllRegularMembersToAssociate(request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/api/OnboardingMemberController.java b/src/main/java/com/gdschongik/gdsc/domain/member/api/OnboardingMemberController.java index 229f989a9..9bd572657 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/api/OnboardingMemberController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/api/OnboardingMemberController.java @@ -1,9 +1,9 @@ package com.gdschongik.gdsc.domain.member.api; import com.gdschongik.gdsc.domain.member.application.OnboardingMemberService; -import com.gdschongik.gdsc.domain.member.dto.request.MemberSignupRequest; -import com.gdschongik.gdsc.domain.member.dto.request.OnboardingMemberUpdateRequest; -import com.gdschongik.gdsc.domain.member.dto.response.MemberInfoResponse; +import com.gdschongik.gdsc.domain.member.dto.request.BasicMemberInfoRequest; +import com.gdschongik.gdsc.domain.member.dto.response.MemberBasicInfoResponse; +import com.gdschongik.gdsc.domain.member.dto.response.MemberDashboardResponse; import com.gdschongik.gdsc.domain.member.dto.response.MemberUnivStatusResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,7 +12,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,25 +24,10 @@ public class OnboardingMemberController { private final OnboardingMemberService onboardingMemberService; - @Operation(summary = "회원 가입 신청", description = "회원 가입을 신청합니다.") - @PostMapping - public ResponseEntity signupMember(@Valid @RequestBody MemberSignupRequest request) { - onboardingMemberService.signupMember(request); - return ResponseEntity.ok().build(); - } - - @Deprecated - @Operation(summary = "디스코드 회원 정보 수정", description = "디스코드 회원 정보를 수정합니다.") - @PutMapping("/me/discord") - public ResponseEntity updateMember(@Valid @RequestBody OnboardingMemberUpdateRequest request) { - onboardingMemberService.updateMember(request); - return ResponseEntity.ok().build(); - } - - @Operation(summary = "회원 정보 조회", description = "회원 정보를 조회합니다.") - @GetMapping("/me") - public ResponseEntity getMemberInfo() { - MemberInfoResponse response = onboardingMemberService.getMemberInfo(); + @Operation(summary = "내 대시보드 조회", description = "내 대시보드를 조회합니다. 2차 MVP 기능입니다.") + @GetMapping("/me/dashboard") + public ResponseEntity getDashboard() { + MemberDashboardResponse response = onboardingMemberService.getDashboard(); return ResponseEntity.ok().body(response); } @@ -60,4 +44,18 @@ public ResponseEntity linkBevy() { onboardingMemberService.verifyBevyStatus(); return ResponseEntity.ok().build(); } + + @Operation(summary = "기본 회원정보 작성", description = "기본 회원정보를 작성합니다.") + @PostMapping("/me/basic-info") + public ResponseEntity updateBasicMemberInfo(@Valid @RequestBody BasicMemberInfoRequest request) { + onboardingMemberService.updateBasicMemberInfo(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "기본 회원정보 조회", description = "기본 회원정보를 조회합니다.") + @GetMapping("/me/basic-info") + public ResponseEntity getMemberBasicInfo() { + MemberBasicInfoResponse response = onboardingMemberService.getMemberBasicInfo(); + return ResponseEntity.ok().body(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/api/TestMemberController.java b/src/main/java/com/gdschongik/gdsc/domain/member/api/TestMemberController.java new file mode 100644 index 000000000..c16800b27 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/api/TestMemberController.java @@ -0,0 +1,36 @@ +package com.gdschongik.gdsc.domain.member.api; + +import com.gdschongik.gdsc.domain.member.application.AdminMemberService; +import com.gdschongik.gdsc.domain.member.application.OnboardingMemberService; +import com.gdschongik.gdsc.domain.member.dto.request.MemberTokenRequest; +import com.gdschongik.gdsc.domain.member.dto.response.MemberTokenResponse; +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.web.bind.annotation.*; + +@Tag(name = "Test Member", description = "회원 테스트용 API입니다. dev 환경에서만 사용 가능합니다") +@RestController +@RequestMapping("/test/members") +@RequiredArgsConstructor +public class TestMemberController { + + private final OnboardingMemberService onboardingMemberService; + private final AdminMemberService adminMemberService; + + @Operation(summary = "임시 토큰 생성", description = "테스트용 API입니다. oauth_id를 입력받아 해당하는 유저의 토큰을 생성합니다.") + @PostMapping("/token") + public ResponseEntity createTemporaryToken(@Valid @RequestBody MemberTokenRequest request) { + MemberTokenResponse response = onboardingMemberService.createTemporaryToken(request); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "게스트로 강등", description = "테스트용 API입니다. 현재 멤버 역할을 게스트로 강등시키기 위해 사용합니다.") + @PatchMapping("/demotion") + public ResponseEntity demoteToGuest() { + adminMemberService.demoteToGuestAndRegularRequirementToPending(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/AdminMemberService.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/AdminMemberService.java index 243e6917f..573f63c51 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/application/AdminMemberService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/AdminMemberService.java @@ -1,29 +1,33 @@ package com.gdschongik.gdsc.domain.member.application; -import static com.gdschongik.gdsc.domain.member.domain.MemberRole.*; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; -import com.gdschongik.gdsc.domain.member.dto.request.MemberGrantRequest; -import com.gdschongik.gdsc.domain.member.dto.request.MemberPaymentRequest; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberValidator; +import com.gdschongik.gdsc.domain.member.dto.request.MemberDemoteRequest; import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; import com.gdschongik.gdsc.domain.member.dto.request.MemberUpdateRequest; import com.gdschongik.gdsc.domain.member.dto.response.AdminMemberResponse; -import com.gdschongik.gdsc.domain.member.dto.response.MemberGrantResponse; +import com.gdschongik.gdsc.domain.membership.application.MembershipService; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.EnvironmentUtil; import com.gdschongik.gdsc.global.util.ExcelUtil; +import com.gdschongik.gdsc.global.util.MemberUtil; import java.io.IOException; import java.util.List; -import java.util.Map; 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; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -31,9 +35,14 @@ public class AdminMemberService { private final MemberRepository memberRepository; private final ExcelUtil excelUtil; + private final RecruitmentRoundRepository recruitmentRoundRepository; + private final MemberValidator memberValidator; + private final MemberUtil memberUtil; + private final EnvironmentUtil environmentUtil; + private final MembershipService membershipService; - public Page findAll(MemberQueryOption queryOption, Pageable pageable) { - Page members = memberRepository.findAllByRole(queryOption, pageable, null); + public Page searchMembers(MemberQueryOption queryOption, Pageable pageable) { + Page members = memberRepository.searchMembers(queryOption, pageable); return members.map(AdminMemberResponse::from); } @@ -57,42 +66,45 @@ public void updateMember(Long memberId, MemberUpdateRequest request) { request.nickname()); } - public Page findAllPendingMembers(MemberQueryOption queryOption, Pageable pageable) { - Page members = memberRepository.findAllByRole(queryOption, pageable, GUEST); - return members.map(AdminMemberResponse::from); + public byte[] createExcel() throws IOException { + return excelUtil.createMemberExcel(); } @Transactional - public MemberGrantResponse grantMember(MemberGrantRequest request) { - Map> classifiedMember = memberRepository.groupByVerified(request.memberIdList()); - List verifiedMembers = classifiedMember.get(true); - verifiedMembers.forEach(Member::grant); - return MemberGrantResponse.from(classifiedMember); - } + public void demoteAllRegularMembersToAssociate(MemberDemoteRequest request) { + List recruitmentRounds = recruitmentRoundRepository.findAllByAcademicYearAndSemesterType( + request.academicYear(), request.semesterType()); - public Page getGrantableMembers(MemberQueryOption queryOption, Pageable pageable) { - Page members = memberRepository.findAllGrantable(queryOption, pageable); - return members.map(AdminMemberResponse::from); - } + memberValidator.validateMemberDemote(recruitmentRounds); - public Page getMembersByPaymentStatus( - MemberQueryOption queryOption, RequirementStatus paymentStatus, Pageable pageable) { - Page members = memberRepository.findAllByPaymentStatus(queryOption, paymentStatus, pageable); - return members.map(AdminMemberResponse::from); + List regularMembers = memberRepository.findAllByRole(MemberRole.REGULAR); + + regularMembers.forEach(Member::demoteToAssociate); + + memberRepository.saveAll(regularMembers); + + log.info( + "[AdminMemberService] 정회원 일괄 강등: demotedMemberIds={}", + regularMembers.stream().map(Member::getId).toList()); } + /** + * 정회원 조건 PENDING으로 변경, 준회원 조건 PENDING으로 변경 + */ @Transactional - public void updatePaymentStatus(Long memberId, MemberPaymentRequest request) { - Member member = memberRepository.findById(memberId).orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); - member.updatePaymentStatus(request.status()); - } + public void demoteToGuestAndRegularRequirementToPending() { + validateProfile(); + Member member = memberUtil.getCurrentMember(); + member.demoteToGuest(); - public Page findAllGrantedMembers(MemberQueryOption queryOption, Pageable pageable) { - Page members = memberRepository.findAllByRole(queryOption, pageable, USER); - return members.map(AdminMemberResponse::from); + membershipService.deleteMembership(member); + + log.info("[AdminMemberService] 게스트로 강등: demotedMemberId={}", member.getId()); } - public byte[] createExcel() throws IOException { - return excelUtil.createMemberExcel(); + private void validateProfile() { + if (!environmentUtil.isDevAndLocalProfile()) { + throw new CustomException(FORBIDDEN); + } } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java index bde59dc87..61d8b7775 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java @@ -1,24 +1,37 @@ package com.gdschongik.gdsc.domain.member.application; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Department; +import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.domain.member.dto.response.MemberDepartmentResponse; +import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.global.exception.CustomException; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class CommonMemberService { + private final MembershipRepository membershipRepository; + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) public List getDepartments() { return Arrays.stream(Department.values()) .map(MemberDepartmentResponse::from) .toList(); } + @Transactional(readOnly = true) public List searchDepartments(String departmentName) { if (departmentName == null) { return getDepartments(); @@ -29,4 +42,24 @@ public List searchDepartments(String departmentName) { .map(MemberDepartmentResponse::from) .toList(); } + + /** + * 이벤트 핸들러에서 사용되므로, `@Transactional` 을 사용하지 않습니다. + */ + public void advanceMemberToRegularByMembership(Long membershipId) { + Membership membership = membershipRepository + .findById(membershipId) + .orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND)); + + Member member = memberRepository + .findById(membership.getMember().getId()) + .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); + + if (membership.isRegularRequirementAllSatisfied()) { + member.advanceToRegular(); + memberRepository.save(member); + + log.info("[CommonMemberService] 정회원 승급 완료: memberId={}", member.getId()); + } + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberService.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberService.java index e48be9528..75a493e20 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberService.java @@ -2,14 +2,29 @@ import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import com.gdschongik.gdsc.domain.auth.application.JwtService; +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationService; +import com.gdschongik.gdsc.domain.email.domain.EmailVerificationStatusService; +import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.dto.request.MemberSignupRequest; -import com.gdschongik.gdsc.domain.member.dto.request.OnboardingMemberUpdateRequest; -import com.gdschongik.gdsc.domain.member.dto.response.MemberInfoResponse; +import com.gdschongik.gdsc.domain.member.dto.UnivVerificationStatus; +import com.gdschongik.gdsc.domain.member.dto.request.BasicMemberInfoRequest; +import com.gdschongik.gdsc.domain.member.dto.request.MemberTokenRequest; +import com.gdschongik.gdsc.domain.member.dto.response.MemberBasicInfoResponse; +import com.gdschongik.gdsc.domain.member.dto.response.MemberDashboardResponse; +import com.gdschongik.gdsc.domain.member.dto.response.MemberTokenResponse; import com.gdschongik.gdsc.domain.member.dto.response.MemberUnivStatusResponse; +import com.gdschongik.gdsc.domain.membership.application.MembershipService; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.application.OnboardingRecruitmentService; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.EnvironmentUtil; import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,45 +35,68 @@ public class OnboardingMemberService { private final MemberUtil memberUtil; + private final OnboardingRecruitmentService onboardingRecruitmentService; + private final MembershipService membershipService; + private final UnivEmailVerificationService univEmailVerificationService; + private final JwtService jwtService; private final MemberRepository memberRepository; + private final EnvironmentUtil environmentUtil; + private final EmailVerificationStatusService emailVerificationStatusService; - @Transactional - public void signupMember(MemberSignupRequest request) { + public MemberUnivStatusResponse checkUnivVerificationStatus() { Member currentMember = memberUtil.getCurrentMember(); - currentMember.signup( - request.studentId(), request.name(), request.phone(), request.department(), request.email()); + return MemberUnivStatusResponse.from(currentMember); } - @Deprecated @Transactional - public void updateMember(OnboardingMemberUpdateRequest request) { + public void verifyBevyStatus() { Member currentMember = memberUtil.getCurrentMember(); - validateDiscordUsernameDuplicate(currentMember); - currentMember.verifyDiscord(request.discordUsername(), request.nickname()); + currentMember.verifyBevy(); + memberRepository.save(currentMember); } - private void validateDiscordUsernameDuplicate(Member member) { - if (memberRepository.existsByDiscordUsername(member.getDiscordUsername())) { - throw new CustomException(MEMBER_DISCORD_USERNAME_DUPLICATE); - } + @Transactional + public void updateBasicMemberInfo(BasicMemberInfoRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + currentMember.updateBasicMemberInfo( + request.studentId(), request.name(), request.phone(), request.department(), request.email()); + memberRepository.save(currentMember); } - public MemberInfoResponse getMemberInfo() { + public MemberBasicInfoResponse getMemberBasicInfo() { Member currentMember = memberUtil.getCurrentMember(); - if (!currentMember.isApplied()) { - throw new CustomException(MEMBER_NOT_APPLIED); - } - return MemberInfoResponse.of(currentMember); + return MemberBasicInfoResponse.from(currentMember); } - public MemberUnivStatusResponse checkUnivVerificationStatus() { - Member currentMember = memberUtil.getCurrentMember(); - return MemberUnivStatusResponse.from(currentMember); + public MemberDashboardResponse getDashboard() { + final Member member = memberUtil.getCurrentMember(); + final RecruitmentRound currentRecruitmentRound = onboardingRecruitmentService.findCurrentRecruitmentRound(); + final Optional myMembership = membershipService.findMyMembership(member, currentRecruitmentRound); + final Optional univEmailVerification = + univEmailVerificationService.getUnivEmailVerificationFromRedis(member.getId()); + UnivVerificationStatus univVerificationStatus = + emailVerificationStatusService.determineStatus(member, univEmailVerification); + + return MemberDashboardResponse.of( + member, univVerificationStatus, currentRecruitmentRound, myMembership.orElse(null)); } - @Transactional - public void verifyBevyStatus() { - Member currentMember = memberUtil.getCurrentMember(); - currentMember.verifyBevy(); + public MemberTokenResponse createTemporaryToken(MemberTokenRequest request) { + validateProfile(); + + final Member member = memberRepository + .findByOauthId(request.oauthId()) + .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); + + AccessTokenDto accessTokenDto = jwtService.createAccessToken(member.getId(), member.getRole()); + RefreshTokenDto refreshTokenDto = jwtService.createRefreshToken(member.getId()); + + return new MemberTokenResponse(accessTokenDto.tokenValue(), refreshTokenDto.tokenValue()); + } + + private void validateProfile() { + if (!environmentUtil.isDevAndLocalProfile()) { + throw new CustomException(FORBIDDEN); + } } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/handler/MemberAssociateEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/handler/MemberAssociateEventHandler.java new file mode 100644 index 000000000..f66bcf683 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/handler/MemberAssociateEventHandler.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.member.application.handler; + +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberAssociateEvent; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberAssociateEventHandler { + private final MemberRepository memberRepository; + + public void advanceToAssociate(MemberAssociateEvent memberAssociateEvent) { + Member member = memberRepository + .findById(memberAssociateEvent.memberId()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + try { + member.advanceToAssociate(); + } catch (CustomException e) { + log.info("{}", e.getErrorCode()); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/listener/MemberAssociateEventListener.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/listener/MemberAssociateEventListener.java new file mode 100644 index 000000000..1e2e29bc4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/listener/MemberAssociateEventListener.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.domain.member.application.listener; + +import com.gdschongik.gdsc.domain.member.application.handler.MemberAssociateEventHandler; +import com.gdschongik.gdsc.domain.member.domain.MemberAssociateEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MemberAssociateEventListener { + + private final MemberAssociateEventHandler memberAssociateEventHandler; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT, classes = MemberAssociateEvent.class) + public void handleMemberAssociateEvent(MemberAssociateEvent event) { + memberAssociateEventHandler.advanceToAssociate(event); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepository.java index 3d38a932e..b5d6a1077 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepository.java @@ -1,27 +1,19 @@ package com.gdschongik.gdsc.domain.member.dao; +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.domain.member.domain.MemberRole; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; import jakarta.annotation.Nullable; import java.util.List; -import java.util.Map; -import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface MemberCustomRepository { - Optional findNormalByOauthId(String oauthId); - Page findAllGrantable(MemberQueryOption queryOption, Pageable pageable); - - Page findAllByRole(MemberQueryOption queryOption, Pageable pageable, @Nullable MemberRole role); - - Page findAllByPaymentStatus( - MemberQueryOption queryOption, RequirementStatus paymentStatus, Pageable pageable); - - Map> groupByVerified(List memberIdList); + Page searchMembers(MemberQueryOption queryOption, Pageable pageable); List findAllByRole(@Nullable MemberRole role); + + List findAllByDiscordStatus(RequirementStatus discordStatus); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepositoryImpl.java index 4b52752af..b49092474 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepositoryImpl.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberCustomRepositoryImpl.java @@ -1,121 +1,77 @@ package com.gdschongik.gdsc.domain.member.dao; +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.*; import static com.gdschongik.gdsc.domain.member.domain.QMember.*; -import static com.querydsl.core.group.GroupBy.*; +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.domain.member.domain.MemberRole; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; -import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Predicate; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.annotation.Nullable; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; @RequiredArgsConstructor -public class MemberCustomRepositoryImpl extends MemberQueryMethod implements MemberCustomRepository { +public class MemberCustomRepositoryImpl implements MemberCustomRepository, MemberQueryMethod { private final JPAQueryFactory queryFactory; @Override - public Optional findNormalByOauthId(String oauthId) { - return Optional.ofNullable( - queryFactory.selectFrom(member).where(eqOauthId(oauthId)).fetchOne()); - } + public Page searchMembers(MemberQueryOption queryOption, Pageable pageable) { - @Override - public Page findAllGrantable(MemberQueryOption queryOption, Pageable pageable) { - List fetch = queryFactory - .selectFrom(member) - .where(matchesQueryOption(queryOption), eqRole(MemberRole.GUEST), isGrantAvailable()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(member.createdAt.desc()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(member.count()) - .from(member) - .where(matchesQueryOption(queryOption), eqRole(MemberRole.GUEST), isGrantAvailable()); + List ids = getIdsByQueryOption(queryOption, null, member.createdAt.desc()); - return PageableExecutionUtils.getPage(fetch, pageable, countQuery::fetchOne); - } - - @Override - public Page findAllByRole(MemberQueryOption queryOption, Pageable pageable, @Nullable MemberRole role) { List fetch = queryFactory .selectFrom(member) - .where(matchesQueryOption(queryOption), eqRole(role), isStudentIdNotNull()) + .where(member.id.in(ids)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .orderBy(member.createdAt.desc()) .fetch(); - JPAQuery countQuery = queryFactory - .select(member.count()) - .from(member) - .where(matchesQueryOption(queryOption), eqRole(role), isStudentIdNotNull()); - - return PageableExecutionUtils.getPage(fetch, pageable, countQuery::fetchOne); + return PageableExecutionUtils.getPage(fetch, pageable, ids::size); } @Override - public Page findAllByPaymentStatus( - MemberQueryOption queryOption, RequirementStatus paymentStatus, Pageable pageable) { - List fetch = queryFactory + public List findAllByRole(MemberRole role) { + return queryFactory .selectFrom(member) - .where( - matchesQueryOption(queryOption), - eqRequirementStatus(member.requirement.paymentStatus, paymentStatus), - isStudentIdNotNull()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(member.createdAt.desc()) + .where(eqRole(role)) + .orderBy(member.studentId.asc(), member.name.asc()) .fetch(); - - JPAQuery countQuery = queryFactory - .select(member.count()) - .from(member) - .where( - matchesQueryOption(queryOption), - eqRequirementStatus(member.requirement.paymentStatus, paymentStatus), - isStudentIdNotNull()); - - return PageableExecutionUtils.getPage(fetch, pageable, countQuery::fetchOne); } @Override - public Map> groupByVerified(List memberIdList) { - Map> groupByVerified = queryFactory + public List findAllByDiscordStatus(RequirementStatus discordStatus) { + return queryFactory .selectFrom(member) - .where(member.id.in(memberIdList)) - .transform(groupBy(isGrantAvailable()).as(list(member))); - - return replaceNullByEmptyList(groupByVerified); - } - - private Map> replaceNullByEmptyList(Map> groupByVerified) { - Map> classifiedMember = new HashMap<>(); - List emptyList = new ArrayList<>(); - classifiedMember.put(true, groupByVerified.getOrDefault(true, emptyList)); - classifiedMember.put(false, groupByVerified.getOrDefault(false, emptyList)); - return classifiedMember; + .where(eqRequirementStatus(member.associateRequirement.discordStatus, discordStatus)) + .fetch(); } - @Override - public List findAllByRole(MemberRole role) { + /** + * queryOption으로 정렬된 상태로id값들을 가져옵니다. + * 이 id값들로 페이지네이션 content를 조인하는 쿼리 생성시 추가적인 정렬은 없어야하며, 정렬이 필요한경우 해당 함수에 넣어주세요. + * @param queryOption -> 필수 + * @param predicate -> 옵션(추가적인 조건 있을 시) + * @param orderSpecifiers -> 최소 1개 이상 + * @return + */ + private List getIdsByQueryOption( + MemberQueryOption queryOption, + @Nullable Predicate predicate, + @NonNull OrderSpecifier... orderSpecifiers) { return queryFactory - .selectFrom(member) - .where(eqRole(role), isStudentIdNotNull()) - .orderBy(member.studentId.asc(), member.name.asc()) + .select(member.id) + .from(member) + .where(matchesQueryOption(queryOption), predicate) + .orderBy(orderSpecifiers) .fetch(); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberQueryMethod.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberQueryMethod.java index 7de11abf6..588444279 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberQueryMethod.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberQueryMethod.java @@ -1,73 +1,60 @@ package com.gdschongik.gdsc.domain.member.dao; import static com.gdschongik.gdsc.domain.member.domain.QMember.*; -import static com.gdschongik.gdsc.domain.member.domain.RequirementStatus.*; +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; import com.gdschongik.gdsc.domain.member.domain.Department; import com.gdschongik.gdsc.domain.member.domain.MemberRole; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.EnumPath; import java.util.List; -public class MemberQueryMethod { +public interface MemberQueryMethod { - protected BooleanExpression eqRole(MemberRole role) { + default BooleanExpression eqRole(MemberRole role) { return role != null ? member.role.eq(role) : null; } - protected BooleanExpression eqStudentId(String studentId) { + default BooleanExpression eqRoles(List roles) { + return roles != null && !roles.isEmpty() ? member.role.in(roles) : null; + } + + default BooleanExpression eqStudentId(String studentId) { return studentId != null ? member.studentId.containsIgnoreCase(studentId) : null; } - protected BooleanExpression eqName(String name) { + default BooleanExpression eqName(String name) { return name != null ? member.name.containsIgnoreCase(name) : null; } - protected BooleanExpression eqPhone(String phone) { + default BooleanExpression eqPhone(String phone) { return phone != null ? member.phone.contains(phone.replaceAll("-", "")) : null; } - protected BooleanExpression eqEmail(String email) { + default BooleanExpression eqEmail(String email) { return email != null ? member.email.containsIgnoreCase(email) : null; } - protected BooleanExpression eqDiscordUsername(String discordUsername) { + default BooleanExpression eqDiscordUsername(String discordUsername) { return discordUsername != null ? member.discordUsername.containsIgnoreCase(discordUsername) : null; } - protected BooleanExpression eqNickname(String nickname) { + default BooleanExpression eqNickname(String nickname) { return nickname != null ? member.nickname.containsIgnoreCase(nickname) : null; } - protected BooleanExpression eqOauthId(String oauthId) { - return member.oauthId.eq(oauthId); - } - - protected BooleanExpression eqRequirementStatus( + default BooleanExpression eqRequirementStatus( EnumPath requirement, RequirementStatus requirementStatus) { return requirementStatus != null ? requirement.eq(requirementStatus) : null; } - protected BooleanExpression inDepartmentList(List departmentCodes) { + default BooleanExpression inDepartmentList(List departmentCodes) { return departmentCodes.isEmpty() ? null : member.department.in(departmentCodes); } - protected BooleanExpression isStudentIdNotNull() { - return member.studentId.isNotNull(); - } - - protected BooleanBuilder isGrantAvailable() { - return new BooleanBuilder() - .and(eqRequirementStatus(member.requirement.discordStatus, VERIFIED)) - .and(eqRequirementStatus(member.requirement.univStatus, VERIFIED)) - .and(eqRequirementStatus(member.requirement.paymentStatus, VERIFIED)) - .and(eqRequirementStatus(member.requirement.bevyStatus, VERIFIED)); - } - - protected BooleanBuilder matchesQueryOption(MemberQueryOption queryOption) { + default BooleanBuilder matchesQueryOption(MemberQueryOption queryOption) { return new BooleanBuilder() .and(eqStudentId(queryOption.studentId())) .and(eqName(queryOption.name())) @@ -75,6 +62,7 @@ protected BooleanBuilder matchesQueryOption(MemberQueryOption queryOption) { .and(inDepartmentList(Department.searchDepartments(queryOption.department()))) .and(eqEmail(queryOption.email())) .and(eqDiscordUsername(queryOption.discordUsername())) - .and(eqNickname(queryOption.nickname())); + .and(eqNickname(queryOption.nickname())) + .and(eqRoles(queryOption.roles())); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java index 746f7513e..7e9fcdfdf 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java @@ -9,9 +9,9 @@ public interface MemberRepository extends JpaRepository, MemberCus boolean existsByNickname(String nickname); - Optional findByDiscordUsername(String discordUsername); + boolean existsByUnivEmail(String univEmail); - Optional findByUnivEmail(String univEmail); + Optional findByDiscordUsername(String discordUsername); - Optional findByEmail(String email); + Optional findByOauthId(String oauthId); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/AssociateRequirement.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/AssociateRequirement.java new file mode 100644 index 000000000..772d08fa7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/AssociateRequirement.java @@ -0,0 +1,127 @@ +package com.gdschongik.gdsc.domain.member.domain; + +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AssociateRequirement { + + @Enumerated(EnumType.STRING) + private RequirementStatus univStatus; + + @Enumerated(EnumType.STRING) + private RequirementStatus discordStatus; + + @Enumerated(EnumType.STRING) + private RequirementStatus bevyStatus; + + @Enumerated(EnumType.STRING) + private RequirementStatus infoStatus; + + @Builder(access = AccessLevel.PRIVATE) + private AssociateRequirement( + RequirementStatus univStatus, + RequirementStatus discordStatus, + RequirementStatus bevyStatus, + RequirementStatus infoStatus) { + this.univStatus = univStatus; + this.discordStatus = discordStatus; + this.bevyStatus = bevyStatus; + this.infoStatus = infoStatus; + } + + public static AssociateRequirement createRequirement() { + return AssociateRequirement.builder() + .univStatus(PENDING) + .discordStatus(PENDING) + .bevyStatus(PENDING) + .infoStatus(PENDING) + .build(); + } + + // 상태 변경 로직 + + public void verifyUniv() { + univStatus = SATISFIED; + } + + public void verifyDiscord() { + discordStatus = SATISFIED; + } + + public void verifyBevy() { + bevyStatus = SATISFIED; + } + + public void verifyInfo() { + infoStatus = SATISFIED; + } + + // 데이터 전달 로직 + + public boolean isUnivSatisfied() { + return univStatus == SATISFIED; + } + + private boolean isDiscordSatisfied() { + return discordStatus == SATISFIED; + } + + private boolean isBevySatisfied() { + return bevyStatus == SATISFIED; + } + + private boolean isInfoSatisfied() { + return infoStatus == SATISFIED; + } + + // 검증 로직 + + public void validateAllSatisfied() { + if (!isUnivSatisfied()) { + throw new CustomException(UNIV_NOT_SATISFIED); + } + + if (!isDiscordSatisfied()) { + throw new CustomException(DISCORD_NOT_SATISFIED); + } + + if (!isBevySatisfied()) { + throw new CustomException(BEVY_NOT_SATISFIED); + } + + if (!isInfoSatisfied()) { + throw new CustomException(BASIC_INFO_NOT_SATISFIED); + } + } + + public void checkVerifiableUniv() { + if (isUnivSatisfied()) { + throw new CustomException(EMAIL_ALREADY_SATISFIED); + } + } + + /** + * 모든 준회원 조건을 강등합니다. + */ + public void demoteAssociateRequirement() { + bevyStatus = PENDING; + discordStatus = PENDING; + infoStatus = PENDING; + univStatus = PENDING; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java index e6607c02f..c278899c8 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java @@ -3,7 +3,7 @@ import static com.gdschongik.gdsc.domain.member.domain.MemberRole.*; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; -import com.gdschongik.gdsc.domain.common.model.BaseTimeEntity; +import com.gdschongik.gdsc.domain.common.model.BaseEntity; import com.gdschongik.gdsc.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -24,7 +24,7 @@ @Getter @SQLRestriction("status='NORMAL'") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Member extends BaseTimeEntity { +public class Member extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -52,6 +52,8 @@ public class Member extends BaseTimeEntity { private String nickname; + private String discordId; + @Column(nullable = false) private String oauthId; @@ -60,7 +62,7 @@ public class Member extends BaseTimeEntity { private String univEmail; @Embedded - private Requirement requirement; + private AssociateRequirement associateRequirement; @Builder(access = AccessLevel.PRIVATE) private Member( @@ -76,7 +78,7 @@ private Member( String oauthId, LocalDateTime lastLoginAt, String univEmail, - Requirement requirement) { + AssociateRequirement associateRequirement) { this.role = role; this.status = status; this.name = name; @@ -89,103 +91,201 @@ private Member( this.oauthId = oauthId; this.lastLoginAt = lastLoginAt; this.univEmail = univEmail; - this.requirement = requirement; + this.associateRequirement = associateRequirement; } public static Member createGuestMember(String oauthId) { - Requirement requirement = Requirement.createRequirement(); + AssociateRequirement associateRequirement = AssociateRequirement.createRequirement(); return Member.builder() .oauthId(oauthId) - .role(MemberRole.GUEST) + .role(GUEST) .status(MemberStatus.NORMAL) - .requirement(requirement) + .associateRequirement(associateRequirement) .build(); } - // 회원 검증 로직 + // 상태 검증 로직 /** * 회원 상태를 변경할 수 있는지 검증합니다. 삭제되거나 차단된 회원은 상태를 변경할 수 없습니다.
* 대부분의 상태 변경 로직에서 사용됩니다. */ private void validateStatusUpdatable() { - if (this.status.isDeleted()) { + if (status.isDeleted()) { throw new CustomException(MEMBER_DELETED); } - if (this.status.isForbidden()) { + if (status.isForbidden()) { throw new CustomException(MEMBER_FORBIDDEN); } } /** - * 재학생 인증 여부를 검증합니다. + * 준회원 승급 가능 여부를 검증합니다. */ - private void validateUnivStatus() { - if (this.requirement.isUnivVerified() && this.univEmail != null) { - return; + private void validateAssociateAvailable() { + if (role.equals(ASSOCIATE)) { + throw new CustomException(MEMBER_ALREADY_ASSOCIATE); } - throw new CustomException(UNIV_NOT_VERIFIED); + associateRequirement.validateAllSatisfied(); } /** - * 회원 승인 가능 여부를 검증합니다. + * 정회원 승급 가능 여부를 검증합니다. */ - private void validateGrantAvailable() { - if (isGranted()) { - throw new CustomException(MEMBER_ALREADY_GRANTED); - } - - if (!this.requirement.isPaymentVerified()) { - throw new CustomException(PAYMENT_NOT_VERIFIED); + private void validateRegularAvailable() { + if (isRegular()) { + throw new CustomException(MEMBER_ALREADY_REGULAR); } - if (!this.requirement.isDiscordVerified() || this.discordUsername == null || this.nickname == null) { - throw new CustomException(DISCORD_NOT_VERIFIED); + if (!role.equals(ASSOCIATE)) { + throw new CustomException(MEMBER_NOT_ASSOCIATE); } - - if (!this.requirement.isBevyVerified()) { - throw new CustomException(BEVY_NOT_VERIFIED); - } - - validateUnivStatus(); } - // 회원 가입상태 변경 로직 + // 준회원 승급 관련 로직 /** - * 가입 신청 시 작성한 정보를 저장합니다. 재학생 인증을 완료한 회원만 신청할 수 있습니다. + * 기본 회원 정보를 작성합니다. + * 기본정보 인증상태를 인증 처리합니다. */ - public void signup(String studentId, String name, String phone, Department department, String email) { + public void updateBasicMemberInfo( + String studentId, String name, String phone, Department department, String email) { validateStatusUpdatable(); - validateUnivStatus(); this.studentId = studentId; this.name = name; this.phone = phone; this.department = department; this.email = email; + + associateRequirement.verifyInfo(); + + registerEvent(new MemberAssociateEvent(this.id)); + } + + /** + * 재학생 이메일 인증을 진행합니다. + * 재학생 이메일 인증상태를 인증 처리합니다. + */ + public void completeUnivEmailVerification(String univEmail) { + validateStatusUpdatable(); + + // 이미 인증되어있으면 에러 + associateRequirement.checkVerifiableUniv(); + + this.univEmail = univEmail; + + associateRequirement.verifyUniv(); + + registerEvent(new MemberAssociateEvent(this.id)); + } + + /** + * 디스코드 서버와의 연동을 진행합니다. + * 디스코드 인증상태를 인증 처리합니다. + */ + public void verifyDiscord(String discordUsername, String nickname) { + validateStatusUpdatable(); + + this.discordUsername = discordUsername; + this.nickname = nickname; + + associateRequirement.verifyDiscord(); + + registerEvent(new MemberAssociateEvent(this.id)); + } + + /** + * Bevy 서버와의 연동을 진행합니다. + * Bevy 인증상태를 인증 처리합니다. + */ + public void verifyBevy() { + validateStatusUpdatable(); + + associateRequirement.verifyBevy(); + + registerEvent(new MemberAssociateEvent(id)); + } + + /** + * 게스트에서 준회원으로 승급합니다. + * 본 로직은 승급조건 충족 이벤트로 트리거됩니다. 다음 조건을 모두 충족하면 승급됩니다. + * 조건 1 : 기본 회원정보 작성 + * 조건 2 : 재학생 인증 + * 조건 3 : 디스코드 인증 + * 조건 4 : Bevy 인증 + */ + public void advanceToAssociate() { + validateStatusUpdatable(); + + validateAssociateAvailable(); + + role = ASSOCIATE; + } + + /** + * 준회원에서 정회원으로 승급합니다. + * 조건 1 : 멤버가 준회원이어야 함 + */ + public void advanceToRegular() { + validateStatusUpdatable(); + + validateRegularAvailable(); + + role = REGULAR; + + registerEvent(new MemberAdvancedToRegularEvent(id, discordId)); } /** - * 가입 신청을 승인합니다.
- * 어드민만 사용할 수 있어야 합니다. + * 정회원에서 준회원으로 강등합니다. */ - public void grant() { + public void demoteToAssociate() { validateStatusUpdatable(); - validateGrantAvailable(); - this.role = USER; + role = ASSOCIATE; + } + + /** + * 테스트 환경 구성을 위한 사용자 상태 변경 메소드 + * 1. 멤버 역할을 GUEST로 강등 + * 2. 준회원 가입 조건을 'PENDING'으로 변경 + */ + public void demoteToGuest() { + role = GUEST; + + univEmail = null; + name = null; + department = null; + studentId = null; + phone = null; + + discordId = null; + nickname = null; + discordUsername = null; + + associateRequirement.demoteAssociateRequirement(); + } + + // 기타 상태 변경 로직 + + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void updateDiscordId(String discordId) { + this.discordId = discordId; } /** * 해당 회원을 탈퇴 처리합니다. */ public void withdraw() { - if (this.status.isDeleted()) { + if (status.isDeleted()) { throw new CustomException(MEMBER_DELETED); } - this.status = MemberStatus.DELETED; + status = MemberStatus.DELETED; } /** @@ -210,60 +310,16 @@ public void updateMemberInfo( this.nickname = nickname; } - public void completeUnivEmailVerification(String univEmail) { - this.univEmail = univEmail; - requirement.updateUnivStatus(RequirementStatus.VERIFIED); - } - - // 가입조건 인증 로직 - public void verifyDiscord(String discordUsername, String nickname) { - validateStatusUpdatable(); - - this.requirement.verifyDiscord(); - this.discordUsername = discordUsername; - this.nickname = nickname; - } - - public void updatePaymentStatus(RequirementStatus status) { - validateStatusUpdatable(); - this.requirement.updatePaymentStatus(status); - } - - public void verifyBevy() { - validateStatusUpdatable(); - this.requirement.verifyBevy(); - } - // 데이터 전달 로직 - - public boolean isGranted() { - return role.equals(USER) || role.equals(MemberRole.ADMIN); - } - - /** - * 회원 승인 가능 여부를 반환합니다. - * - * @see com.gdschongik.gdsc.domain.member.dao.MemberQueryMethod#isGrantAvailable() - */ - public boolean isGrantAvailable() { - try { - validateGrantAvailable(); - return true; - } catch (CustomException e) { - return false; - } + public boolean isGuest() { + return role.equals(GUEST); } - /** - * 가입 신청서 제출 여부를 반환합니다. - */ - public boolean isApplied() { - return studentId != null; + public boolean isAssociate() { + return role.equals(ASSOCIATE); } - // 기타 로직 - - public void updateLastLoginAt() { - this.lastLoginAt = LocalDateTime.now(); + public boolean isRegular() { + return role.equals(REGULAR); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAdvancedToRegularEvent.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAdvancedToRegularEvent.java new file mode 100644 index 000000000..d78bde16b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAdvancedToRegularEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.member.domain; + +public record MemberAdvancedToRegularEvent(Long memberId, String discordId) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAssociateEvent.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAssociateEvent.java new file mode 100644 index 000000000..d351add94 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberAssociateEvent.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.member.domain; + +public record MemberAssociateEvent(Long memberId) { + // TODO: 적절한 이름을 갖도록 수정 +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberRole.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberRole.java index ad8d4d6e0..225332638 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberRole.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberRole.java @@ -7,7 +7,8 @@ @AllArgsConstructor public enum MemberRole { GUEST("ROLE_GUEST"), - USER("ROLE_USER"), + ASSOCIATE("ROLE_ASSOCIATE"), + REGULAR("ROLE_REGULAR"), ADMIN("ROLE_ADMIN"); private final String value; diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberValidator.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberValidator.java new file mode 100644 index 000000000..50b2fe170 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberValidator.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.member.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.List; + +@DomainService +public class MemberValidator { + + public void validateMemberDemote(List recruitmentRounds) { + + // 해당 학기에 모집회차가 존재하는지 검증 + if (recruitmentRounds.isEmpty()) { + throw new CustomException(RECRUITMENT_ROUND_NOT_FOUND); + } + + // 해당 학기의 모든 모집회차가 아직 시작되지 않았는지 검증 + recruitmentRounds.forEach(RecruitmentRound::validatePeriodNotStarted); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java deleted file mode 100644 index 1a6891ba1..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.gdschongik.gdsc.domain.member.domain; - -import static com.gdschongik.gdsc.domain.member.domain.RequirementStatus.*; - -import jakarta.persistence.Embeddable; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Embeddable -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Requirement { - - @Enumerated(EnumType.STRING) - private RequirementStatus univStatus; - - @Enumerated(EnumType.STRING) - private RequirementStatus discordStatus; - - @Enumerated(EnumType.STRING) - private RequirementStatus paymentStatus; - - @Enumerated(EnumType.STRING) - private RequirementStatus bevyStatus; - - @Builder(access = AccessLevel.PRIVATE) - private Requirement( - RequirementStatus univStatus, - RequirementStatus discordStatus, - RequirementStatus paymentStatus, - RequirementStatus bevyStatus) { - this.univStatus = univStatus; - this.discordStatus = discordStatus; - this.paymentStatus = paymentStatus; - this.bevyStatus = bevyStatus; - } - - public static Requirement createRequirement() { - return Requirement.builder() - .univStatus(PENDING) - .discordStatus(PENDING) - .paymentStatus(PENDING) - .bevyStatus(PENDING) - .build(); - } - - public void updateUnivStatus(RequirementStatus univStatus) { - this.univStatus = univStatus; - } - - public void updatePaymentStatus(RequirementStatus status) { - this.paymentStatus = status; - } - - public void verifyDiscord() { - this.discordStatus = VERIFIED; - } - - public void verifyBevy() { - this.bevyStatus = VERIFIED; - } - - public boolean isUnivVerified() { - return this.univStatus == VERIFIED; - } - - public boolean isDiscordVerified() { - return this.discordStatus == VERIFIED; - } - - public boolean isPaymentVerified() { - return this.paymentStatus == VERIFIED; - } - - public boolean isBevyVerified() { - return this.bevyStatus == VERIFIED; - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberDto.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberDto.java new file mode 100644 index 000000000..c547c2bd9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberDto.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.domain.member.dto; + +import com.gdschongik.gdsc.domain.member.domain.Member; + +public record MemberDto(Long memberId, String studentId, String name, String email, String phone) { + public static MemberDto from(Member member) { + return new MemberDto( + member.getId(), member.getStudentId(), member.getName(), member.getEmail(), member.getPhone()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberFullDto.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberFullDto.java new file mode 100644 index 000000000..abfc4b842 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberFullDto.java @@ -0,0 +1,64 @@ +package com.gdschongik.gdsc.domain.member.dto; + +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; +import com.gdschongik.gdsc.domain.member.domain.Department; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.util.formatter.PhoneFormatter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Optional; + +public record MemberFullDto( + Long memberId, + @Schema(description = "멤버 역할", implementation = MemberRole.class) MemberRole role, + @Schema(description = "회원정보", implementation = MemberBasicInfoDto.class) MemberBasicInfoDto basicInfo, + @Schema(description = "인증상태정보", implementation = MemberAssociateRequirementDto.class) + MemberAssociateRequirementDto associateRequirement) { + public static MemberFullDto of(Member member, UnivVerificationStatus univVerificationStatus) { + return new MemberFullDto( + member.getId(), + member.getRole(), + MemberBasicInfoDto.from(member), + MemberAssociateRequirementDto.of(member, univVerificationStatus)); + } + + record MemberBasicInfoDto( + String name, + String studentId, + String email, + String department, + String phone, + String discordUsername, + String nickname) { + public static MemberBasicInfoDto from(Member member) { + return new MemberBasicInfoDto( + member.getName(), + member.getStudentId(), + member.getEmail(), + Optional.ofNullable(member.getDepartment()) + .map(Department::getDepartmentName) + .orElse(null), + Optional.ofNullable(member.getPhone()) + .map(PhoneFormatter::format) + .orElse(null), + member.getDiscordUsername(), + member.getNickname()); + } + } + + public record MemberAssociateRequirementDto( + @Schema(description = "학교메일 인증상태", implementation = UnivVerificationStatus.class) + UnivVerificationStatus univStatus, + @Schema(description = "디스코드 인증상태", implementation = RequirementStatus.class) + RequirementStatus discordStatus, + @Schema(description = "bevy 인증상태", implementation = RequirementStatus.class) RequirementStatus bevyStatus, + @Schema(description = "회원정보 입력상태", implementation = RequirementStatus.class) RequirementStatus infoStatus) { + public static MemberAssociateRequirementDto of(Member member, UnivVerificationStatus univVerificationStatus) { + return new MemberAssociateRequirementDto( + univVerificationStatus, + member.getAssociateRequirement().getDiscordStatus(), + member.getAssociateRequirement().getBevyStatus(), + member.getAssociateRequirement().getInfoStatus()); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/UnivVerificationStatus.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/UnivVerificationStatus.java new file mode 100644 index 000000000..7152df0af --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/UnivVerificationStatus.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UnivVerificationStatus { + PENDING("PENDING"), + IN_PROGRESS("IN_PROGRESS"), + SATISFIED("SATISFIED"); + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberSignupRequest.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/BasicMemberInfoRequest.java similarity index 88% rename from src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberSignupRequest.java rename to src/main/java/com/gdschongik/gdsc/domain/member/dto/request/BasicMemberInfoRequest.java index 5543c4375..fd609dcf4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberSignupRequest.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/BasicMemberInfoRequest.java @@ -1,6 +1,7 @@ package com.gdschongik.gdsc.domain.member.dto.request; -import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.PHONE_WITHOUT_HYPHEN; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.STUDENT_ID; import com.gdschongik.gdsc.domain.member.domain.Department; import io.swagger.v3.oas.annotations.media.Schema; @@ -9,7 +10,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -public record MemberSignupRequest( +public record BasicMemberInfoRequest( @NotBlank @Pattern(regexp = STUDENT_ID, message = "학번은 " + STUDENT_ID + " 형식이어야 합니다.") @Schema(description = "학번", pattern = STUDENT_ID) diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberDemoteRequest.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberDemoteRequest.java new file mode 100644 index 000000000..6907388ad --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberDemoteRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.member.dto.request; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; + +public record MemberDemoteRequest(Integer academicYear, SemesterType semesterType) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberGrantRequest.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberGrantRequest.java deleted file mode 100644 index 8c10ac820..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberGrantRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.gdschongik.gdsc.domain.member.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -public record MemberGrantRequest(@Schema(description = "승인할 멤버 ID 리스트") List memberIdList) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberPaymentRequest.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberPaymentRequest.java deleted file mode 100644 index ad4843ea0..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberPaymentRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.gdschongik.gdsc.domain.member.dto.request; - -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; -import io.swagger.v3.oas.annotations.media.Schema; - -public record MemberPaymentRequest(@Schema(description = "변경할 상태") RequirementStatus status) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberQueryOption.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberQueryOption.java index 26daf58e2..191b99014 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberQueryOption.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberQueryOption.java @@ -1,14 +1,15 @@ package com.gdschongik.gdsc.domain.member.dto.request; -import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; - +import com.gdschongik.gdsc.domain.member.domain.MemberRole; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; public record MemberQueryOption( - @Schema(description = "학번", pattern = STUDENT_ID) String studentId, + @Schema(description = "학번") String studentId, @Schema(description = "이름") String name, - @Schema(description = "전화번호", pattern = PHONE_WITHOUT_HYPHEN) String phone, + @Schema(description = "전화번호") String phone, @Schema(description = "학과") String department, @Schema(description = "이메일") String email, @Schema(description = "디스코드 유저네임") String discordUsername, - @Schema(description = "커뮤니티 닉네임", pattern = NICKNAME) String nickname) {} + @Schema(description = "커뮤니티 닉네임") String nickname, + @Schema(description = "멤버 권한") List roles) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberTokenRequest.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberTokenRequest.java new file mode 100644 index 000000000..b108a1181 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/request/MemberTokenRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.member.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record MemberTokenRequest(@NotBlank String oauthId) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/AdminMemberResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/AdminMemberResponse.java index 312a6f216..434921dc1 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/AdminMemberResponse.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/AdminMemberResponse.java @@ -1,8 +1,9 @@ package com.gdschongik.gdsc.domain.member.dto.response; +import com.gdschongik.gdsc.domain.member.domain.AssociateRequirement; import com.gdschongik.gdsc.domain.member.domain.Department; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.Requirement; +import com.gdschongik.gdsc.global.util.formatter.PhoneFormatter; import java.util.Optional; public record AdminMemberResponse( @@ -21,15 +22,12 @@ public static AdminMemberResponse from(Member member) { member.getId(), member.getStudentId(), member.getName(), - Optional.ofNullable(member.getPhone()) - .map(phone -> String.format( - "%s-%s-%s", phone.substring(0, 3), phone.substring(3, 7), phone.substring(7))) - .orElse(null), + PhoneFormatter.format(member.getPhone()), DepartmentDto.from(member.getDepartment()), member.getEmail(), member.getDiscordUsername(), member.getNickname(), - RequirementDto.from(member.getRequirement())); + RequirementDto.from(member.getAssociateRequirement())); } record DepartmentDto(Department code, String name) { @@ -40,13 +38,12 @@ public static DepartmentDto from(Department department) { } } - record RequirementDto(String univStatus, String discordStatus, String paymentStatus, String bevyStatus) { - public static RequirementDto from(Requirement requirement) { + record RequirementDto(String univStatus, String discordStatus, String bevyStatus) { + public static RequirementDto from(AssociateRequirement associateRequirement) { return new RequirementDto( - requirement.getUnivStatus().name(), - requirement.getDiscordStatus().name(), - requirement.getPaymentStatus().name(), - requirement.getBevyStatus().name()); + associateRequirement.getUnivStatus().name(), + associateRequirement.getDiscordStatus().name(), + associateRequirement.getBevyStatus().name()); } } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberBasicInfoResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberBasicInfoResponse.java new file mode 100644 index 000000000..986082d10 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberBasicInfoResponse.java @@ -0,0 +1,30 @@ +package com.gdschongik.gdsc.domain.member.dto.response; + +import com.gdschongik.gdsc.domain.member.domain.Department; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.util.formatter.PhoneFormatter; +import java.util.Optional; + +public record MemberBasicInfoResponse( + Long memberId, + String studentId, + String name, + String phone, + String department, + String email, + String discordUsername, + String nickname) { + public static MemberBasicInfoResponse from(Member member) { + return new MemberBasicInfoResponse( + member.getId(), + member.getStudentId(), + member.getName(), + PhoneFormatter.format(member.getPhone()), + Optional.ofNullable(member.getDepartment()) + .map(Department::getDepartmentName) + .orElse(null), + member.getEmail(), + member.getDiscordUsername(), + member.getNickname()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberDashboardResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberDashboardResponse.java new file mode 100644 index 000000000..ca88b1b23 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberDashboardResponse.java @@ -0,0 +1,26 @@ +package com.gdschongik.gdsc.domain.member.dto.response; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.dto.MemberFullDto; +import com.gdschongik.gdsc.domain.member.dto.UnivVerificationStatus; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.membership.dto.MembershipFullDto; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.dto.RecruitmentRoundFullDto; +import jakarta.annotation.Nullable; + +public record MemberDashboardResponse( + MemberFullDto member, + RecruitmentRoundFullDto currentRecruitmentRound, + @Nullable MembershipFullDto currentMembership) { + public static MemberDashboardResponse of( + Member member, + UnivVerificationStatus univVerificationStatus, + RecruitmentRound currentRecruitmentRound, + Membership currentMembership) { + return new MemberDashboardResponse( + MemberFullDto.of(member, univVerificationStatus), + RecruitmentRoundFullDto.from(currentRecruitmentRound), + currentMembership == null ? null : MembershipFullDto.from(currentMembership)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberGrantResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberGrantResponse.java deleted file mode 100644 index 818131740..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberGrantResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.gdschongik.gdsc.domain.member.dto.response; - -import com.gdschongik.gdsc.domain.member.domain.Member; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import java.util.Map; - -public record MemberGrantResponse( - @Schema(description = "승인에 성공한 멤버 이름 리스트") List grantedMembers, - @Schema(description = "승인에 실패한 멤버 이름 리스트") List notGrantedMembers) { - public static MemberGrantResponse from(Map> grantResult) { - List grantedMemberIdList = - grantResult.get(true).stream().map(Member::getName).toList(); - List notGrantedMemberIdList = - grantResult.get(false).stream().map(Member::getName).toList(); - return new MemberGrantResponse(grantedMemberIdList, notGrantedMemberIdList); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberInfoResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberInfoResponse.java deleted file mode 100644 index ccb70751a..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberInfoResponse.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.gdschongik.gdsc.domain.member.dto.response; - -import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.MemberRole; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; -import io.swagger.v3.oas.annotations.media.Schema; - -public record MemberInfoResponse( - Long memberId, - String studentId, - String name, - String phone, - String department, - String email, - String discordUsername, - String nickname, - @Schema(description = "회비 입금 상태") RequirementStatus paymentStatus, - @Schema(description = "디스코드 연동 상태") RequirementStatus discordStatus, - @Schema(description = "GDSC Bevy 가입 상태") RequirementStatus bevyStatus, - @Schema(description = "가입 상태") MemberRole role, - @Schema(description = "입금자명") String depositorName, - @Schema(description = "가입 상태") RegistrationStatus registrationStatus) { - - public static MemberInfoResponse of(Member member) { - return new MemberInfoResponse( - member.getId(), - member.getStudentId(), - member.getName(), - String.format( - "%s-%s-%s", - member.getPhone().substring(0, 3), - member.getPhone().substring(3, 7), - member.getPhone().substring(7)), - member.getDepartment().getDepartmentName(), - member.getEmail(), - member.getDiscordUsername(), - member.getNickname(), - member.getRequirement().getPaymentStatus(), - member.getRequirement().getDiscordStatus(), - member.getRequirement().getBevyStatus(), - member.getRole(), - String.format("%s%s", member.getName(), member.getPhone().substring(7)), - RegistrationStatus.from(member)); - } - - enum RegistrationStatus { - APPLIED, - PENDING, - GRANTED; - - static RegistrationStatus from(Member member) { - if (member.isGranted()) { - return GRANTED; - } - if (member.isGrantAvailable()) { - return PENDING; - } - return APPLIED; - } - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberPaymentResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberPaymentResponse.java deleted file mode 100644 index f0a65ccc9..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberPaymentResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.gdschongik.gdsc.domain.member.dto.response; - -import com.gdschongik.gdsc.domain.member.domain.Member; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -public record MemberPaymentResponse( - @Schema(description = "회비 납부 처리에 성공한 멤버 ID 리스트") List paymentVerifiedMemberIdList) { - public static MemberPaymentResponse from(List paymentVerifiedMembers) { - List paymentVerifiedMemberIdList = - paymentVerifiedMembers.stream().map(Member::getId).toList(); - return new MemberPaymentResponse(paymentVerifiedMemberIdList); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberTokenResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberTokenResponse.java new file mode 100644 index 000000000..c25ad0681 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberTokenResponse.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MemberTokenResponse( + @Schema(description = "액세스 토큰") String accessToken, @Schema(description = "리프레쉬 토큰") String refreshToken) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberUnivStatusResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberUnivStatusResponse.java index a425432b0..d7ae148d2 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberUnivStatusResponse.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberUnivStatusResponse.java @@ -1,11 +1,11 @@ package com.gdschongik.gdsc.domain.member.dto.response; +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.RequirementStatus; import io.swagger.v3.oas.annotations.media.Schema; public record MemberUnivStatusResponse(@Schema(description = "재학생 인증 완료 여부") RequirementStatus univStatus) { public static MemberUnivStatusResponse from(Member member) { - return new MemberUnivStatusResponse(member.getRequirement().getUnivStatus()); + return new MemberUnivStatusResponse(member.getAssociateRequirement().getUnivStatus()); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/api/MembershipController.java b/src/main/java/com/gdschongik/gdsc/domain/membership/api/MembershipController.java new file mode 100644 index 000000000..eed24dc0d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/api/MembershipController.java @@ -0,0 +1,27 @@ +package com.gdschongik.gdsc.domain.membership.api; + +import com.gdschongik.gdsc.domain.membership.application.MembershipService; +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 = "Membership", description = "멤버십 API입니다.") +@RestController +@RequestMapping("/membership") +@RequiredArgsConstructor +public class MembershipController { + + private final MembershipService membershipService; + + @Operation(summary = "멤버십 가입 신청 접수", description = "정회원 가입을 위해 멤버십 가입 신청을 접수합니다. 별도의 정회원 가입 조건을 만족해야 가입이 완료됩니다.") + @PostMapping + public ResponseEntity submitMembership(@RequestParam(name = "recruitmentRoundId") Long recruitmentRoundId) { + membershipService.submitMembership(recruitmentRoundId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipService.java b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipService.java new file mode 100644 index 000000000..bc24e7f0c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipService.java @@ -0,0 +1,100 @@ +package com.gdschongik.gdsc.domain.membership.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.membership.domain.MembershipValidator; +import com.gdschongik.gdsc.domain.order.dao.OrderRepository; +import com.gdschongik.gdsc.domain.order.domain.Order; +import com.gdschongik.gdsc.domain.recruitment.application.OnboardingRecruitmentService; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MembershipService { + + private final OrderRepository orderRepository; + private final MembershipRepository membershipRepository; + private final RecruitmentRoundRepository recruitmentRoundRepository; + private final MemberUtil memberUtil; + private final MembershipValidator membershipValidator; + private final OnboardingRecruitmentService onboardingRecruitmentService; + + /** + * 이벤트 핸들러에서 사용되므로, `@Transactional` 을 사용하지 않습니다. + */ + public void verifyPaymentStatus(String orderNanoId) { + Long membershipId = orderRepository + .findByNanoId(orderNanoId) + .map(Order::getMembershipId) + .orElseThrow(() -> new CustomException(ORDER_NOT_FOUND)); + + findMembershipAndVerifyPayment(membershipId); + } + + @Transactional + public void verifyPaymentStatus(Long membershipId) { + findMembershipAndVerifyPayment(membershipId); + } + + private void findMembershipAndVerifyPayment(Long membershipId) { + Membership currentMembership = membershipRepository + .findById(membershipId) + .orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND)); + + currentMembership.verifyPaymentStatus(); + + membershipRepository.save(currentMembership); + + log.info("[MembershipService] 멤버십 회비납입 인증 완료: membershipId={}", currentMembership.getId()); + } + + @Transactional + public void submitMembership(Long recruitmentRoundId) { + Member currentMember = memberUtil.getCurrentMember(); + + RecruitmentRound recruitmentRound = recruitmentRoundRepository + .findById(recruitmentRoundId) + .orElseThrow(() -> new CustomException(RECRUITMENT_ROUND_NOT_FOUND)); + + boolean isMembershipAlreadySubmitted = + membershipRepository.existsByMemberAndRecruitment(currentMember, recruitmentRound.getRecruitment()); + + membershipValidator.validateMembershipSubmit(currentMember, recruitmentRound, isMembershipAlreadySubmitted); + + Membership membership = Membership.createMembership(currentMember, recruitmentRound); + membershipRepository.save(membership); + + log.info("[MembershipService] 멤버십 가입 신청 접수: membershipId = {}", membership.getId()); + } + + public Optional findMyMembership(Member member, RecruitmentRound recruitmentRound) { + return membershipRepository.findByMemberAndRecruitmentRound(member, recruitmentRound); + } + + public void deleteMembership(Member member) { + Optional currentRecruitmentRoundOpt = + onboardingRecruitmentService.findCurrentRecruitmentRoundToDemote(); + + if (!currentRecruitmentRoundOpt.isPresent()) { + return; + } + + RecruitmentRound currentRecruitmentRound = currentRecruitmentRoundOpt.get(); + Optional myMembershipOpt = findMyMembership(member, currentRecruitmentRound); + + myMembershipOpt.ifPresent(membershipRepository::delete); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java new file mode 100644 index 000000000..4580949d3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.domain.membership.application; + +import com.gdschongik.gdsc.domain.member.application.CommonMemberService; +import com.gdschongik.gdsc.domain.membership.domain.MembershipVerifiedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MembershipVerifiedEventHandler { + + private final CommonMemberService commonMemberService; + + @EventListener + public void handleMembershipVerifiedEvent(MembershipVerifiedEvent event) { + log.info("[MembershipVerifiedEventHandler] 멤버십 인증 이벤트 수신: membershipId={}", event.membershipId()); + commonMemberService.advanceMemberToRegularByMembership(event.membershipId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepository.java new file mode 100644 index 000000000..c1eea6740 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepository.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.membership.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; + +public interface MembershipCustomRepository { + + boolean existsByMemberAndRecruitment(Member member, Recruitment recruitment); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepositoryImpl.java new file mode 100644 index 000000000..9ccce0a56 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipCustomRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.gdschongik.gdsc.domain.membership.dao; + +import static com.gdschongik.gdsc.domain.membership.domain.QMembership.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MembershipCustomRepositoryImpl implements MembershipCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public boolean existsByMemberAndRecruitment(Member member, Recruitment recruitment) { + Integer fetchOne = queryFactory + .selectOne() + .from(membership) + .where(eqMember(member), eqRecruitment(recruitment)) + .fetchFirst(); + + return fetchOne != null; + } + + private BooleanExpression eqMember(Member member) { + return member != null ? membership.member.eq(member) : null; + } + + private BooleanExpression eqRecruitment(Recruitment recruitment) { + return recruitment != null ? membership.recruitmentRound.recruitment.eq(recruitment) : null; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipRepository.java b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipRepository.java new file mode 100644 index 000000000..bc7868ca3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/dao/MembershipRepository.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.domain.membership.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MembershipRepository extends JpaRepository, MembershipCustomRepository { + + Optional findByMemberAndRecruitmentRound(Member member, RecruitmentRound recruitmentRound); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/Membership.java b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/Membership.java new file mode 100644 index 000000000..fdad73bb7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/Membership.java @@ -0,0 +1,83 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Membership extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "membership_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recruitment_round_id") + private RecruitmentRound recruitmentRound; + + @Embedded + private RegularRequirement regularRequirement; + + @Builder(access = AccessLevel.PRIVATE) + private Membership(Member member, RecruitmentRound recruitmentRound, RegularRequirement regularRequirement) { + this.member = member; + this.recruitmentRound = recruitmentRound; + this.regularRequirement = regularRequirement; + } + + public static Membership createMembership(Member member, RecruitmentRound recruitmentRound) { + return Membership.builder() + .member(member) + .recruitmentRound(recruitmentRound) + .regularRequirement(RegularRequirement.createUnsatisfiedRequirement()) + .build(); + } + + // 검증 로직 + + public void validateRegularRequirement() { + if (isRegularRequirementAllSatisfied()) { + throw new CustomException(MEMBERSHIP_ALREADY_SATISFIED); + } + } + + // 상태 변경 로직 + + public void verifyPaymentStatus() { + validateRegularRequirement(); + + regularRequirement.updatePaymentStatus(SATISFIED); + + registerEvent(new MembershipVerifiedEvent(id)); + } + + // 데이터 전달 로직 + + public boolean isRegularRequirementAllSatisfied() { + return regularRequirement.isAllSatisfied(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidator.java b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidator.java new file mode 100644 index 000000000..4d0114f73 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidator.java @@ -0,0 +1,33 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@DomainService +@RequiredArgsConstructor +public class MembershipValidator { + + public void validateMembershipSubmit( + Member currentMember, RecruitmentRound recruitmentRound, boolean isMembershipAlreadySubmitted) { + // 준회원인지 검증 + // TODO: 어드민인 경우 리쿠르팅 지원 및 결제에 대한 정책 검토 필요. 현재는 불가능하도록 설정 + if (!currentMember.isAssociate()) { + throw new CustomException(MEMBERSHIP_NOT_APPLICABLE); + } + + // 이미 접수한 멤버십이 있는지 검증 + if (isMembershipAlreadySubmitted) { + throw new CustomException(MEMBERSHIP_ALREADY_SUBMITTED); + } + + // 모집 회차가 열려있는지 검증 + if (!recruitmentRound.isOpen()) { + throw new CustomException(MEMBERSHIP_RECRUITMENT_ROUND_NOT_OPEN); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipVerifiedEvent.java b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipVerifiedEvent.java new file mode 100644 index 000000000..cc0ce2f59 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipVerifiedEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +public record MembershipVerifiedEvent(Long membershipId) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/RegularRequirement.java b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/RegularRequirement.java new file mode 100644 index 000000000..d522fcd30 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/RegularRequirement.java @@ -0,0 +1,56 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.PAYMENT_NOT_SATISFIED; + +import com.gdschongik.gdsc.domain.common.model.RequirementStatus; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RegularRequirement { + + @Enumerated(EnumType.STRING) + private RequirementStatus paymentStatus; + + @Builder(access = AccessLevel.PRIVATE) + private RegularRequirement(RequirementStatus paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public static RegularRequirement createUnsatisfiedRequirement() { + return RegularRequirement.builder() + .paymentStatus(RequirementStatus.PENDING) + .build(); + } + + public void updatePaymentStatus(RequirementStatus paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public boolean isPaymentSatisfied() { + return paymentStatus == RequirementStatus.SATISFIED; + } + + /** + * 정회원 승급 조건은 추가될 가능성이 존재 + */ + public boolean isAllSatisfied() { + return isPaymentSatisfied(); + } + + public void validateAllSatisfied() { + if (!isPaymentSatisfied()) { + throw new CustomException(PAYMENT_NOT_SATISFIED); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/dto/MembershipFullDto.java b/src/main/java/com/gdschongik/gdsc/domain/membership/dto/MembershipFullDto.java new file mode 100644 index 000000000..9149eb0bc --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/dto/MembershipFullDto.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.domain.membership.dto; + +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.membership.domain.RegularRequirement; + +public record MembershipFullDto( + Long membershipId, Long memberId, Long recruitmentId, RegularRequirement regularRequirement) { + public static MembershipFullDto from(Membership membership) { + return new MembershipFullDto( + membership.getId(), + membership.getMember().getId(), + membership.getRecruitmentRound().getId(), + membership.getRegularRequirement()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/api/AdminOrderController.java b/src/main/java/com/gdschongik/gdsc/domain/order/api/AdminOrderController.java new file mode 100644 index 000000000..bda633097 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/api/AdminOrderController.java @@ -0,0 +1,55 @@ +package com.gdschongik.gdsc.domain.order.api; + +import com.gdschongik.gdsc.domain.order.application.OrderService; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCancelRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.gdschongik.gdsc.domain.order.dto.response.OrderAdminResponse; +import com.gdschongik.gdsc.infra.feign.payment.dto.response.PaymentResponse; +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +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; + +@Tag(name = "Admin Order", description = "주문 어드민 API입니다.") +@RestController +@RequestMapping("/admin/orders") +@RequiredArgsConstructor +public class AdminOrderController { + + private final OrderService orderService; + + @Operation(summary = "주문 목록 조회하기", description = "주문 목록을 조회합니다.") + @GetMapping + public ResponseEntity> getOrders( + @ParameterObject @Valid OrderQueryOption queryOption, @ParameterObject Pageable pageable) { + var response = orderService.searchOrders(queryOption, pageable); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "완료된 유료 주문 결제정보 조회하기", + description = "주문 결제정보를 조회합니다. 토스페이먼츠 API의 결제 정보인 Payment 객체를 반환합니다. 완료된 유료 주문만 조회할 수 있습니다") + @GetMapping("/{orderId}") + public ResponseEntity getCompletedOrderPayment(@PathVariable Long orderId) { + var response = orderService.getCompletedPaidOrderPayment(orderId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "주문 결제 취소하기", description = "주문 상태를 취소로 변경하고 결제를 취소합니다. 회비납입상태를 대기로 변경하고, 준회원으로 강등합니다.") + @PostMapping("/{orderId}/cancel") + public ResponseEntity cancelOrder( + @PathVariable Long orderId, @Valid @RequestBody OrderCancelRequest request) { + orderService.cancelOrder(orderId, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/api/OnboardingOrderController.java b/src/main/java/com/gdschongik/gdsc/domain/order/api/OnboardingOrderController.java new file mode 100644 index 000000000..6fc147796 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/api/OnboardingOrderController.java @@ -0,0 +1,44 @@ +package com.gdschongik.gdsc.domain.order.api; + +import com.gdschongik.gdsc.domain.order.application.OrderService; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCompleteRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCreateRequest; +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.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 = "Onboarding Order", description = "주문 온보딩 API입니다.") +@RestController +@RequestMapping("/onboarding/orders") +@RequiredArgsConstructor +public class OnboardingOrderController { + + private final OrderService orderService; + + @Operation(summary = "임시 주문 생성", description = "임시 주문을 생성합니다.") + @PostMapping + public ResponseEntity createPendingOrder(@Valid @RequestBody OrderCreateRequest request) { + orderService.createPendingOrder(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "주문 완료", description = "임시 주문을 완료합니다. 요청된 결제는 승인됩니다.") + @PostMapping("/complete") + public ResponseEntity completeOrder(@Valid @RequestBody OrderCompleteRequest request) { + orderService.completeOrder(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "무료 주문 생성", description = "무료 주문을 생성합니다. 무료 주문은 완료된 상태로 생성됩니다.") + @PostMapping("/free") + public ResponseEntity createFreeOrder(@Valid @RequestBody OrderCreateRequest request) { + orderService.createFreeOrder(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderEventHandler.java new file mode 100644 index 000000000..3af0f182f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderEventHandler.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.order.application; + +import com.gdschongik.gdsc.domain.membership.application.MembershipService; +import com.gdschongik.gdsc.domain.order.domain.OrderCompletedEvent; +import com.gdschongik.gdsc.domain.order.domain.OrderCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventHandler { + + private final MembershipService membershipService; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCreatedEvent(OrderCreatedEvent orderCreatedEvent) { + log.info( + "[OrderEventHandler] 주문 생성 이벤트 수신: nanoId={}, isFree={}", + orderCreatedEvent.nanoId(), + orderCreatedEvent.isFree()); + if (orderCreatedEvent.isFree()) { + membershipService.verifyPaymentStatus(orderCreatedEvent.nanoId()); + } + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCompletedEvent(OrderCompletedEvent orderCompletedEvent) { + log.info("[OrderEventHandler] 주문 완료 이벤트 수신: nanoId={}", orderCompletedEvent.nanoId()); + membershipService.verifyPaymentStatus(orderCompletedEvent.nanoId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderService.java b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderService.java new file mode 100644 index 000000000..e3f61802e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderService.java @@ -0,0 +1,170 @@ +package com.gdschongik.gdsc.domain.order.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.dao.IssuedCouponRepository; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.order.dao.OrderRepository; +import com.gdschongik.gdsc.domain.order.domain.MoneyInfo; +import com.gdschongik.gdsc.domain.order.domain.Order; +import com.gdschongik.gdsc.domain.order.domain.OrderValidator; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCancelRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCompleteRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCreateRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.gdschongik.gdsc.domain.order.dto.response.OrderAdminResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.infra.feign.payment.client.PaymentClient; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentCancelRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentConfirmRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.response.PaymentResponse; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderService { + + private final PaymentClient paymentClient; + private final MemberUtil memberUtil; + private final OrderRepository orderRepository; + private final MembershipRepository membershipRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final OrderValidator orderValidator; + + @Transactional + public void createPendingOrder(OrderCreateRequest request) { + Membership membership = membershipRepository + .findById(request.membershipId()) + .orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND)); + + IssuedCoupon issuedCoupon = request.issuedCouponId() == null ? null : getIssuedCoupon(request.issuedCouponId()); + + MoneyInfo moneyInfo = MoneyInfo.of( + Money.from(request.totalAmount()), + Money.from(request.discountAmount()), + Money.from(request.finalPaymentAmount())); + + Member currentMember = memberUtil.getCurrentMember(); + + orderValidator.validatePendingOrderCreate(membership, issuedCoupon, moneyInfo, currentMember); + + Order order = Order.createPending(request.orderNanoId(), membership, issuedCoupon, moneyInfo); + + orderRepository.save(order); + + log.info("[OrderService] 임시 주문 생성: orderId={}", order.getId()); + } + + private IssuedCoupon getIssuedCoupon(Long issuedCouponId) { + return issuedCouponRepository + .findById(issuedCouponId) + .orElseThrow(() -> new CustomException(ISSUED_COUPON_NOT_FOUND)); + } + + @Transactional + public void completeOrder(OrderCompleteRequest request) { + Order order = orderRepository + .findByNanoId(request.orderNanoId()) + .orElseThrow(() -> new CustomException(ORDER_NOT_FOUND)); + + Optional issuedCoupon = + Optional.ofNullable(order.getIssuedCouponId()).map(this::getIssuedCoupon); + + Member currentMember = memberUtil.getCurrentMember(); + + Money requestedAmount = Money.from(request.amount()); + + orderValidator.validateCompleteOrder(order, issuedCoupon, currentMember, requestedAmount); + + var paymentRequest = new PaymentConfirmRequest(request.paymentKey(), order.getNanoId(), request.amount()); + PaymentResponse response = paymentClient.confirm(paymentRequest); + + order.complete(request.paymentKey(), response.approvedAt()); + issuedCoupon.ifPresent(IssuedCoupon::use); + + orderRepository.save(order); + + log.info("[OrderService] 주문 완료: orderId={}", order.getId()); + } + + @Transactional(readOnly = true) + public Page searchOrders(OrderQueryOption queryOption, Pageable pageable) { + return orderRepository.searchOrders(queryOption, pageable); + } + + @Transactional(readOnly = true) + public PaymentResponse getCompletedPaidOrderPayment(Long orderId) { + Order order = orderRepository + .findById(orderId) + .filter(Order::isCompleted) + .filter(o -> !o.isFree()) + .orElseThrow(() -> new CustomException(ORDER_COMPLETED_PAID_NOT_FOUND)); + + return paymentClient.getPayment(order.getPaymentKey()); + } + + @Transactional + public void cancelOrder(Long orderId, OrderCancelRequest request) { + Order order = orderRepository.findById(orderId).orElseThrow(() -> new CustomException(ORDER_NOT_FOUND)); + + order.validateCancelable(); + + var cancelRequest = new PaymentCancelRequest(request.cancelReason()); + PaymentResponse response = paymentClient.cancelPayment(order.getPaymentKey(), cancelRequest); + ZonedDateTime canceledAt = getCanceledAt(response); + + order.cancel(canceledAt); + + log.info("[OrderService] 주문 취소: orderId={}", order.getId()); + } + + private ZonedDateTime getCanceledAt(PaymentResponse response) { + // TODO: 예외 발생하는 경우 대개 응답 DTO 매핑 오류이며, 결제 취소는 완료되었으나 DB 주문 취소는 실패한 것이므로 별도 처리 필요 + return Optional.ofNullable(response.cancels()) + .flatMap(this::findLatestCancelDate) + .orElseThrow(() -> new CustomException(ORDER_CANCEL_RESPONSE_NOT_FOUND)); + } + + private Optional findLatestCancelDate(List cancels) { + return cancels.stream().map(PaymentResponse.CancelDto::canceledAt).max(ZonedDateTime::compareTo); + } + + @Transactional + public void createFreeOrder(OrderCreateRequest request) { + Membership membership = membershipRepository + .findById(request.membershipId()) + .orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND)); + + Optional issuedCoupon = + Optional.ofNullable(request.issuedCouponId()).map(this::getIssuedCoupon); + + MoneyInfo moneyInfo = MoneyInfo.of( + Money.from(request.totalAmount()), + Money.from(request.discountAmount()), + Money.from(request.finalPaymentAmount())); + + Member currentMember = memberUtil.getCurrentMember(); + + orderValidator.validateFreeOrderCreate(membership, issuedCoupon, currentMember); + + Order order = Order.createFree(request.orderNanoId(), membership, issuedCoupon.orElse(null), moneyInfo); + + orderRepository.save(order); + + log.info("[OrderService] 무료 주문 생성: orderId={}", order.getId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepository.java new file mode 100644 index 000000000..e452cfb23 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepository.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.domain.order.dao; + +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.gdschongik.gdsc.domain.order.dto.response.OrderAdminResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface OrderCustomRepository { + Page searchOrders(OrderQueryOption queryOption, Pageable pageable); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepositoryImpl.java new file mode 100644 index 000000000..35c49206e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.gdschongik.gdsc.domain.order.dao; + +import static com.gdschongik.gdsc.domain.member.domain.QMember.*; +import static com.gdschongik.gdsc.domain.order.domain.QOrder.*; +import static com.gdschongik.gdsc.domain.recruitment.domain.QRecruitmentRound.*; + +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.gdschongik.gdsc.domain.order.dto.response.OrderAdminResponse; +import com.gdschongik.gdsc.domain.order.dto.response.QOrderAdminResponse; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.annotation.Nullable; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +@RequiredArgsConstructor +public class OrderCustomRepositoryImpl implements OrderCustomRepository, OrderQueryMethod { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchOrders(OrderQueryOption queryOption, Pageable pageable) { + + List ids = getIdsByQueryOption(queryOption, null, order.createdAt.desc()); + + List fetch = queryFactory + .select(getOrderAdminResponse()) + .from(order) + .join(member) + .on(eqMember()) + .join(recruitmentRound) + .on(eqRecruitmentRound()) + .where(order.id.in(ids)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return PageableExecutionUtils.getPage(fetch, pageable, ids::size); + } + + private QOrderAdminResponse getOrderAdminResponse() { + return new QOrderAdminResponse( + order.id, + recruitmentRound.academicYear, + recruitmentRound.semesterType, + member.name, + order.status, + member.studentId, + order.nanoId, + order.paymentKey, + order.moneyInfo.totalAmount, + order.moneyInfo.discountAmount, + order.moneyInfo.finalPaymentAmount, + order.approvedAt); + } + + private List getIdsByQueryOption( + OrderQueryOption queryOption, + @Nullable Predicate predicate, + @NonNull OrderSpecifier... orderSpecifiers) { + return queryFactory + .select(order.id) + .from(order) + .innerJoin(recruitmentRound) + .on(order.recruitmentRoundId.eq(recruitmentRound.id)) + .where(matchesOrderQueryOption(queryOption), predicate) + .orderBy(orderSpecifiers) + .fetch(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderQueryMethod.java b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderQueryMethod.java new file mode 100644 index 000000000..0af33f5fd --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderQueryMethod.java @@ -0,0 +1,70 @@ +package com.gdschongik.gdsc.domain.order.dao; + +import static com.gdschongik.gdsc.domain.member.domain.QMember.*; +import static com.gdschongik.gdsc.domain.order.domain.QOrder.*; +import static com.gdschongik.gdsc.domain.recruitment.domain.QRecruitment.*; +import static com.gdschongik.gdsc.domain.recruitment.domain.QRecruitmentRound.*; +import static com.querydsl.jpa.JPAExpressions.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.*; + +public interface OrderQueryMethod { + + default BooleanBuilder matchesOrderQueryOption(OrderQueryOption queryOption) { + return new BooleanBuilder() + .and(eqName(queryOption.name())) + .and(eqAcademicYear(queryOption.academicYear())) + .and(eqSemesterType(queryOption.semesterType())) + .and(eqStudentId(queryOption.studentId())) + .and(eqNanoId(queryOption.nanoId())) + .and(eqPaymentKey(queryOption.paymentKey())) + .and(eqApprovedAt(queryOption.approvedDate())); + } + + default BooleanExpression eqMember() { + return order.memberId.eq(member.id); + } + + default BooleanExpression eqRecruitmentRound() { + return order.recruitmentRoundId.eq(recruitment.id); + } + + // TODO: MemberQueryMethod가 interface로 변경된 경우 해당 메서드 제거 및 대체 + default BooleanExpression eqName(String name) { + return name != null ? member.name.contains(name) : null; + } + + default BooleanExpression eqAcademicYear(Integer academicYear) { + return academicYear != null ? recruitmentRound.academicYear.eq(academicYear) : null; + } + + default BooleanExpression eqSemesterType(SemesterType semesterType) { + return semesterType != null ? recruitmentRound.semesterType.eq(semesterType) : null; + } + + default BooleanExpression eqStudentId(String studentId) { + return studentId != null ? member.studentId.containsIgnoreCase(studentId) : null; + } + + default BooleanExpression eqNanoId(String nanoId) { + return nanoId != null ? order.nanoId.contains(nanoId) : null; + } + + default BooleanExpression eqPaymentKey(String paymentKey) { + return paymentKey != null ? order.paymentKey.contains(paymentKey) : null; + } + + default BooleanExpression eqApprovedAt(LocalDate approvedAt) { + if (approvedAt == null) { + return null; + } + ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + ZonedDateTime startOfDay = approvedAt.atStartOfDay(seoulZone); + ZonedDateTime endOfDay = approvedAt.atTime(LocalTime.MAX).atZone(seoulZone); + return order.approvedAt.between(startOfDay, endOfDay); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderRepository.java b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderRepository.java new file mode 100644 index 000000000..9acfbe9c9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderRepository.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.order.dao; + +import com.gdschongik.gdsc.domain.order.domain.Order; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository, OrderCustomRepository { + Optional findByNanoId(String nanoId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfo.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfo.java new file mode 100644 index 000000000..78c430ac3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfo.java @@ -0,0 +1,65 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MoneyInfo { + + @Comment("주문총액") + @Embedded + @AttributeOverride(name = "amount", column = @Column(name = "total_amount")) + private Money totalAmount; + + @Comment("쿠폰할인금액") + @Embedded + @AttributeOverride(name = "amount", column = @Column(name = "discount_amount")) + private Money discountAmount; + + @Comment("최종결제금액") + @Embedded + @AttributeOverride(name = "amount", column = @Column(name = "final_payment_amount")) + private Money finalPaymentAmount; + + @Builder(access = AccessLevel.PRIVATE) + private MoneyInfo(Money totalAmount, Money discountAmount, Money finalPaymentAmount) { + this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.finalPaymentAmount = finalPaymentAmount; + } + + public static MoneyInfo of(Money totalAmount, Money discountAmount, Money finalPaymentAmount) { + validateFinalPaymentAmount(totalAmount, discountAmount, finalPaymentAmount); + + return MoneyInfo.builder() + .totalAmount(totalAmount) + .discountAmount(discountAmount) + .finalPaymentAmount(finalPaymentAmount) + .build(); + } + + private static void validateFinalPaymentAmount(Money totalAmount, Money discountAmount, Money finalPaymentAmount) { + Money expectedFinalPaymentAmount = totalAmount.subtract(discountAmount); + if (!finalPaymentAmount.equals(expectedFinalPaymentAmount)) { + throw new CustomException(ErrorCode.ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH); + } + } + + public boolean isFree() { + return finalPaymentAmount.equals(Money.ZERO); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java new file mode 100644 index 000000000..37d82f9fa --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java @@ -0,0 +1,169 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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.Table; +import java.time.ZonedDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Table(name = "orders") +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "orders_id") + private Long id; + + @Comment("주문상태") + @Enumerated(EnumType.STRING) + private OrderStatus status; + + @Comment("주문 nanoId") + @Column(unique = true, length = 21) + private String nanoId; + + @Comment("주문자 ID") + private Long memberId; + + @Comment("주문 대상 멤버십 ID") + private Long membershipId; + + @Comment("신청하려는 모집회차 ID") + private Long recruitmentRoundId; + + @Comment("사용하려는 발급쿠폰 ID") + private Long issuedCouponId; + + @Embedded + private MoneyInfo moneyInfo; + + private String paymentKey; + + private ZonedDateTime approvedAt; + + private ZonedDateTime canceledAt; + + @Builder(access = AccessLevel.PRIVATE) + private Order( + OrderStatus status, + String nanoId, + Long memberId, + Long membershipId, + Long recruitmentRoundId, + Long issuedCouponId, + MoneyInfo moneyInfo) { + this.status = status; + this.nanoId = nanoId; + this.memberId = memberId; + this.membershipId = membershipId; + this.recruitmentRoundId = recruitmentRoundId; + this.issuedCouponId = issuedCouponId; + this.moneyInfo = moneyInfo; + + registerEvent(new OrderCreatedEvent(nanoId, isFree())); + } + + /** + * 결제 요청 전 임시 주문을 생성합니다. + * 쿠폰의 경우 사용 여부를 선택할 수 있습니다. + */ + public static Order createPending( + String nanoId, Membership membership, @Nullable IssuedCoupon issuedCoupon, MoneyInfo moneyInfo) { + return Order.builder() + .status(OrderStatus.PENDING) + .nanoId(nanoId) + .memberId(membership.getMember().getId()) + .membershipId(membership.getId()) + .recruitmentRoundId(membership.getRecruitmentRound().getId()) + .issuedCouponId(issuedCoupon != null ? issuedCoupon.getId() : null) + .moneyInfo(moneyInfo) + .build(); + } + + public static Order createFree( + String nanoId, Membership membership, @Nullable IssuedCoupon issuedCoupon, MoneyInfo moneyInfo) { + validateFreeOrder(moneyInfo); + return Order.builder() + .status(OrderStatus.COMPLETED) + .nanoId(nanoId) + .memberId(membership.getMember().getId()) + .membershipId(membership.getId()) + .recruitmentRoundId(membership.getRecruitmentRound().getId()) + .issuedCouponId(issuedCoupon != null ? issuedCoupon.getId() : null) + .moneyInfo(moneyInfo) + .build(); + } + + private static void validateFreeOrder(MoneyInfo moneyInfo) { + if (!moneyInfo.isFree()) { + throw new CustomException(ORDER_FREE_FINAL_PAYMENT_NOT_ZERO); + } + } + + // 데이터 변경 로직 + + /** + * 주문을 완료 처리합니다. + * 상태 변경 및 결제 관련 정보를 저장하며, 예외를 발생시키지 않습니다. + * 이는 결제 승인 API 호출 후 완료 처리 과정에서 예외가 발생하는 것을 방지하기 위함입니다. + * 실제 완료 처리 유효성에 대한 검증은 {@link OrderValidator#validateCompleteOrder}에서 수행합니다. + */ + public void complete(String paymentKey, ZonedDateTime approvedAt) { + this.status = OrderStatus.COMPLETED; + this.paymentKey = paymentKey; + this.approvedAt = approvedAt; + + registerEvent(new OrderCompletedEvent(nanoId)); + } + + /** + * 주문을 취소 처리합니다. + * 상태 변경 및 취소 시각을 저장하며, 예외를 발생시키지 않도록 외부 취소 요청 전에 validateCancelable을 호출합니다. + */ + public void cancel(ZonedDateTime canceledAt) { + // TODO: 취소 이벤트 발행을 통해 멤버십 및 멤버 상태에 대한 변경 로직 추가 + validateCancelable(); + this.status = OrderStatus.CANCELED; + this.canceledAt = canceledAt; + } + + public void validateCancelable() { + if (status != OrderStatus.COMPLETED) { + throw new CustomException(ORDER_CANCEL_NOT_COMPLETED); + } + + if (isFree()) { + throw new CustomException(ORDER_CANCEL_FREE_ORDER); + } + } + + // 데이터 조회 로직 + + public boolean isCompleted() { + return status == OrderStatus.COMPLETED; + } + + public boolean isFree() { + return moneyInfo.isFree(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCompletedEvent.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCompletedEvent.java new file mode 100644 index 000000000..c88cd6ca4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCompletedEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.order.domain; + +public record OrderCompletedEvent(String nanoId) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCreatedEvent.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCreatedEvent.java new file mode 100644 index 000000000..4d2cf97f4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCreatedEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.order.domain; + +public record OrderCreatedEvent(String nanoId, boolean isFree) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderStatus.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderStatus.java new file mode 100644 index 000000000..7573a7c66 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderStatus.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.domain.order.domain; + +public enum OrderStatus { + PENDING, + COMPLETED, + CANCELED, + ; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderValidator.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderValidator.java new file mode 100644 index 000000000..1a266cf5f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderValidator.java @@ -0,0 +1,132 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.annotation.Nullable; +import java.util.Optional; + +@DomainService +public class OrderValidator { + + public void validatePendingOrderCreate( + Membership membership, @Nullable IssuedCoupon issuedCoupon, MoneyInfo moneyInfo, Member currentMember) { + + // 멤버십 관련 검증 + + if (!membership.getMember().getId().equals(currentMember.getId())) { + throw new CustomException(ORDER_MEMBERSHIP_MEMBER_MISMATCH); + } + + if (membership.getRegularRequirement().isPaymentSatisfied()) { + throw new CustomException(ORDER_MEMBERSHIP_ALREADY_PAID); + } + + // 리쿠르팅 관련 검증 + + RecruitmentRound recruitmentRound = membership.getRecruitmentRound(); + + if (!recruitmentRound.isOpen()) { + throw new CustomException(ORDER_RECRUITMENT_PERIOD_INVALID); + } + + // 발급쿠폰 관련 검증 + + // TODO: 주문 완료 검증 로직처럼 Optional로 변경 + if (issuedCoupon != null) { + validateIssuedCouponOwnership(issuedCoupon, currentMember); + issuedCoupon.validateUsable(); + } + + // 금액 관련 검증 + + Money totalAmount = moneyInfo.getTotalAmount(); + Money discountAmount = moneyInfo.getDiscountAmount(); + + if (!totalAmount.equals(recruitmentRound.getRecruitment().getFee())) { + throw new CustomException(ORDER_TOTAL_AMOUNT_MISMATCH); + } + + if (issuedCoupon == null) { + validateDiscountAmountZero(discountAmount); + } else { + validateDiscountAmountMatches(discountAmount, issuedCoupon); + } + } + + private void validateIssuedCouponOwnership(IssuedCoupon issuedCoupon, Member currentMember) { + if (!issuedCoupon.getMember().getId().equals(currentMember.getId())) { + throw new CustomException(ORDER_ISSUED_COUPON_MEMBER_MISMATCH); + } + } + + private void validateDiscountAmountZero(Money discountAmount) { + if (!discountAmount.equals(Money.ZERO)) { + throw new CustomException(ORDER_DISCOUNT_AMOUNT_NOT_ZERO); + } + } + + private void validateDiscountAmountMatches(Money discountAmount, IssuedCoupon issuedCoupon) { + if (!discountAmount.equals(issuedCoupon.getCoupon().getDiscountAmount())) { + throw new CustomException(ORDER_DISCOUNT_AMOUNT_MISMATCH); + } + } + + public void validateCompleteOrder( + Order order, Optional optionalIssuedCoupon, Member currentMember, Money requestedAmount) { + if (order.isCompleted()) { + throw new CustomException(ORDER_ALREADY_COMPLETED); + } + + if (optionalIssuedCoupon.isPresent()) { + var issuedCoupon = optionalIssuedCoupon.get(); + issuedCoupon.validateUsable(); + validateIssuedCouponOwnership(issuedCoupon, currentMember); + } + + if (!order.getMemberId().equals(currentMember.getId())) { + throw new CustomException(ORDER_MEMBERSHIP_MEMBER_MISMATCH); + } + + if (!order.getMoneyInfo().getFinalPaymentAmount().equals(requestedAmount)) { + throw new CustomException(ORDER_COMPLETE_AMOUNT_MISMATCH); + } + } + + public void validateFreeOrderCreate( + Membership membership, Optional optionalIssuedCoupon, Member currentMember) { + // TODO: 공통 로직으로 추출 + + // 멤버십 관련 검증 + + if (!membership.getMember().getId().equals(currentMember.getId())) { + throw new CustomException(ORDER_MEMBERSHIP_MEMBER_MISMATCH); + } + + if (membership.getRegularRequirement().isPaymentSatisfied()) { + throw new CustomException(ORDER_MEMBERSHIP_ALREADY_PAID); + } + + // 리쿠르팅 관련 검증 + + RecruitmentRound recruitmentRound = membership.getRecruitmentRound(); + + if (!recruitmentRound.isOpen()) { + throw new CustomException(ORDER_RECRUITMENT_PERIOD_INVALID); + } + + // 발급쿠폰 관련 검증 + + if (optionalIssuedCoupon.isPresent()) { + var issuedCoupon = optionalIssuedCoupon.get(); + validateIssuedCouponOwnership(issuedCoupon, currentMember); + issuedCoupon.validateUsable(); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCancelRequest.java b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCancelRequest.java new file mode 100644 index 000000000..f81ef0707 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCancelRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.order.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderCancelRequest(@NotBlank String cancelReason) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCompleteRequest.java b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCompleteRequest.java new file mode 100644 index 000000000..b6cb4135c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCompleteRequest.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.domain.order.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record OrderCompleteRequest( + @NotBlank String paymentKey, @NotBlank @Size(min = 21, max = 21) String orderNanoId, @Positive Long amount) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCreateRequest.java new file mode 100644 index 000000000..d877dace5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderCreateRequest.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.domain.order.dto.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; + +public record OrderCreateRequest( + @Size(min = 21, max = 21) String orderNanoId, + @NotNull @Positive Long membershipId, + @Nullable @Positive Long issuedCouponId, + @NotNull BigDecimal totalAmount, + @NotNull BigDecimal discountAmount, + @NotNull BigDecimal finalPaymentAmount) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderQueryOption.java b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderQueryOption.java new file mode 100644 index 000000000..5b656da17 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dto/request/OrderQueryOption.java @@ -0,0 +1,16 @@ +package com.gdschongik.gdsc.domain.order.dto.request; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.order.domain.OrderStatus; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; + +public record OrderQueryOption( + String name, + @Min(2023) Integer academicYear, + SemesterType semesterType, + String studentId, + OrderStatus status, + String nanoId, + String paymentKey, + LocalDate approvedDate) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/dto/response/OrderAdminResponse.java b/src/main/java/com/gdschongik/gdsc/domain/order/dto/response/OrderAdminResponse.java new file mode 100644 index 000000000..32be905f6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dto/response/OrderAdminResponse.java @@ -0,0 +1,51 @@ +package com.gdschongik.gdsc.domain.order.dto.response; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.order.domain.OrderStatus; +import com.gdschongik.gdsc.global.util.formatter.MoneyFormatter; +import com.gdschongik.gdsc.global.util.formatter.SemesterFormatter; +import com.querydsl.core.annotations.QueryProjection; +import java.time.ZonedDateTime; + +public record OrderAdminResponse( + Long orderId, + String semester, + String memberName, + OrderStatus status, + String studentId, + String nanoId, + String paymentKey, + String totalAmount, + String discountAmount, + String finalPaymentAmount, + ZonedDateTime approvedAt) { + + @QueryProjection + public OrderAdminResponse( + Long orderId, + Integer academicYear, + SemesterType semesterType, + String memberName, + OrderStatus status, + String studentId, + String nanoId, + String paymentKey, + Money totalAmount, + Money discountAmount, + Money finalPaymentAmount, + ZonedDateTime approvedAt) { + this( + orderId, + SemesterFormatter.format(academicYear, semesterType), + memberName, + status, + studentId, + nanoId, + paymentKey, + MoneyFormatter.format(totalAmount), + MoneyFormatter.format(discountAmount), + MoneyFormatter.format(finalPaymentAmount), + approvedAt); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/api/AdminRecruitmentController.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/api/AdminRecruitmentController.java new file mode 100644 index 000000000..90ee88fa5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/api/AdminRecruitmentController.java @@ -0,0 +1,66 @@ +package com.gdschongik.gdsc.domain.recruitment.api; + +import com.gdschongik.gdsc.domain.recruitment.application.AdminRecruitmentService; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundCreateUpdateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.response.AdminRecruitmentResponse; +import com.gdschongik.gdsc.domain.recruitment.dto.response.AdminRecruitmentRoundResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Recruitment", description = "어드민 리쿠르팅 관리 API입니다.") +@RestController +@RequestMapping("/admin/recruitments") +@RequiredArgsConstructor +public class AdminRecruitmentController { + + private final AdminRecruitmentService adminRecruitmentService; + + @Operation(summary = "리쿠르팅 생성", description = "새로운 리쿠르팅을 생성합니다.") + @PostMapping + public ResponseEntity createRecruitment(@Valid @RequestBody RecruitmentCreateRequest request) { + adminRecruitmentService.createRecruitment(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "리쿠르팅 목록 조회", description = "전체 리쿠르팅 목록을 조회합니다.") + @GetMapping + public ResponseEntity> getAllRecruitments() { + List response = adminRecruitmentService.getAllRecruitments(); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "모집회차 목록 조회", description = "전체 모집회차 목록을 조회합니다.") + @GetMapping("/rounds") + public ResponseEntity> getAllRecruitmentRounds() { + List response = adminRecruitmentService.getAllRecruitmentRounds(); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "모집회차 생성", description = "새로운 모집회차를 생성합니다. 모집기간은 학기 시작일로부터 2주 이내입니다.") + @PostMapping("/rounds") + public ResponseEntity createRecruitmentRound( + @Valid @RequestBody RecruitmentRoundCreateUpdateRequest request) { + adminRecruitmentService.createRecruitmentRound(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "모집회차 수정", description = "기존 모집회차를 수정합니다. 학년도와 학기는 수정 대상이 아닙니다.") + @PutMapping("/rounds/{recruitmentRoundId}") + public ResponseEntity updateRecruitmentRound( + @PathVariable Long recruitmentRoundId, @Valid @RequestBody RecruitmentRoundCreateUpdateRequest request) { + adminRecruitmentService.updateRecruitmentRound(recruitmentRoundId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentService.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentService.java new file mode 100644 index 000000000..e69f8b104 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentService.java @@ -0,0 +1,109 @@ +package com.gdschongik.gdsc.domain.recruitment.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRepository; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRoundValidator; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentValidator; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundCreateUpdateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.response.AdminRecruitmentResponse; +import com.gdschongik.gdsc.domain.recruitment.dto.response.AdminRecruitmentRoundResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminRecruitmentService { + + private final RecruitmentRepository recruitmentRepository; + private final RecruitmentRoundRepository recruitmentRoundRepository; + private final RecruitmentValidator recruitmentValidator; + private final RecruitmentRoundValidator recruitmentRoundValidator; + + @Transactional + public void createRecruitment(RecruitmentCreateRequest request) { + boolean isRecruitmentOverlap = recruitmentRepository.existsByAcademicYearAndSemesterType( + request.academicYear(), request.semesterType()); + + recruitmentValidator.validateRecruitmentCreate(isRecruitmentOverlap); + + Recruitment recruitment = Recruitment.createRecruitment( + request.academicYear(), + request.semesterType(), + Money.from(request.fee()), + request.feeName(), + Period.createPeriod(request.semesterStartDate(), request.semesterEndDate())); + recruitmentRepository.save(recruitment); + + log.info("[AdminRecruitmentService] 리쿠르팅 생성: recruitmentId={}", recruitment.getId()); + } + + public List getAllRecruitments() { + List recruitments = recruitmentRepository.findByOrderBySemesterPeriodDesc(); + return recruitments.stream().map(AdminRecruitmentResponse::from).toList(); + } + + public List getAllRecruitmentRounds() { + List recruitmentRounds = recruitmentRoundRepository.findAll(); + return recruitmentRounds.stream() + .map(AdminRecruitmentRoundResponse::from) + .toList(); + } + + @Transactional + public void createRecruitmentRound(RecruitmentRoundCreateUpdateRequest request) { + Recruitment recruitment = recruitmentRepository + .findByAcademicYearAndSemesterType(request.academicYear(), request.semesterType()) + .orElseThrow(() -> new CustomException(RECRUITMENT_NOT_FOUND)); + + List recruitmentRoundsInThisSemester = + recruitmentRoundRepository.findAllByAcademicYearAndSemesterType( + request.academicYear(), request.semesterType()); + + recruitmentRoundValidator.validateRecruitmentRoundCreate( + request.startDate(), + request.endDate(), + request.roundType(), + recruitment, + recruitmentRoundsInThisSemester); + + RecruitmentRound recruitmentRound = RecruitmentRound.create( + request.name(), request.startDate(), request.endDate(), recruitment, request.roundType()); + recruitmentRoundRepository.save(recruitmentRound); + + log.info("[AdminRecruitmentService] 모집회차 생성: recruitmentRoundId={}", recruitmentRound.getId()); + } + + @Transactional + public void updateRecruitmentRound(Long recruitmentRoundId, RecruitmentRoundCreateUpdateRequest request) { + List recruitmentRounds = recruitmentRoundRepository.findAllByAcademicYearAndSemesterType( + request.academicYear(), request.semesterType()); + + RecruitmentRound recruitmentRound = recruitmentRounds.stream() + .filter(r -> r.getId().equals(recruitmentRoundId)) + .findAny() + .orElseThrow(() -> new CustomException(RECRUITMENT_ROUND_NOT_FOUND)); + + recruitmentRounds.remove(recruitmentRound); + + recruitmentRoundValidator.validateRecruitmentRoundUpdate( + request.startDate(), request.endDate(), request.roundType(), recruitmentRound, recruitmentRounds); + + recruitmentRound.updateRecruitmentRound( + request.name(), Period.createPeriod(request.startDate(), request.endDate()), request.roundType()); + + log.info("[AdminRecruitmentService] 모집회차 수정: recruitmentRoundId={}", recruitmentRoundId); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/OnboardingRecruitmentService.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/OnboardingRecruitmentService.java new file mode 100644 index 000000000..f2f43a5ec --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/OnboardingRecruitmentService.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.recruitment.application; + +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OnboardingRecruitmentService { + + private final RecruitmentRoundRepository recruitmentRoundRepository; + + // TODO: 모집기간과 별도로 표시기간 사용하여 필터링하도록 변경 + public RecruitmentRound findCurrentRecruitmentRound() { + return recruitmentRoundRepository.findAll().stream() + .filter(RecruitmentRound::isOpen) // isOpen -> isDisplayable + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.RECRUITMENT_ROUND_OPEN_NOT_FOUND)); + } + + /** + * 테스트용 강등 API에서 모집 회차가 존재하지 않을 경우에 대해 필요한 메소드입니다. + */ + public Optional findCurrentRecruitmentRoundToDemote() { + return recruitmentRoundRepository.findAll().stream() + .filter(RecruitmentRound::isOpen) + .findFirst(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRepository.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRepository.java new file mode 100644 index 000000000..baa3ebacd --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRepository.java @@ -0,0 +1,16 @@ +package com.gdschongik.gdsc.domain.recruitment.dao; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecruitmentRepository extends JpaRepository { + + boolean existsByAcademicYearAndSemesterType(Integer academicYear, SemesterType semesterType); + + List findByOrderBySemesterPeriodDesc(); + + Optional findByAcademicYearAndSemesterType(Integer academicYear, SemesterType semesterType); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRoundRepository.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRoundRepository.java new file mode 100644 index 000000000..7d5d93d36 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dao/RecruitmentRoundRepository.java @@ -0,0 +1,11 @@ +package com.gdschongik.gdsc.domain.recruitment.dao; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecruitmentRoundRepository extends JpaRepository { + + List findAllByAcademicYearAndSemesterType(Integer academicYear, SemesterType semesterType); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/Recruitment.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/Recruitment.java new file mode 100644 index 000000000..4626380bf --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/Recruitment.java @@ -0,0 +1,50 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import com.gdschongik.gdsc.domain.common.model.BaseSemesterEntity; +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Recruitment extends BaseSemesterEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recruitment_id") + private Long id; + + @Embedded + private Money fee; + + private String feeName; + + @Embedded + private Period semesterPeriod; + + @Builder(access = AccessLevel.PRIVATE) + private Recruitment( + Integer academicYear, SemesterType semesterType, Money fee, String feeName, final Period semesterPeriod) { + super(academicYear, semesterType); + this.fee = fee; + this.feeName = feeName; + this.semesterPeriod = semesterPeriod; + } + + public static Recruitment createRecruitment( + Integer academicYear, SemesterType semesterType, Money fee, String feeName, Period semesterPeriod) { + return Recruitment.builder() + .academicYear(academicYear) + .semesterType(semesterType) + .fee(fee) + .feeName(feeName) + .semesterPeriod(semesterPeriod) + .build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRound.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRound.java new file mode 100644 index 000000000..18d3cec87 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRound.java @@ -0,0 +1,99 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.BaseSemesterEntity; +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecruitmentRound extends BaseSemesterEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recruitment_round_id") + private Long id; + + private String name; + + @Embedded + private Period period; + + @ManyToOne + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; + + @Enumerated(EnumType.STRING) + private RoundType roundType; + + @Builder(access = AccessLevel.PRIVATE) + private RecruitmentRound( + String name, + final Period period, + Integer academicYear, + SemesterType semesterType, + Recruitment recruitment, + RoundType roundType) { + super(academicYear, semesterType); + this.name = name; + this.period = period; + this.recruitment = recruitment; + this.roundType = roundType; + } + + public static RecruitmentRound create( + String name, LocalDateTime startDate, LocalDateTime endDate, Recruitment recruitment, RoundType roundType) { + Period period = Period.createPeriod(startDate, endDate); + return RecruitmentRound.builder() + .name(name) + .period(period) + .academicYear(recruitment.getAcademicYear()) + .semesterType(recruitment.getSemesterType()) + .recruitment(recruitment) + .roundType(roundType) + .build(); + } + + public boolean isOpen() { + return period.isOpen(); + } + + public void validatePeriodOverlap(LocalDateTime startDate, LocalDateTime endDate) { + period.validatePeriodOverlap(startDate, endDate); + } + + public void updateRecruitmentRound(String name, Period period, RoundType roundType) { + this.name = name; + this.period = period; + this.roundType = roundType; + } + + public void validatePeriodNotStarted() { + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(period.getStartDate())) { + throw new CustomException(RECRUITMENT_ROUND_STARTDATE_ALREADY_PASSED); + } + } + + public boolean isFirstRound() { + return roundType.equals(RoundType.FIRST); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidator.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidator.java new file mode 100644 index 000000000..42d4ec58a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidator.java @@ -0,0 +1,90 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.domain.recruitment.domain.RoundType.*; +import static com.gdschongik.gdsc.global.common.constant.TemporalConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; +import java.util.List; + +@DomainService +public class RecruitmentRoundValidator { + + public void validateRecruitmentRoundCreate( + LocalDateTime startDate, + LocalDateTime endDate, + RoundType roundType, + Recruitment recruitment, + List recruitmentRoundsInThisSemester) { + validatePeriodWithinTwoWeeks(startDate, endDate, recruitment); + validatePeriodOverlap(recruitmentRoundsInThisSemester, startDate, endDate); + validateRoundOverlap(recruitmentRoundsInThisSemester, roundType); + validateRoundOneExist(recruitmentRoundsInThisSemester, roundType); + } + + /** + * 수정하려는 모집회차의 차수와 기간은 수정 후에 유효하지 않으므로, + * 변경하려는 값들은 다른 모집회차들과 차수, 기간이 겹치는지 검증해야 합니다. + * 따라서, 수정하려는 모집회차와 이를 제외한 다른 모집회차들을 분리하여 매개변수로 받습니다. + * + * @param currentRecruitmentRound: 수정하려는 모집회차 + * @param otherRecruitmentRounds: 동일 리쿠르팅을 참조하는 모집회차 중 수정하려는 모집회차를 제외한 나머지 모집회차 + */ + public void validateRecruitmentRoundUpdate( + LocalDateTime startDate, + LocalDateTime endDate, + RoundType roundType, + RecruitmentRound currentRecruitmentRound, + List otherRecruitmentRounds) { + validatePeriodWithinTwoWeeks(startDate, endDate, currentRecruitmentRound.getRecruitment()); + validatePeriodOverlap(otherRecruitmentRounds, startDate, endDate); + validateRoundOverlap(otherRecruitmentRounds, roundType); + validateRoundOneToTwo(currentRecruitmentRound.getRoundType(), roundType); + currentRecruitmentRound.validatePeriodNotStarted(); + } + + private void validatePeriodWithinTwoWeeks(LocalDateTime startDate, LocalDateTime endDate, Recruitment recruitment) { + LocalDateTime semesterStartDate = recruitment.getSemesterPeriod().getStartDate(); + + validateDateTimeWithinTwoWeeks(startDate, semesterStartDate); + validateDateTimeWithinTwoWeeks(endDate, semesterStartDate); + } + + private void validateDateTimeWithinTwoWeeks(LocalDateTime dateTime, LocalDateTime semesterStartDate) { + if (semesterStartDate.minusWeeks(PRE_SEMESTER_TERM).isAfter(dateTime) + || semesterStartDate.plusWeeks(PRE_SEMESTER_TERM).isBefore(dateTime)) { + throw new CustomException(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS); + } + } + + private void validatePeriodOverlap( + List recruitmentRounds, LocalDateTime startDate, LocalDateTime endDate) { + recruitmentRounds.forEach(recruitmentRound -> recruitmentRound.validatePeriodOverlap(startDate, endDate)); + } + + // 학년도, 학기, 모집회차가 모두 같은 경우 + private void validateRoundOverlap(List recruitmentRounds, RoundType roundType) { + recruitmentRounds.stream() + .filter(recruitmentRound -> recruitmentRound.getRoundType().equals(roundType)) + .findAny() + .ifPresent(ignored -> { + throw new CustomException(RECRUITMENT_ROUND_TYPE_OVERLAP); + }); + } + + // 1차 모집이 없는데 2차 모집을 생성하려고 하는 경우 + private void validateRoundOneExist(List recruitmentRounds, RoundType roundType) { + if (roundType.equals(SECOND) && recruitmentRounds.stream().noneMatch(RecruitmentRound::isFirstRound)) { + throw new CustomException(ROUND_ONE_DOES_NOT_EXIST); + } + } + + // 1차 모집을 비워둬서는 안되므로, 1차 모집을 2차 모집으로 수정하려고 하는 경우 예외 발생 + private void validateRoundOneToTwo(RoundType previousRoundType, RoundType newRoundType) { + if (previousRoundType.equals(FIRST) && newRoundType.equals(SECOND)) { + throw new CustomException(ROUND_ONE_DOES_NOT_EXIST); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidator.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidator.java new file mode 100644 index 000000000..e64e85a11 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidator.java @@ -0,0 +1,17 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; + +@DomainService +public class RecruitmentValidator { + + public void validateRecruitmentCreate(boolean isRecruitmentOverlap) { + // 학년도와 학기가 같은 리쿠르팅이 이미 존재하는 경우 + if (isRecruitmentOverlap) { + throw new CustomException(RECRUITMENT_OVERLAP); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RoundType.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RoundType.java new file mode 100644 index 000000000..a70e8a963 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/RoundType.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RoundType { + FIRST("1차"), + SECOND("2차"); + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/vo/Period.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/vo/Period.java new file mode 100644 index 000000000..ded4a8352 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/domain/vo/Period.java @@ -0,0 +1,62 @@ +package com.gdschongik.gdsc.domain.recruitment.domain.vo; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.Embeddable; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Period { + private LocalDateTime startDate; + + private LocalDateTime endDate; + + @Builder(access = AccessLevel.PRIVATE) + private Period(final LocalDateTime startDate, final LocalDateTime endDate) { + this.startDate = startDate; + this.endDate = endDate; + } + + public static Period createPeriod(LocalDateTime startDate, LocalDateTime endDate) { + validatePeriod(startDate, endDate); + return Period.builder().startDate(startDate).endDate(endDate).build(); + } + + private static void validatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate.isAfter(endDate) || startDate.isEqual(endDate)) { + throw new CustomException(DATE_PRECEDENCE_INVALID); + } + } + + public boolean isOpen() { + LocalDateTime now = LocalDateTime.now(); + return (now.isAfter(startDate) || now.isEqual(startDate)) && (now.isBefore(endDate) || now.isEqual(startDate)); + } + + public void validatePeriodOverlap(LocalDateTime startDate, LocalDateTime endDate) { + if (!this.endDate.isBefore(startDate) && !this.startDate.isAfter(endDate)) { + throw new CustomException(PERIOD_OVERLAP); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Period that = (Period) o; + return startDate == that.startDate && endDate == that.endDate; + } + + @Override + public int hashCode() { + return Objects.hash(startDate, endDate); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/RecruitmentRoundFullDto.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/RecruitmentRoundFullDto.java new file mode 100644 index 000000000..44f6c529a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/RecruitmentRoundFullDto.java @@ -0,0 +1,19 @@ +package com.gdschongik.gdsc.domain.recruitment.dto; + +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import java.math.BigDecimal; + +public record RecruitmentRoundFullDto( + Long recruitmentId, String name, Period period, BigDecimal fee, RoundType roundType, String roundTypeValue) { + public static RecruitmentRoundFullDto from(RecruitmentRound recruitmentRound) { + return new RecruitmentRoundFullDto( + recruitmentRound.getId(), + recruitmentRound.getName(), + recruitmentRound.getPeriod(), + recruitmentRound.getRecruitment().getFee().getAmount(), + recruitmentRound.getRoundType(), + recruitmentRound.getRoundType().getValue()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentCreateRequest.java new file mode 100644 index 000000000..033d8de44 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentCreateRequest.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.domain.recruitment.dto.request; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record RecruitmentCreateRequest( + @Future @Schema(description = "학기 시작일", pattern = DATETIME) LocalDateTime semesterStartDate, + @Future @Schema(description = "학기 종료일", pattern = DATETIME) LocalDateTime semesterEndDate, + @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) + Integer academicYear, + @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, + @NotNull(message = "회비는 null이 될 수 없습니다.") @Schema(description = "회비") BigDecimal fee, + @NotBlank @Schema(description = "회비 이름") String feeName) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateUpdateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateUpdateRequest.java new file mode 100644 index 000000000..80b377fdc --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateUpdateRequest.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.domain.recruitment.dto.request; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record RecruitmentRoundCreateUpdateRequest( + @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) + Integer academicYear, + @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, + @NotBlank @Schema(description = "이름") String name, + @Future @Schema(description = "모집기간 시작일", pattern = DATETIME) LocalDateTime startDate, + @Future @Schema(description = "모집기간 종료일", pattern = DATETIME) LocalDateTime endDate, + @NotNull(message = "모집 차수는 null이 될 수 없습니다.") @Schema(description = "모집 차수") RoundType roundType) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentResponse.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentResponse.java new file mode 100644 index 000000000..30c385517 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentResponse.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.recruitment.dto.response; + +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.global.util.formatter.SemesterFormatter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.text.DecimalFormat; +import java.time.LocalDateTime; + +public record AdminRecruitmentResponse( + Long recruitmentId, + @Schema(description = "활동 학기") String semester, + @Schema(description = "학기 시작일") LocalDateTime semesterStartDate, + @Schema(description = "학기 종료일") LocalDateTime semesterEndDate, + @Schema(description = "회비") String recruitmentFee, + @Schema(description = "회비 이름") String feeName) { + + public static AdminRecruitmentResponse from(Recruitment recruitment) { + DecimalFormat decimalFormat = new DecimalFormat("#,###"); + + return new AdminRecruitmentResponse( + recruitment.getId(), + SemesterFormatter.format(recruitment), + recruitment.getSemesterPeriod().getStartDate(), + recruitment.getSemesterPeriod().getEndDate(), + String.format("%s원", decimalFormat.format(recruitment.getFee().getAmount())), + recruitment.getFeeName()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentRoundResponse.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentRoundResponse.java new file mode 100644 index 000000000..b1b402497 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/response/AdminRecruitmentRoundResponse.java @@ -0,0 +1,26 @@ +package com.gdschongik.gdsc.domain.recruitment.dto.response; + +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.util.formatter.SemesterFormatter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record AdminRecruitmentRoundResponse( + Long recruitmentRoundId, + @Schema(description = "활동 학기") String semester, + @Schema(description = "신청 시작일") LocalDateTime startDate, + @Schema(description = "신청 종료일") LocalDateTime endDate, + @Schema(description = "모집회차 이름") String name, + @Schema(description = "차수") String round) { + + public static AdminRecruitmentRoundResponse from(RecruitmentRound recruitmentRound) { + + return new AdminRecruitmentRoundResponse( + recruitmentRound.getId(), + SemesterFormatter.format(recruitmentRound), + recruitmentRound.getPeriod().getStartDate(), + recruitmentRound.getPeriod().getEndDate(), + recruitmentRound.getName(), + recruitmentRound.getRoundType().getValue()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java new file mode 100644 index 000000000..730bc7ffd --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java @@ -0,0 +1,29 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.AdminStudyService; +import com.gdschongik.gdsc.domain.study.dto.request.StudyCreateRequest; +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.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 = "Admin Study", description = "어드민 스터디 API입니다.") +@RestController +@RequestMapping("/admin/studies") +@RequiredArgsConstructor +public class AdminStudyController { + + private final AdminStudyService adminStudyService; + + @Operation(summary = "스터디 개설", description = "수강신청을 위한 스터디를 개설합니다. 코어멤버만 스터디를 개설할 수 있습니다.") + @PostMapping + public ResponseEntity createStudy(@Valid @RequestBody StudyCreateRequest request) { + adminStudyService.createStudyAndStudyDetail(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java new file mode 100644 index 000000000..79e46d71d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java @@ -0,0 +1,45 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.StudyService; +import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; + +@Tag(name = "Study", description = "사용자 스터디 API입니다.") +@RestController +@RequestMapping("/studies") +@RequiredArgsConstructor +public class StudyController { + + private final StudyService studyService; + + @Operation(summary = "신청 가능한 스터디 조회", description = "모집 기간 중에 있는 스터디를 조회합니다.") + @GetMapping("/apply") + public ResponseEntity> getAllApplicableStudies() { + List response = studyService.getAllApplicableStudies(); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "스터디 수강신청", description = "스터디에 수강신청 합니다. 모집 기간 중이어야 하고, 이미 수강 중인 스터디가 없어야 합니다.") + @PostMapping("/apply/{studyId}") + public ResponseEntity applyStudy(@PathVariable Long studyId) { + studyService.applyStudy(studyId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 수강신청 취소", description = "수강신청을 취소합니다. 스터디 수강신청 기간 중에만 취소할 수 있습니다.") + @DeleteMapping("/apply/{studyId}") + public ResponseEntity cancelStudyApply(@PathVariable Long studyId) { + studyService.cancelStudyApply(studyId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyMentorController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyMentorController.java new file mode 100644 index 000000000..5e6f2a344 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyMentorController.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.domain.request.AssignmentCreateRequest; +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.web.bind.annotation.*; + +@Tag(name = "Mentor Study", description = "멘토 스터디 관리 API입니다.") +@RestController +@RequestMapping("/mentor/studies") +@RequiredArgsConstructor +public class StudyMentorController { + + @Operation(summary = "스터디 과제 개설", description = "멘토만 과제를 개설할 수 있습니다.") + @PutMapping("/assignment/{assignmentId}") + public ResponseEntity createStudyAssignment( + @PathVariable Long assignmentId, @Valid @RequestBody AssignmentCreateRequest request) { + return null; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java new file mode 100644 index 000000000..76b9e01e3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java @@ -0,0 +1,58 @@ +package com.gdschongik.gdsc.domain.study.application; + +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.dto.request.StudyCreateRequest; +import com.gdschongik.gdsc.domain.study.factory.StudyDomainFactory; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminStudyService { + + private final StudyRepository studyRepository; + private final MemberRepository memberRepository; + private final StudyDetailRepository studyDetailRepository; + private final StudyDomainFactory studyDomainFactory; + + @Transactional + public void createStudyAndStudyDetail(StudyCreateRequest request) { + // TODO: 멘토 권한 부여 + final Member mentor = getMemberById(request.mentorId()); + + Study study = studyDomainFactory.createNewStudy(request, mentor); + final Study savedStudy = studyRepository.save(study); + + // TODO: 레포지토리 분리 (DDD 적용) + List studyDetails = createNoneStudyDetail(savedStudy); + studyDetailRepository.saveAll(studyDetails); + + log.info("[AdminStudyService] 스터디 생성: studyId = {}", study.getId()); + } + + private List createNoneStudyDetail(Study study) { + List studyDetails = new ArrayList<>(); + + for (long i = 1; i <= study.getTotalWeek(); i++) { + studyDetails.add(studyDomainFactory.createNoneStudyDetail(study, i)); + } + return studyDetails; + } + + private Member getMemberById(Long memberId) { + return memberRepository.findById(memberId).orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java new file mode 100644 index 000000000..c0ed98955 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java @@ -0,0 +1,67 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyHistoryValidator; +import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyService { + + private final MemberUtil memberUtil; + private final StudyRepository studyRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final StudyHistoryValidator studyHistoryValidator; + + public List getAllApplicableStudies() { + return studyRepository.findAll().stream() + .filter(Study::isApplicable) + .map(StudyResponse::from) + .toList(); + } + + @Transactional + public void applyStudy(Long studyId) { + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Member currentMember = memberUtil.getCurrentMember(); + + List currentMemberStudyHistories = studyHistoryRepository.findAllByMentee(currentMember); + + studyHistoryValidator.validateApplyStudy(study, currentMemberStudyHistories); + + StudyHistory studyHistory = StudyHistory.create(currentMember, study); + studyHistoryRepository.save(studyHistory); + + log.info("[StudyService] 스터디 수강신청: studyHistoryId={}", studyHistory.getId()); + } + + @Transactional + public void cancelStudyApply(Long studyId) { + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Member currentMember = memberUtil.getCurrentMember(); + + studyHistoryValidator.validateCancelStudyApply(study); + + StudyHistory studyHistory = studyHistoryRepository + .findByMenteeAndStudy(currentMember, study) + .orElseThrow(() -> new CustomException(STUDY_HISTORY_NOT_FOUND)); + studyHistoryRepository.delete(studyHistory); + + log.info("[StudyService] 스터디 수강신청 취소: studyId={}, memberId={}", study.getId(), currentMember.getId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java new file mode 100644 index 000000000..fe5910c0d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyDetailRepository extends JpaRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java new file mode 100644 index 000000000..f706aea27 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyHistoryRepository extends JpaRepository { + + List findAllByMentee(Member member); + + Optional findByMenteeAndStudy(Member member, Study study); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java new file mode 100644 index 000000000..0f77c5045 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.study.domain.Study; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyRepository extends JpaRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java new file mode 100644 index 000000000..bea99e59b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Difficulty { + HIGH("상"), + MEDIUM("중"), + LOW("하"); + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java new file mode 100644 index 000000000..0d6639b31 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java @@ -0,0 +1,174 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.domain.study.domain.StudyType.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.common.model.BaseSemesterEntity; +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Study extends BaseSemesterEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "study_id") + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member mentor; + + @Embedded + private Period period; + + @Embedded + @AttributeOverride(name = "startDate", column = @Column(name = "application_start_date")) + @AttributeOverride(name = "endDate", column = @Column(name = "application_end_date")) + private Period applicationPeriod; + + @Comment("총 주차수") + private Long totalWeek; + + @Comment("스터디 상세 노션 링크(Text)") + @Column(columnDefinition = "TEXT") + private String notionLink; + + @Comment("스터디 한줄 소개") + private String introduction; + + @Enumerated(EnumType.STRING) + private StudyType studyType; + + @Comment("스터디 요일") + @Enumerated(EnumType.STRING) + private DayOfWeek dayOfWeek; + + @Comment("스터디 시작 시간") + private LocalTime startTime; + + @Comment("스터디 종료 시간") + private LocalTime endTime; + + @Builder(access = AccessLevel.PRIVATE) + private Study( + Integer academicYear, + SemesterType semesterType, + Member mentor, + Period period, + Period applicationPeriod, + Long totalWeek, + StudyType studyType, + DayOfWeek dayOfWeek, + LocalTime startTime, + LocalTime endTime) { + super(academicYear, semesterType); + this.mentor = mentor; + this.period = period; + this.applicationPeriod = applicationPeriod; + this.totalWeek = totalWeek; + this.studyType = studyType; + this.dayOfWeek = dayOfWeek; + this.startTime = startTime; + this.endTime = endTime; + } + + public static Study createStudy( + Integer academicYear, + SemesterType semesterType, + Member mentor, + Period period, + Period applicationPeriod, + Long totalWeek, + StudyType studyType, + DayOfWeek dayOfWeek, + LocalTime startTime, + LocalTime endTime) { + validateApplicationStartDateBeforeSessionStartDate(applicationPeriod.getStartDate(), period.getStartDate()); + validateMentorRole(mentor); + validateStudyTime(studyType, startTime, endTime); + return Study.builder() + .academicYear(academicYear) + .semesterType(semesterType) + .mentor(mentor) + .period(period) + .applicationPeriod(applicationPeriod) + .totalWeek(totalWeek) + .studyType(studyType) + .dayOfWeek(dayOfWeek) + .startTime(startTime) + .endTime(endTime) + .build(); + } + + private static void validateApplicationStartDateBeforeSessionStartDate( + LocalDateTime applicationStartDate, LocalDateTime startDate) { + if (!applicationStartDate.isBefore(startDate)) { + throw new CustomException(STUDY_APPLICATION_START_DATE_INVALID); + } + } + + private static void validateMentorRole(Member mentor) { + if (mentor.isGuest()) { + throw new CustomException(STUDY_MENTOR_IS_UNAUTHORIZED); + } + } + + private static void validateStudyTime(StudyType studyType, LocalTime studyStartTime, LocalTime studyEndTime) { + if (studyType == OFFLINE || studyType == ONLINE) { + validateOnOffLineStudyTime(studyStartTime, studyEndTime); + } + if (studyType == ASSIGNMENT) { + validateAssignmentLineStudyTime(studyStartTime, studyEndTime); + } + } + + private static void validateOnOffLineStudyTime(LocalTime studyStartTime, LocalTime studyEndTime) { + if (!(studyStartTime != null && studyEndTime != null)) { + throw new CustomException(ON_OFF_LINE_STUDY_TIME_IS_ESSENTIAL); + } else if (!studyStartTime.isBefore(studyEndTime)) { + throw new CustomException(STUDY_TIME_INVALID); + } + } + + private static void validateAssignmentLineStudyTime(LocalTime studyStartTime, LocalTime studyEndTime) { + if (!(studyStartTime == null && studyEndTime == null)) { + throw new CustomException(ASSIGNMENT_STUDY_CAN_NOT_INPUT_STUDY_TIME); + } + } + + // 데이터 전달 로직 + public boolean isApplicable() { + return applicationPeriod.isOpen(); + } + + public boolean isStudyOngoing() { + return period.isOpen(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java new file mode 100644 index 000000000..154ce8a90 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java @@ -0,0 +1,72 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; +import com.gdschongik.gdsc.domain.study.domain.vo.Session; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StudyDetail extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "study_detail_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_id") + private Study study; + + @Comment("현 주차수") + private Long week; + + private String attendanceNumber; + + @Embedded + private Period period; + + @Embedded + @AttributeOverride(name = "title", column = @Column(name = "session_title")) + @AttributeOverride(name = "difficulty", column = @Column(name = "session_difficulty")) + @AttributeOverride(name = "startAt", column = @Column(name = "session_start_at")) + @AttributeOverride(name = "description", column = @Column(name = "session_description")) + @AttributeOverride(name = "status", column = @Column(name = "session_status")) + private Session session; + + @Embedded + @AttributeOverride(name = "title", column = @Column(name = "assignment_title")) + @AttributeOverride(name = "difficulty", column = @Column(name = "assignment_difficulty")) + @AttributeOverride(name = "status", column = @Column(name = "assignment_status")) + private Assignment assignment; + + @Builder(access = AccessLevel.PRIVATE) + private StudyDetail( + Study study, Long week, String attendanceNumber, Period period, Session session, Assignment assignment) { + this.study = study; + this.week = week; + this.attendanceNumber = attendanceNumber; + this.period = period; + this.session = session; + this.assignment = assignment; + } + + public static StudyDetail createStudyDetail(Study study, Long week, String attendanceNumber, Period period) { + return StudyDetail.builder() + .study(study) + .week(week) + .period(period) + .attendanceNumber(attendanceNumber) + .period(period) + .session(Session.createEmptySession()) + .assignment(Assignment.createEmptyAssignment()) + .build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java new file mode 100644 index 000000000..a77ad9dd3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java @@ -0,0 +1,50 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.member.domain.Member; +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StudyHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "study_history_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member mentee; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_id") + private Study study; + + @Builder(access = AccessLevel.PRIVATE) + private StudyHistory(Member mentee, Study study) { + this.mentee = mentee; + this.study = study; + } + + public static StudyHistory create(Member mentee, Study study) { + return StudyHistory.builder().mentee(mentee).study(study).build(); + } + + // 데이터 전달 로직 + public boolean isStudyOngoing() { + return study.isStudyOngoing(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java new file mode 100644 index 000000000..aed358292 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java @@ -0,0 +1,40 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.List; + +@DomainService +public class StudyHistoryValidator { + + public void validateApplyStudy(Study study, List currentMemberStudyHistories) { + // 이미 해당 스터디에 수강신청한 경우 + boolean isStudyHistoryDuplicate = currentMemberStudyHistories.stream() + .anyMatch(studyHistory -> studyHistory.getStudy().equals(study)); + + if (isStudyHistoryDuplicate) { + throw new CustomException(STUDY_HISTORY_DUPLICATE); + } + + // 스터디 수강신청 기간이 아닌 경우 + if (!study.isApplicable()) { + throw new CustomException(STUDY_NOT_APPLICABLE); + } + + // 이미 듣고 있는 스터디가 있는 경우 + boolean isInOngoingStudy = currentMemberStudyHistories.stream().anyMatch(StudyHistory::isStudyOngoing); + + if (isInOngoingStudy) { + throw new CustomException(STUDY_HISTORY_ONGOING_ALREADY_EXISTS); + } + } + + public void validateCancelStudyApply(Study study) { + // 스터디 수강신청 기간이 아닌 경우 + if (!study.isApplicable()) { + throw new CustomException(STUDY_NOT_CANCELABLE_APPLICATION_PERIOD); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java new file mode 100644 index 000000000..125b88f55 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java @@ -0,0 +1,34 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.domain.common.model.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.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StudyNotification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "study_notification_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_id") + private Study study; + + private String title; + + @Column(columnDefinition = "TEXT") + private String link; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyStatus.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyStatus.java new file mode 100644 index 000000000..dc6c803a3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyStatus.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum StudyStatus { + NONE("생성"), + OPEN("개설"), + CANCELLED("휴강"); + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyType.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyType.java new file mode 100644 index 000000000..52cd23877 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyType.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum StudyType { + ASSIGNMENT("과제 스터디"), + ONLINE("온라인 세션"), + OFFLINE("오프라인 세션"); + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java new file mode 100644 index 000000000..160e492eb --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java @@ -0,0 +1,11 @@ +package com.gdschongik.gdsc.domain.study.domain.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import java.time.LocalDateTime; + +public record AssignmentCreateRequest( + @NotBlank @Schema(description = "과제 제목") String title, + @NotBlank @Schema(description = "과제 명세 노션 링크") String descriptionNotionLink, + @Future @Schema(description = "과제 마감일") LocalDateTime deadLine) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java new file mode 100644 index 000000000..c426f35d6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java @@ -0,0 +1,51 @@ +package com.gdschongik.gdsc.domain.study.domain.vo; + +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Assignment { + + private String title; + + @Comment("과제 마감 시각") + private LocalDateTime deadline; + + @Column(columnDefinition = "TEXT") + private String descriptionLink; + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; + + @Comment("과제 상태") + @Enumerated(EnumType.STRING) + private StudyStatus status; + + @Builder(access = AccessLevel.PRIVATE) + private Assignment( + String title, LocalDateTime deadline, String descriptionLink, Difficulty difficulty, StudyStatus status) { + this.title = title; + this.deadline = deadline; + this.descriptionLink = descriptionLink; + this.difficulty = difficulty; + this.status = status; + } + + public static Assignment createEmptyAssignment() { + return Assignment.builder().status(StudyStatus.NONE).build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java new file mode 100644 index 000000000..d32aba4cc --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java @@ -0,0 +1,48 @@ +package com.gdschongik.gdsc.domain.study.domain.vo; + +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Session { + + private LocalDateTime startAt; + + private String title; + + private String description; + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; + + @Comment("세션 상태") + @Enumerated(EnumType.STRING) + private StudyStatus status; + + @Builder(access = AccessLevel.PRIVATE) + private Session( + LocalDateTime startAt, String title, String description, Difficulty difficulty, StudyStatus status) { + this.startAt = startAt; + this.title = title; + this.description = description; + this.difficulty = difficulty; + this.status = status; + } + + public static Session createEmptySession() { + return Session.builder().status(StudyStatus.NONE).build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java new file mode 100644 index 000000000..cb5153e3c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java @@ -0,0 +1,32 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.study.domain.StudyType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; + +public record StudyCreateRequest( + @NotNull(message = "스터디 멘토 ID는 null이 될 수 없습니다.") @Schema(description = "스터디 멘토 ID") Long mentorId, + @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) + Integer academicYear, + @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, + @NotNull(message = "신청기간 시작일은 null이 될 수 없습니다.") @Schema(description = "신청기간 시작일", pattern = DATE) + LocalDate applicationStartDate, + @Future @NotNull(message = "신청기간 종료일은 null이 될 수 없습니다.") @Schema(description = "신청기간 종료일", pattern = DATE) + LocalDate applicationEndDate, + @Positive @NotNull(message = "총 주차수는 null이 될 수 없습니다.") @Schema(description = "총 주차수") Long totalWeek, + @Future @NotNull(message = "스터디 시작일은 null이 될 수 없습니다.") @Schema(description = "스터디 시작일", pattern = DATE) + LocalDate startDate, + @NotNull(message = "스터디 요일은 null이 될 수 없습니다.") @Schema(description = "스터디 요일", implementation = DayOfWeek.class) + DayOfWeek dayOfWeek, + @NotNull @Schema(description = "스터디 시작 시간", implementation = LocalTime.class) LocalTime studyStartTime, + @NotNull @Schema(description = "스터디 종료 시간", implementation = LocalTime.class) LocalTime studyEndTime, + @NotNull(message = "스터디 타입은 null이 될 수 없습니다.") @Schema(description = "스터디 타입", implementation = StudyType.class) + StudyType studyType) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java new file mode 100644 index 000000000..56b531e81 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java @@ -0,0 +1,50 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.Study; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +public record StudyResponse( + Long studyId, + @Schema(description = "이름") String title, + @Schema(description = "종류") String studyType, + @Schema(description = "상세설명 노션 링크") String notionLink, + @Schema(description = "한 줄 소개") String introduction, + @Schema(description = "멘토 이름") String mentorName, + @Schema(description = "스터디 시간") String schedule, + @Schema(description = "총 주차수") String totalWeek, + @Schema(description = "개강일") String openingDate) { + + public static StudyResponse from(Study study) { + // todo: 포맷터로 분리 + return new StudyResponse( + study.getId(), + study.getTitle(), + study.getStudyType().getValue(), + study.getNotionLink(), + study.getIntroduction(), + study.getMentor().getName(), + getSchedule(study.getDayOfWeek(), study.getStartTime()), + study.getTotalWeek().toString() + "주 코스", + DateTimeFormatter.ofPattern("MM.dd").format(study.getPeriod().getStartDate()) + " 개강"); + } + + private static String getSchedule(DayOfWeek dayOfWeek, LocalTime startTime) { + return getKoreanDayOfWeek(dayOfWeek) + startTime.format(DateTimeFormatter.ofPattern("HH")) + "시"; + } + + private static String getKoreanDayOfWeek(DayOfWeek dayOfWeek) { + return switch (dayOfWeek) { + case MONDAY -> "월"; + case TUESDAY -> "화"; + case WEDNESDAY -> "수"; + case THURSDAY -> "목"; + case FRIDAY -> "금"; + case SATURDAY -> "토"; + case SUNDAY -> "일"; + default -> ""; + }; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java b/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java new file mode 100644 index 000000000..3c45acb51 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java @@ -0,0 +1,44 @@ +package com.gdschongik.gdsc.domain.study.factory; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.dto.request.StudyCreateRequest; +import com.gdschongik.gdsc.global.annotation.DomainFactory; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Random; + +@DomainFactory +public class StudyDomainFactory { + + // 새로운 스터디를 생성합니다. + public Study createNewStudy(StudyCreateRequest request, Member mentor) { + LocalDate endDate = request.startDate().plusWeeks(request.totalWeek()).minusDays(1); + return Study.createStudy( + request.academicYear(), + request.semesterType(), + mentor, + Period.createPeriod(request.startDate().atStartOfDay(), endDate.atTime(LocalTime.MAX)), + Period.createPeriod( + request.applicationStartDate().atStartOfDay(), + request.applicationEndDate().atTime(LocalTime.MAX)), + request.totalWeek(), + request.studyType(), + request.dayOfWeek(), + request.studyStartTime(), + request.studyEndTime()); + } + + // 해당 주의 비어있는 스터디상세를 생성합니다. + public StudyDetail createNoneStudyDetail(Study study, Long week) { + LocalDateTime startDate = study.getPeriod().getStartDate().plusWeeks((week - 1)); + LocalDateTime endDate = startDate.plusDays(6).toLocalDate().atTime(LocalTime.MAX); + + String attendanceNumber = + new Random().ints(4, 0, 10).mapToObj(String::valueOf).reduce("", String::concat); + return StudyDetail.createStudyDetail(study, week, attendanceNumber, Period.createPeriod(startDate, endDate)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/annotation/DomainFactory.java b/src/main/java/com/gdschongik/gdsc/global/annotation/DomainFactory.java new file mode 100644 index 000000000..e0146138b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/annotation/DomainFactory.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Component; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Component +public @interface DomainFactory {} diff --git a/src/main/java/com/gdschongik/gdsc/global/annotation/DomainService.java b/src/main/java/com/gdschongik/gdsc/global/annotation/DomainService.java new file mode 100644 index 000000000..a2d9eaca0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/annotation/DomainService.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Component; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Component +public @interface DomainService {} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java index 64f467957..81aa63986 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java @@ -15,9 +15,9 @@ private DiscordConstant() {} public static final String DEFER_MESSAGE_ISSUING_CODE = "인증코드를 발급받는 중입니다..."; public static final String REPLY_MESSAGE_ISSUING_CODE = "인증코드는 %d 입니다. 인증코드는 %d분 동안 유효합니다."; - // 가입하기 커맨드 - public static final String COMMAND_NAME_JOIN = "가입하기"; - public static final String COMMAND_DESCRIPTION_JOIN = "가입 신청이 승인된 멤버에게 역할을 부여합니다."; - public static final String DEFER_MESSAGE_JOIN = "가입 신청을 처리하는 중입니다..."; - public static final String REPLY_MESSAGE_JOIN = "가입 신청이 승인되었습니다. GDSC Hongik에 합류하신 것을 환영합니다!"; + // 디스코드 ID 저장 커맨드 + public static final String COMMAND_NAME_BATCH_DISCORD_ID = "디스코드id-저장하기"; + public static final String COMMAND_DESCRIPTION_BATCH_DISCORD_ID = "디스코드 인증이 완료된 멤버들의 디스코드 ID를 저장합니다."; + public static final String DEFER_MESSAGE_BATCH_DISCORD_ID = "디스코드 ID 저장 배치 작업을 진행하는 중입니다..."; + public static final String REPLY_MESSAGE_BATCH_DISCORD_ID = "디스코드 ID 저장 배치 작업이 완료되었습니다."; } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java index 330eb0cd3..3b4ba4a6c 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java @@ -8,6 +8,7 @@ public class EmailConstant { public static final String SENDER_PERSONAL = "GDSC Hongik"; public static final String SENDER_ADDRESS = "gdsc.hongik@gmail.com"; public static final String VERIFICATION_EMAIL_SUBJECT = "GDSC Hongik 이메일 인증 코드입니다."; + public static final String TOKEN_EMAIL_NAME = "email"; private EmailConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/EnvironmentConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/EnvironmentConstant.java index fed291460..a23a67322 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/EnvironmentConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/EnvironmentConstant.java @@ -23,5 +23,6 @@ public static class Constants { public static final String DEV_ENV = "dev"; public static final String LOCAL_ENV = "local"; public static final List PROD_AND_DEV_ENV = List.of(PROD_ENV, DEV_ENV); + public static final List DEV_AND_LOCAL_ENV = List.of(DEV_ENV, LOCAL_ENV); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java index 04a4ef40d..49599aa37 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java @@ -7,12 +7,14 @@ @AllArgsConstructor public enum JwtConstant { ACCESS_TOKEN(Constants.ACCESS_TOKEN_COOKIE_NAME), - REFRESH_TOKEN(Constants.REFRESH_TOKEN_COOKIE_NAME); + REFRESH_TOKEN(Constants.REFRESH_TOKEN_COOKIE_NAME), + EMAIL_VERIFICATION_TOKEN(Constants.EMAIL_VERIFICATION_TOKEN_NAME); private final String cookieName; private static class Constants { public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + public static final String EMAIL_VERIFICATION_TOKEN_NAME = "emailVerificationToken"; } } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java index 0ca98289b..6e7d42c90 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java @@ -8,6 +8,9 @@ public class RegexConstant { public static final String NICKNAME = "[ㄱ-ㅣ가-힣]{1,6}$"; public static final String DEPARTMENT = "^D[0-9]{3}$"; public static final String HONGIK_EMAIL = "^[^\\W&=+'-+,<>]+(\\.[^\\W&=+'-+,<>]+)*@g\\.hongik\\.ac\\.kr$"; + public static final String DATETIME = "yyyy-MM-dd'T'HH:mm:ss"; + public static final String DATE = "yyyy-MM-dd"; + public static final String ACADEMIC_YEAR = "^[0-9]{4}$"; private RegexConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/TemporalConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/TemporalConstant.java new file mode 100644 index 000000000..5531ce26a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/TemporalConstant.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class TemporalConstant { + + private TemporalConstant() {} + + // 학기 준비 기간(주 단위) + public static final int PRE_SEMESTER_TERM = 2; +} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java index 07d575690..e307e0269 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java @@ -1,7 +1,5 @@ package com.gdschongik.gdsc.global.common.constant; -import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; - import java.util.List; public class UrlConstant { @@ -17,8 +15,13 @@ private UrlConstant() {} public static final String LOCAL_REACT_CLIENT_SECURE_URL = "https://localhost:3000"; public static final String LOCAL_VITE_CLIENT_URL = "http://localhost:5173"; public static final String LOCAL_VITE_CLIENT_SECURE_URL = "https://localhost:5173"; + public static final String LOCAL_PROXY_CLIENT_ONBOARDING_URL = "https://local-onboarding.gdschongik.com"; public static final List LOCAL_CLIENT_URLS = List.of( - LOCAL_REACT_CLIENT_URL, LOCAL_REACT_CLIENT_SECURE_URL, LOCAL_VITE_CLIENT_URL, LOCAL_VITE_CLIENT_SECURE_URL); + LOCAL_REACT_CLIENT_URL, + LOCAL_REACT_CLIENT_SECURE_URL, + LOCAL_VITE_CLIENT_URL, + LOCAL_VITE_CLIENT_SECURE_URL, + LOCAL_PROXY_CLIENT_ONBOARDING_URL); // 서버 URL public static final String PROD_SERVER_URL = "https://api.gdschongik.com"; diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/WorkbookConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/WorkbookConstant.java index acc45dee7..ddb4aee73 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/WorkbookConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/WorkbookConstant.java @@ -2,7 +2,7 @@ public class WorkbookConstant { public static final String ALL_MEMBER_SHEET_NAME = "전체 회원 목록"; - public static final String GRANTED_MEMBER_SHEET_NAME = "승인된 회원 목록"; + public static final String REGULAR_MEMBER_SHEET_NAME = "정회원 목록"; public static final String[] MEMBER_SHEET_HEADER = { "가입 일시", "이름", "학번", "학과", "전화번호", "이메일", "디스코드 유저네임", "커뮤니티 닉네임" }; diff --git a/src/main/java/com/gdschongik/gdsc/global/config/AuditorAwareImpl.java b/src/main/java/com/gdschongik/gdsc/global/config/AuditorAwareImpl.java new file mode 100644 index 000000000..3122fba0e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/AuditorAwareImpl.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.global.config; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.AuditorAware; + +@RequiredArgsConstructor +public class AuditorAwareImpl implements AuditorAware { + + private final MemberUtil memberUtil; + + @Override + public Optional getCurrentAuditor() { + try { + return Optional.of(memberUtil.getCurrentMemberId()); + } catch (CustomException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/DiscordConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/DiscordConfig.java index dbfb90760..4b429d9dd 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/DiscordConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/DiscordConfig.java @@ -2,7 +2,7 @@ import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; -import com.gdschongik.gdsc.global.discord.ListenerBeanPostProcessor; +import com.gdschongik.gdsc.domain.discord.application.listener.ListenerBeanPostProcessor; import com.gdschongik.gdsc.global.property.DiscordProperty; import com.gdschongik.gdsc.global.util.DiscordUtil; import java.util.Objects; @@ -13,6 +13,7 @@ import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.ChunkingFilter; import net.dv8tion.jda.api.utils.MemberCachePolicy; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -33,6 +34,7 @@ public JDA jda() { JDA jda = JDABuilder.createDefault(discordProperty.getToken()) .setActivity(Activity.playing(DISCORD_BOT_STATUS_CONTENT)) .enableIntents(GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_MEMBERS) + .setChunkingFilter(ChunkingFilter.ALL) .setMemberCachePolicy(MemberCachePolicy.ALL) .build(); @@ -40,7 +42,7 @@ public JDA jda() { Objects.requireNonNull(jda.awaitReady().getGuildById(discordProperty.getServerId())) .updateCommands() .addCommands(Commands.slash(COMMAND_NAME_ISSUING_CODE, COMMAND_DESCRIPTION_ISSUING_CODE)) - .addCommands(Commands.slash(COMMAND_NAME_JOIN, COMMAND_DESCRIPTION_JOIN)) + .addCommands(Commands.slash(COMMAND_NAME_BATCH_DISCORD_ID, COMMAND_DESCRIPTION_BATCH_DISCORD_ID)) .queue(); return jda; @@ -54,13 +56,13 @@ public ListenerBeanPostProcessor listenerBeanPostProcessor(JDA jda) { @Bean @ConditionalOnBean(JDA.class) - public DiscordUtil discordUtil(JDA jda) { - return new DiscordUtil(jda); + public DiscordUtil discordUtil(JDA jda, DiscordProperty discordProperty) { + return new DiscordUtil(jda, discordProperty); } @Bean @Order(1) public DiscordUtil fallbackDiscordUtil() { - return new DiscordUtil(null); + return new DiscordUtil(null, null); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java index eb9a05b74..b55ed4622 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java @@ -1,8 +1,21 @@ package com.gdschongik.gdsc.global.config; +import com.gdschongik.gdsc.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration @EnableJpaAuditing -public class JpaConfig {} +@RequiredArgsConstructor +public class JpaConfig { + + private final MemberUtil memberUtil; + + @Bean + public AuditorAware auditorProvider() { + return new AuditorAwareImpl(memberUtil); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java index b03a1b5d2..4b304a2c7 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java @@ -4,6 +4,7 @@ import com.gdschongik.gdsc.global.property.DiscordProperty; import com.gdschongik.gdsc.global.property.EmailProperty; import com.gdschongik.gdsc.global.property.JwtProperty; +import com.gdschongik.gdsc.global.property.PaymentProperty; import com.gdschongik.gdsc.global.property.RedisProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; @@ -13,7 +14,8 @@ RedisProperty.class, BasicAuthProperty.class, DiscordProperty.class, - EmailProperty.class + EmailProperty.class, + PaymentProperty.class }) @Configuration public class PropertyConfig {} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java index 6f7346951..077a68923 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java @@ -110,6 +110,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/onboarding/verify-email") .permitAll() + .requestMatchers("/test/**") + .permitAll() .requestMatchers("/onboarding/**") .authenticated() .requestMatchers("/admin/**") @@ -170,6 +172,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.addAllowedOriginPattern(LOCAL_REACT_CLIENT_SECURE_URL); configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_URL); configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_SECURE_URL); + configuration.addAllowedOriginPattern(LOCAL_PROXY_CLIENT_ONBOARDING_URL); + configuration.addAllowedOriginPattern(DEV_SERVER_URL); } configuration.addAllowedHeader("*"); diff --git a/src/main/java/com/gdschongik/gdsc/global/discord/listener/JoinCommandListener.java b/src/main/java/com/gdschongik/gdsc/global/discord/listener/JoinCommandListener.java deleted file mode 100644 index fad1cc598..000000000 --- a/src/main/java/com/gdschongik/gdsc/global/discord/listener/JoinCommandListener.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.gdschongik.gdsc.global.discord.listener; - -import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; - -import com.gdschongik.gdsc.domain.discord.handler.JoinCommandHandler; -import com.gdschongik.gdsc.global.discord.Listener; -import jakarta.validation.constraints.NotNull; -import lombok.RequiredArgsConstructor; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -@Listener -@RequiredArgsConstructor -public class JoinCommandListener extends ListenerAdapter { - - private final JoinCommandHandler joinCommandHandler; - - @Override - public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { - if (event.getName().equals(COMMAND_NAME_JOIN)) { - joinCommandHandler.delegate(event); - } - } -} diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java index 6d2d73c3c..843ecbbcb 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -11,6 +11,7 @@ public enum ErrorCode { METHOD_ARGUMENT_NULL(HttpStatus.BAD_REQUEST, "인자는 null이 될 수 없습니다."), METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "인자가 유효하지 않습니다."), REGEX_VIOLATION(HttpStatus.BAD_REQUEST, "정규표현식을 위반했습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), // Auth INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 JWT 토큰입니다."), @@ -25,37 +26,115 @@ public enum ErrorCode { // Parameter INVALID_QUERY_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 쿼리 파라미터입니다."), + // Money + MONEY_AMOUNT_NOT_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "금액은 null이 될 수 없습니다."), + + // Period + PERIOD_OVERLAP(HttpStatus.CONFLICT, "기간이 중복됩니다."), + // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 커뮤니티 멤버입니다."), MEMBER_DELETED(HttpStatus.CONFLICT, "탈퇴한 회원입니다."), MEMBER_FORBIDDEN(HttpStatus.CONFLICT, "차단된 회원입니다."), - MEMBER_ALREADY_GRANTED(HttpStatus.CONFLICT, "이미 승인된 회원입니다."), - MEMBER_ALREADY_VERIFIED(HttpStatus.CONFLICT, "이미 인증된 상태입니다."), + MEMBER_ALREADY_ASSOCIATE(HttpStatus.CONFLICT, "이미 준회원 역할에 해당하는 회원입니다."), + MEMBER_ALREADY_REGULAR(HttpStatus.CONFLICT, "이미 정회원 역할에 해당하는 회원입니다."), MEMBER_DISCORD_USERNAME_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 디스코드 유저네임입니다."), MEMBER_NICKNAME_DUPLICATE(HttpStatus.CONFLICT, "이미 사용중인 닉네임입니다."), MEMBER_NOT_APPLIED(HttpStatus.CONFLICT, "가입신청서를 제출하지 않은 회원입니다."), + MEMBER_NOT_ASSOCIATE(HttpStatus.CONFLICT, "준회원이 아닌 회원입니다."), // Requirement - UNIV_NOT_VERIFIED(HttpStatus.CONFLICT, "재학생 인증이 완료되지 않았습니다."), - DISCORD_NOT_VERIFIED(HttpStatus.CONFLICT, "디스코드 인증이 완료되지 않았습니다."), - PAYMENT_NOT_VERIFIED(HttpStatus.CONFLICT, "회비 납부가 완료되지 않았습니다."), - BEVY_NOT_VERIFIED(HttpStatus.CONFLICT, "GDSC Bevy 가입이 완료되지 않았습니다."), + UNIV_NOT_SATISFIED(HttpStatus.CONFLICT, "재학생 인증이 완료되지 않았습니다."), + DISCORD_NOT_SATISFIED(HttpStatus.CONFLICT, "디스코드 인증이 완료되지 않았습니다."), + BEVY_NOT_SATISFIED(HttpStatus.CONFLICT, "GDSC Bevy 가입이 완료되지 않았습니다."), + EMAIL_ALREADY_SATISFIED(HttpStatus.CONFLICT, "이미 이메일 인증된 회원입니다."), + BASIC_INFO_NOT_SATISFIED(HttpStatus.CONFLICT, "기본 회원정보 작성이 완료되지 않았습니다."), // Univ Email Verification - UNIV_EMAIL_ALREADY_VERIFIED(HttpStatus.CONFLICT, "이미 가입된 재학생 메일입니다."), + UNIV_EMAIL_ALREADY_SATISFIED(HttpStatus.CONFLICT, "이미 가입된 재학생 메일입니다."), UNIV_EMAIL_FORMAT_MISMATCH(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 재학생 메일입니다."), UNIV_EMAIL_DOMAIN_MISMATCH(HttpStatus.BAD_REQUEST, "재학생 메일의 도메인이 맞지 않습니다."), MESSAGING_EXCEPTION(HttpStatus.BAD_REQUEST, "수신자 이메일이 올바르지 않습니다."), VERIFICATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "재학생 인증 코드가 존재하지 않습니다."), + EMAIL_NOT_SENT(HttpStatus.BAD_REQUEST, "재학생 인증 메일이 발송되지 않았습니다."), + EXPIRED_EMAIL_VERIFICATION_TOKEN(HttpStatus.BAD_REQUEST, "이메일 인증 토큰이 만료되었습니다."), + INVALID_EMAIL_VERIFICATION_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 이메일 인증 토큰입니다."), // Discord DISCORD_INVALID_CODE_RANGE(HttpStatus.INTERNAL_SERVER_ERROR, "디스코드 인증코드는 4자리 숫자여야 합니다."), DISCORD_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저네임으로 발급된 디스코드 인증코드가 존재하지 않습니다."), DISCORD_CODE_MISMATCH(HttpStatus.CONFLICT, "디스코드 인증코드가 일치하지 않습니다."), - DISCORD_ROLE_UNASSIGNABLE(HttpStatus.INTERNAL_SERVER_ERROR, "디스코드 역할 부여가 불가능합니다. 가입 조건을 확인해주세요."), DISCORD_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND, "디스코드 역할을 찾을 수 없습니다."), DISCORD_NOT_SIGNUP(HttpStatus.INTERNAL_SERVER_ERROR, "아직 가입신청서를 작성하지 않은 회원입니다."), DISCORD_NICKNAME_NOTNULL(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임은 빈 값이 될 수 없습니다."), + DISCORD_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "디스코드 멤버를 찾을 수 없습니다."), + + // Membership + PAYMENT_NOT_SATISFIED(HttpStatus.CONFLICT, "회비 납부가 완료되지 않았습니다."), + MEMBERSHIP_NOT_APPLICABLE(HttpStatus.CONFLICT, "멤버십 가입을 신청할 수 없는 회원입니다."), + MEMBERSHIP_ALREADY_SUBMITTED(HttpStatus.CONFLICT, "이미 이번 학기에 멤버십 가입을 신청한 회원입니다."), + MEMBERSHIP_ALREADY_SATISFIED(HttpStatus.CONFLICT, "이미 이번 학기에 정회원 승급을 완료한 회원입니다."), + MEMBERSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 멤버십이 존재하지 않습니다."), + MEMBERSHIP_RECRUITMENT_ROUND_NOT_OPEN(HttpStatus.CONFLICT, "리크루팅 회차 모집기간이 아닙니다."), + + // Recruitment + DATE_PRECEDENCE_INVALID(HttpStatus.BAD_REQUEST, "종료일이 시작일과 같거나 앞설 수 없습니다."), + RECRUITMENT_OVERLAP(HttpStatus.BAD_REQUEST, "해당 학기에 이미 리크루팅이 존재합니다."), + RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "리크루팅이 존재하지 않습니다."), + RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS(HttpStatus.BAD_REQUEST, "모집 시작일과 종료일이 학기 시작일로부터 2주 이내에 있지 않습니다."), + + // RecruitmentRound + RECRUITMENT_ROUND_NOT_FOUND(HttpStatus.NOT_FOUND, "모집회차가 존재하지 않습니다."), + RECRUITMENT_ROUND_TYPE_OVERLAP(HttpStatus.BAD_REQUEST, "모집 차수가 중복됩니다."), + RECRUITMENT_ROUND_STARTDATE_ALREADY_PASSED(HttpStatus.BAD_REQUEST, "이미 모집 시작일이 지난 모집회차입니다."), + ROUND_ONE_DOES_NOT_EXIST(HttpStatus.CONFLICT, "1차 모집이 존재하지 않습니다."), + RECRUITMENT_ROUND_OPEN_NOT_FOUND(HttpStatus.NOT_FOUND, "진행중인 모집회차가 존재하지 않습니다."), + + // Coupon + COUPON_DISCOUNT_AMOUNT_NOT_POSITIVE(HttpStatus.CONFLICT, "쿠폰의 할인 금액은 0보다 커야 합니다."), + COUPON_NOT_USABLE_ALREADY_USED(HttpStatus.CONFLICT, "이미 사용한 쿠폰은 사용할 수 없습니다."), + COUPON_NOT_USABLE_REVOKED(HttpStatus.CONFLICT, "회수된 쿠폰은 사용할 수 없습니다."), + COUPON_NOT_REVOKABLE_ALREADY_REVOKED(HttpStatus.CONFLICT, "이미 회수된 쿠폰은 다시 회수할 수 없습니다."), + COUPON_NOT_REVOKABLE_ALREADY_USED(HttpStatus.CONFLICT, "이미 사용한 쿠폰은 회수할 수 없습니다."), + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 쿠폰입니다."), + ISSUED_COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 발급쿠폰입니다."), + + // Study + STUDY_APPLICATION_START_DATE_INVALID(HttpStatus.CONFLICT, "스터디 신청기간 시작일이 스터디 시작일보다 빠릅니다."), + STUDY_MENTOR_IS_UNAUTHORIZED(HttpStatus.CONFLICT, "게스트인 회원은 멘토로 지정할 수 없습니다."), + ON_OFF_LINE_STUDY_TIME_IS_ESSENTIAL(HttpStatus.CONFLICT, "온오프라인 스터디는 스터디 시간이 필요합니다."), + STUDY_TIME_INVALID(HttpStatus.CONFLICT, "스터디종료 시각이 스터디시작 시각보다 빠릅니다."), + ASSIGNMENT_STUDY_CAN_NOT_INPUT_STUDY_TIME(HttpStatus.CONFLICT, "과제 스터디는 스터디 시간을 입력할 수 없습니다."), + STUDY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디입니다."), + STUDY_NOT_APPLICABLE(HttpStatus.CONFLICT, "스터디 신청기간이 아닙니다."), + STUDY_NOT_CANCELABLE_APPLICATION_PERIOD(HttpStatus.CONFLICT, "스터디 신청기간이 아니라면 취소할 수 없습니다."), + + // StudyHistory + STUDY_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 수강 기록입니다."), + STUDY_HISTORY_DUPLICATE(HttpStatus.CONFLICT, "이미 해당 스터디를 신청했습니다."), + STUDY_HISTORY_ONGOING_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 진행중인 스터디가 있습니다."), + + // Order + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문이 존재하지 않습니다."), + ORDER_MEMBERSHIP_MEMBER_MISMATCH(HttpStatus.CONFLICT, "주문 대상 멤버십의 멤버와 현재 로그인한 멤버가 일치하지 않습니다."), + ORDER_MEMBERSHIP_ALREADY_PAID(HttpStatus.CONFLICT, "주문 대상 멤버십의 회비가 이미 납부되었습니다."), + ORDER_RECRUITMENT_PERIOD_INVALID(HttpStatus.CONFLICT, "주문 대상 멤버십의 리크루팅의 지원기간이 아닙니다."), + ORDER_ISSUED_COUPON_MEMBER_MISMATCH(HttpStatus.CONFLICT, "주문 시 사용할 발급쿠폰의 멤버와 현재 로그인한 멤버가 일치하지 않습니다."), + ORDER_TOTAL_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 금액은 리쿠르팅 회비와 일치해야 합니다."), + ORDER_DISCOUNT_AMOUNT_NOT_ZERO(HttpStatus.CONFLICT, "쿠폰 미사용시 할인 금액은 0이어야 합니다."), + ORDER_DISCOUNT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "쿠폰 사용시 할인 금액은 쿠폰의 할인 금액과 일치해야 합니다."), + ORDER_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 완료된 주문입니다."), + ORDER_COMPLETE_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액이 주문완료요청의 결제금액과 일치하지 않습니다."), + ORDER_COMPLETE_MEMBER_MISMATCH(HttpStatus.CONFLICT, "주문자와 현재 로그인한 멤버가 일치하지 않습니다."), + ORDER_COMPLETED_PAID_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문이거나, 완료되지 않은 유료 주문입니다."), + ORDER_CANCEL_NOT_COMPLETED(HttpStatus.CONFLICT, "완료되지 않은 주문은 취소할 수 없습니다."), + ORDER_CANCEL_FREE_ORDER(HttpStatus.CONFLICT, "무료 주문은 취소할 수 없습니다."), + ORDER_CANCEL_RESPONSE_NOT_FOUND( + HttpStatus.INTERNAL_SERVER_ERROR, "주문 결제가 취소되었지만, 응답에 취소 정보가 존재하지 않습니다. 관리자에게 문의 바랍니다."), + ORDER_FREE_FINAL_PAYMENT_NOT_ZERO(HttpStatus.CONFLICT, "무료 주문의 최종결제금액은 0원이어야 합니다."), + + // Order - MoneyInfo + ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액은 주문총액에서 할인금액을 뺀 값이어야 합니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorResponse.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorResponse.java index 03acec347..c04ac7d18 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorResponse.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorResponse.java @@ -8,4 +8,8 @@ public static ErrorResponse of(ErrorCode errorCode) { public static ErrorResponse of(ErrorCode errorCode, String errorMessage) { return new ErrorResponse(errorCode.name(), errorMessage); } + + public static ErrorResponse of(String errorCodeName, String errorMessage) { + return new ErrorResponse(errorCodeName, errorMessage); + } } diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/GlobalExceptionHandler.java b/src/main/java/com/gdschongik/gdsc/global/exception/GlobalExceptionHandler.java index 86168133e..b6ba65e68 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.gdschongik.gdsc.global.exception; +import com.gdschongik.gdsc.infra.feign.payment.error.CustomPaymentException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; @@ -20,6 +21,12 @@ public ResponseEntity handleCustomException(CustomException e) { return ResponseEntity.status(e.getErrorCode().getStatus()).body(ErrorResponse.of(e.getErrorCode())); } + @ExceptionHandler(CustomPaymentException.class) + public ResponseEntity handleCustomPaymentException(CustomPaymentException e) { + log.error("CustomPaymentException : {}, {}", e.getCode(), e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(ErrorResponse.of(e.getCode(), e.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("INTERNAL_SERVER_ERROR : {}", e.getMessage(), e); diff --git a/src/main/java/com/gdschongik/gdsc/global/property/DiscordProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/DiscordProperty.java index 860537b14..504dddc03 100644 --- a/src/main/java/com/gdschongik/gdsc/global/property/DiscordProperty.java +++ b/src/main/java/com/gdschongik/gdsc/global/property/DiscordProperty.java @@ -12,4 +12,5 @@ public class DiscordProperty { private final String token; private final String serverId; private final String commandChannelId; + private final String adminChannelId; } diff --git a/src/main/java/com/gdschongik/gdsc/global/property/PaymentProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/PaymentProperty.java new file mode 100644 index 000000000..b81d39b28 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/PaymentProperty.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.global.property; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "toss") +public class PaymentProperty { + private final String secretKey; +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java index 69e2c0781..b363fcce0 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java @@ -17,8 +17,8 @@ public class CustomOAuth2User extends DefaultOAuth2User { public CustomOAuth2User(OAuth2User oAuth2User, Member member) { super(oAuth2User.getAuthorities(), oAuth2User.getAttributes(), GITHUB_NAME_ATTR_KEY); - this.memberId = member.getId(); - this.memberRole = member.getRole(); - this.landingStatus = LandingStatus.of(member); + memberId = member.getId(); + memberRole = member.getRole(); + landingStatus = LandingStatus.TO_DASHBOARD; } } diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java index de32a49f7..02d6e822d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java @@ -71,11 +71,9 @@ protected String determineTargetUrl(HttpServletRequest request, HttpServletRespo } private void validateBaseUri(String baseUri) { - if (baseUri.endsWith(ROOT_DOMAIN) || LOCAL_CLIENT_URLS.contains(baseUri)) { - return; + if (!baseUri.endsWith(ROOT_DOMAIN) && !LOCAL_CLIENT_URLS.contains(baseUri)) { + log.error("허용되지 않은 BASE URI로의 리다이렉트 요청 발생: {}", baseUri); + throw new CustomException(NOT_ALLOWED_BASE_URI); } - - log.error("허용되지 않은 BASE URI로의 리다이렉트 요청 발생: {}", baseUri); - throw new CustomException(NOT_ALLOWED_BASE_URI); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java index 076c317b7..0edf0fed8 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java @@ -29,7 +29,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } private Member fetchOrCreate(OAuth2User oAuth2User) { - return memberRepository.findNormalByOauthId(oAuth2User.getName()).orElseGet(() -> registerMember(oAuth2User)); + return memberRepository.findByOauthId(oAuth2User.getName()).orElseGet(() -> registerMember(oAuth2User)); } private Member registerMember(OAuth2User oAuth2User) { diff --git a/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java b/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java index 98901a2c1..01aa94fb5 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java @@ -1,55 +1,5 @@ package com.gdschongik.gdsc.global.security; -import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.MemberRole; -import java.time.LocalDate; -import java.time.LocalDateTime; - public enum LandingStatus { - ONBOARDING_NOT_OPENED, // 대기 페이지로 랜딩 - ONBOARDING_CLOSED, // 모집 기간 마감 - TO_STUDENT_AUTHENTICATION, // 재학생 인증 페이지로 랜딩 - TO_REGISTRATION, // 가입신청 페이지로 랜딩 - TO_DASHBOARD, // 대시보드로 랜딩 - ; - - public static LandingStatus of(Member member) { - // 1차 모집기간 종료 ~ 2차 모집기간 시작 사이 가입했고, 현재는 2차 모집기간이 아닐 때 대기 페이지로 랜딩 - if (member.getCreatedAt().isAfter(Constants.FIRST_RECRUITMENT_END_DATE.atStartOfDay()) - && member.getCreatedAt().isBefore(Constants.SECOND_RECRUITMENT_START_DATE.atStartOfDay()) - && LocalDateTime.now().isBefore(Constants.SECOND_RECRUITMENT_START_DATE.atStartOfDay())) { - return ONBOARDING_NOT_OPENED; - } - - // 2차 모집기간 종료일 12시 30분 이후, 신청서 미제출 상태면 마감 페이지로 랜딩 - if (LocalDateTime.now().isAfter(Constants.SECOND_RECRUITMENT_END_DATE.atTime(0, 30)) && !member.isApplied()) { - return ONBOARDING_CLOSED; - } - - // 2차 모집기간 종료일 1시 이후, Guest를 마감 페이지로 랜딩. - if (LocalDateTime.now().isAfter(Constants.SECOND_RECRUITMENT_END_DATE.atTime(1, 0)) - && member.getRole().equals(MemberRole.GUEST)) { - return ONBOARDING_CLOSED; - } - - // 아직 재학생 인증을 하지 않았다면 재학생 인증 페이지로 랜딩 - if (!member.getRequirement().isUnivVerified()) { - return TO_STUDENT_AUTHENTICATION; - } - - // 재학생 인증은 했지만 가입신청을 하지 않았다면 가입신청 페이지로 랜딩 - // 가입신청 여부는 학번 존재여부로 판단 - if (!member.isApplied()) { - return TO_REGISTRATION; - } - - // 재학생 인증과 가입신청을 모두 완료했다면 대시보드로 랜딩 - return TO_DASHBOARD; - } - - private static class Constants { - private static final LocalDate FIRST_RECRUITMENT_END_DATE = LocalDate.of(2024, 3, 2); - private static final LocalDate SECOND_RECRUITMENT_START_DATE = LocalDate.of(2024, 3, 4); - private static final LocalDate SECOND_RECRUITMENT_END_DATE = LocalDate.of(2024, 3, 9); - } + TO_DASHBOARD; } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/DiscordUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/DiscordUtil.java index 3a9b3806c..ef64cbeae 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/DiscordUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/DiscordUtil.java @@ -1,19 +1,49 @@ package com.gdschongik.gdsc.global.util; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + import com.gdschongik.gdsc.global.exception.CustomException; -import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.property.DiscordProperty; +import java.util.Optional; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; @RequiredArgsConstructor public class DiscordUtil { private final JDA jda; + private final DiscordProperty discordProperty; public Role findRoleByName(String roleName) { return jda.getRolesByName(roleName, true).stream() .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.DISCORD_ROLE_NOT_FOUND)); + .orElseThrow(() -> new CustomException(DISCORD_ROLE_NOT_FOUND)); + } + + public Guild getCurrentGuild() { + return jda.getGuildById(discordProperty.getServerId()); + } + + public TextChannel getAdminChannel() { + return jda.getTextChannelById(discordProperty.getAdminChannelId()); + } + + public Optional getOptionalMemberByUsername(String username) { + return getCurrentGuild().getMembersByName(username, true).stream().findFirst(); + } + + public Member getMemberById(String discordId) { + return Optional.ofNullable(getCurrentGuild().getMemberById(discordId)) + .orElseThrow(() -> new CustomException(DISCORD_MEMBER_NOT_FOUND)); + } + + public String getMemberIdByUsername(String username) { + return getOptionalMemberByUsername(username) + .orElseThrow(() -> new CustomException(DISCORD_MEMBER_NOT_FOUND)) + .getId(); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java index 76b0e6328..98cf71ca6 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java @@ -32,6 +32,10 @@ public boolean isProdAndDevProfile() { return getActiveProfiles().anyMatch(PROD_AND_DEV_ENV::contains); } + public boolean isDevAndLocalProfile() { + return getActiveProfiles().anyMatch(DEV_AND_LOCAL_ENV::contains); + } + private Stream getActiveProfiles() { return Stream.of(environment.getActiveProfiles()); } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/ExcelUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/ExcelUtil.java index e45100ed0..cd9dcf5b1 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/ExcelUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/ExcelUtil.java @@ -28,7 +28,7 @@ public class ExcelUtil { public byte[] createMemberExcel() throws IOException { HSSFWorkbook workbook = new HSSFWorkbook(); createSheet(workbook, ALL_MEMBER_SHEET_NAME, null); - createSheet(workbook, GRANTED_MEMBER_SHEET_NAME, USER); + createSheet(workbook, REGULAR_MEMBER_SHEET_NAME, REGULAR); return createByteArray(workbook); } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java index de557dfdd..7b1f608f5 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java @@ -1,6 +1,6 @@ package com.gdschongik.gdsc.global.util; -import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.TOKEN_ROLE_NAME; import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/EmailVerificationTokenUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/email/EmailVerificationTokenUtil.java new file mode 100644 index 000000000..535a91fc0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/EmailVerificationTokenUtil.java @@ -0,0 +1,75 @@ +package com.gdschongik.gdsc.global.util.email; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.TOKEN_EMAIL_NAME; + +import com.gdschongik.gdsc.domain.email.dto.request.EmailVerificationTokenDto; +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.property.JwtProperty; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EmailVerificationTokenUtil { + + private final JwtProperty jwtProperty; + + public String generateEmailVerificationToken(Long memberId, String email) { + Date issuedAt = new Date(); + JwtProperty.TokenProperty emailVerificationTokenProperty = + jwtProperty.getToken().get(JwtConstant.EMAIL_VERIFICATION_TOKEN); + Date expiredAt = new Date(issuedAt.getTime() + emailVerificationTokenProperty.expirationMilliTime()); + Key key = getKey(); + + return buildToken(memberId, email, issuedAt, expiredAt, key); + } + + public EmailVerificationTokenDto parseEmailVerificationTokenDto(String emailVerificationTokenValue) + throws ExpiredJwtException { + try { + Jws claims = Jwts.parserBuilder() + .requireIssuer(jwtProperty.getIssuer()) + .setSigningKey(getKey()) + .build() + .parseClaimsJws(emailVerificationTokenValue); + + return new EmailVerificationTokenDto( + Long.parseLong(claims.getBody().getSubject()), + claims.getBody().get(TOKEN_EMAIL_NAME, String.class)); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.EXPIRED_EMAIL_VERIFICATION_TOKEN); + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_EMAIL_VERIFICATION_TOKEN); + } + } + + private String buildToken(Long memberId, String email, Date issuedAt, Date expiredAt, Key key) { + JwtBuilder jwtBuilder = Jwts.builder() + .claim(TOKEN_EMAIL_NAME, email) + .setIssuer(jwtProperty.getIssuer()) + .setSubject(memberId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key); + + return jwtBuilder.compact(); + } + + private Key getKey() { + return Keys.hmacShaKeyFor(jwtProperty + .getToken() + .get(JwtConstant.EMAIL_VERIFICATION_TOKEN) + .secret() + .getBytes()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java b/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java deleted file mode 100644 index fe0545b3d..000000000 --- a/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.gdschongik.gdsc.global.util.email; - -import static com.gdschongik.gdsc.global.common.constant.EmailConstant.HONGIK_UNIV_MAIL_DOMAIN; -import static com.gdschongik.gdsc.global.common.constant.RegexConstant.HONGIK_EMAIL; - -import com.gdschongik.gdsc.global.exception.CustomException; -import com.gdschongik.gdsc.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class HongikUnivEmailValidator { - - public void validate(String email) { - if (!email.contains(HONGIK_UNIV_MAIL_DOMAIN)) { - throw new CustomException(ErrorCode.UNIV_EMAIL_DOMAIN_MISMATCH); - } - - if (!email.matches(HONGIK_EMAIL)) { - throw new CustomException(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH); - } - } -} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java deleted file mode 100644 index 28c138d3e..000000000 --- a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.gdschongik.gdsc.global.util.email; - -import org.apache.commons.lang3.RandomStringUtils; -import org.springframework.stereotype.Component; - -@Component -public class VerificationCodeGenerator { - - private static final int VERIFICATION_CODE_LENGTH = 16; - private static final char RANGE_START_CHAR = '0'; - private static final char RANGE_END_CHAR = 'z'; - - public String generate() { - return RandomStringUtils.random(VERIFICATION_CODE_LENGTH, RANGE_START_CHAR, RANGE_END_CHAR, true, true); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java index 39452a0d9..8a97ee7ec 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java @@ -18,9 +18,9 @@ public class VerificationLinkUtil { private final EnvironmentUtil environmentUtil; - public String createLink(String verificationCode) { + public String createLink(String verificationToken) { String verifyEmailApiEndpoint = String.format(VERIFY_EMAIL_API_ENDPOINT, VERIFY_EMAIL_REQUEST_PARAMETER_KEY); - return getClientUrl() + verifyEmailApiEndpoint + verificationCode; + return getClientUrl() + verifyEmailApiEndpoint + verificationToken; } private String getClientUrl() { diff --git a/src/main/java/com/gdschongik/gdsc/global/util/formatter/MoneyFormatter.java b/src/main/java/com/gdschongik/gdsc/global/util/formatter/MoneyFormatter.java new file mode 100644 index 000000000..a7f7c4f37 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/formatter/MoneyFormatter.java @@ -0,0 +1,18 @@ +package com.gdschongik.gdsc.global.util.formatter; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import java.text.NumberFormat; +import java.util.Locale; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MoneyFormatter { + + public static final NumberFormat KOREA_NUMBER_FORMAT = NumberFormat.getNumberInstance(Locale.KOREA); + + public static String format(@NonNull Money money) { + return KOREA_NUMBER_FORMAT.format(money.getAmount()) + "원"; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/formatter/PhoneFormatter.java b/src/main/java/com/gdschongik/gdsc/global/util/formatter/PhoneFormatter.java new file mode 100644 index 000000000..1fd675919 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/formatter/PhoneFormatter.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.global.util.formatter; + +import jakarta.annotation.Nullable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PhoneFormatter { + @Nullable public static String format(@Nullable String phone) { + if (phone == null) return null; + return phone.substring(0, 3) + '-' + phone.substring(3, 7) + '-' + phone.substring(7, 11); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/formatter/SemesterFormatter.java b/src/main/java/com/gdschongik/gdsc/global/util/formatter/SemesterFormatter.java new file mode 100644 index 000000000..bf798303b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/formatter/SemesterFormatter.java @@ -0,0 +1,21 @@ +package com.gdschongik.gdsc.global.util.formatter; + +import com.gdschongik.gdsc.domain.common.model.BaseSemesterEntity; +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SemesterFormatter { + public static String format(BaseSemesterEntity semesterEntity) { + return format(semesterEntity.getAcademicYear(), semesterEntity.getSemesterType()); + } + + public static String format(Integer academicYear, SemesterType semesterType) { + return academicYear + "-" + semesterType.getValue(); + } + + public static String formatType(BaseSemesterEntity semesterEntity) { + return semesterEntity.getSemesterType().getValue() + "학기"; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/global/config/FeignConfig.java b/src/main/java/com/gdschongik/gdsc/infra/feign/global/config/FeignConfig.java new file mode 100644 index 000000000..be4d6dde6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/global/config/FeignConfig.java @@ -0,0 +1,44 @@ +package com.gdschongik.gdsc.infra.feign.global.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import feign.Logger; +import feign.codec.Decoder; +import feign.jackson.JacksonDecoder; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignFormatterRegistrar; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; + +@Configuration +@EnableFeignClients("com.gdschongik.gdsc.infra") +public class FeignConfig { + + @Bean + public Decoder feignDecoder() { + return new JacksonDecoder(customObjectMapper()); + } + + public ObjectMapper customObjectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL); + } + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } + + @Bean + public FeignFormatterRegistrar dateTimeFormatterRegistrar() { + return registry -> { + var registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(registry); + }; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/client/PaymentClient.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/client/PaymentClient.java new file mode 100644 index 000000000..ed75cc09b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/client/PaymentClient.java @@ -0,0 +1,25 @@ +package com.gdschongik.gdsc.infra.feign.payment.client; + +import com.gdschongik.gdsc.infra.feign.payment.config.PaymentClientConfig; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentCancelRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentConfirmRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.response.PaymentResponse; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +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; + +@FeignClient(name = "paymentClient", url = "https://api.tosspayments.com", configuration = PaymentClientConfig.class) +public interface PaymentClient { + + @PostMapping("/v1/payments/confirm") + PaymentResponse confirm(@Valid @RequestBody PaymentConfirmRequest request); + + @GetMapping("/v1/payments/{paymentKey}") + PaymentResponse getPayment(@PathVariable String paymentKey); + + @PostMapping("/v1/payments/{paymentKey}/cancel") + PaymentResponse cancelPayment(@PathVariable String paymentKey, @Valid @RequestBody PaymentCancelRequest request); +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/BasicAuthConfig.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/BasicAuthConfig.java new file mode 100644 index 000000000..dce58b830 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/BasicAuthConfig.java @@ -0,0 +1,17 @@ +package com.gdschongik.gdsc.infra.feign.payment.config; + +import com.gdschongik.gdsc.global.property.PaymentProperty; +import feign.auth.BasicAuthRequestInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; + +@RequiredArgsConstructor +public class BasicAuthConfig { + + private final PaymentProperty paymentProperty; + + @Bean + public BasicAuthRequestInterceptor basicAuthRequestInterceptor() { + return new BasicAuthRequestInterceptor(paymentProperty.getSecretKey(), ""); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/PaymentClientConfig.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/PaymentClientConfig.java new file mode 100644 index 000000000..a327a3c1c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/config/PaymentClientConfig.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.infra.feign.payment.config; + +import com.gdschongik.gdsc.infra.feign.payment.error.PaymentErrorDecoder; +import org.springframework.context.annotation.Import; + +@Import({BasicAuthConfig.class, PaymentErrorDecoder.class}) +public class PaymentClientConfig {} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentCancelRequest.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentCancelRequest.java new file mode 100644 index 000000000..fb7c4ec3b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentCancelRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.infra.feign.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentCancelRequest(@NotBlank String cancelReason) {} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentConfirmRequest.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentConfirmRequest.java new file mode 100644 index 000000000..9594720e3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/request/PaymentConfirmRequest.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.infra.feign.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record PaymentConfirmRequest( + @NotBlank String paymentKey, @NotBlank @Size(min = 21, max = 21) String orderId, @Positive Long amount) {} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/response/PaymentResponse.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/response/PaymentResponse.java new file mode 100644 index 000000000..44774eb1d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/dto/response/PaymentResponse.java @@ -0,0 +1,100 @@ +package com.gdschongik.gdsc.infra.feign.payment.dto.response; + +import jakarta.annotation.Nullable; +import java.time.ZonedDateTime; +import java.util.List; + +public record PaymentResponse( + String version, + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Long totalAmount, + Long balanceAmount, + String status, + ZonedDateTime requestedAt, + ZonedDateTime approvedAt, + Boolean useEscrow, + @Nullable String lastTransactionKey, + Long suppliedAmount, + Long vat, + Boolean cultureExpense, + Long taxFreeAmount, + Long taxExemtionAmount, + @Nullable List cancels, + Boolean isPartialCancelable, + @Nullable CardDto card, + @Nullable TransferDto transfer, + @Nullable ReceiptDto receipt, + @Nullable CheckoutDto checkout, + @Nullable EasyPayDto easyPay, + String country, + @Nullable FailureDto failure, + @Nullable CashReceiptDto cashReceipt, + @Nullable List cashReceipts) { + // TODO: enum 관련 매핑 여부 검토 + public record CancelDto( + Long cancelAmount, + String cancelReason, + Long taxFreeAmount, + Long refundableAmount, + Long easyPayDiscountAmount, + ZonedDateTime canceledAt, + String transactionKey, + @Nullable String receiptKey, + String cancelStatus, + @Nullable String cancelRequestId) {} + + public record CardDto( + Long amount, + String issuerCode, + @Nullable String acquirerCode, + String number, + Integer installmentPlanMonths, + String approveNo, + Boolean useCardPoint, + String cardType, + String ownerType, + String acquireStatus, + Boolean isInterestFree, + @Nullable String interestPayer) {} + + public record TransferDto(String bankCode, String settlementStatus) {} + + public record ReceiptDto(String url) {} + + public record CheckoutDto(String url) {} + + public record EasyPayDto(String provider, Long amount, Long discountAmount) {} + + public record FailureDto(String code, String message) {} + + public record CashReceiptDto( + String type, + String receiptKey, + String issueNumber, + String receiptUrl, + Long amount, + Long taxFreeAmount, + Long taxExemptionAmount) {} + + public record CashReceiptsDto( + String receiptKey, + String orderId, + String orderName, + String type, + String issueNumber, + String receiptUrl, + String businessNumber, + String transactionType, + Integer amount, + Integer taxFreeAmount, + String issueStatus, + Object failure, + String customerIdentityNumber, + ZonedDateTime requestedAt) {} +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/CustomPaymentException.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/CustomPaymentException.java new file mode 100644 index 000000000..f4f65c5d9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/CustomPaymentException.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.infra.feign.payment.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomPaymentException extends RuntimeException { + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDecoder.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDecoder.java new file mode 100644 index 000000000..5e995bcf9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDecoder.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.infra.feign.payment.error; + +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Response; +import feign.codec.ErrorDecoder; +import java.io.IOException; + +public class PaymentErrorDecoder implements ErrorDecoder { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ErrorDecoder defaultErrorDecoder = new Default(); + + @Override + public Exception decode(String methodKey, Response response) { + try { + var paymentErrorDto = objectMapper.readValue(response.body().asInputStream(), PaymentErrorDto.class); + return new CustomPaymentException(response.status(), paymentErrorDto.code(), paymentErrorDto.message()); + } catch (IOException e) { + return defaultErrorDecoder.decode(methodKey, response); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDto.java b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDto.java new file mode 100644 index 000000000..499cd9253 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/feign/payment/error/PaymentErrorDto.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.infra.feign.payment.error; + +public record PaymentErrorDto(String code, String message) {} diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml index b30468ace..dc90c984e 100644 --- a/src/main/resources/application-datasource.yml +++ b/src/main/resources/application-datasource.yml @@ -7,3 +7,8 @@ spring: url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE} username: ${MYSQL_USER} password: ${MYSQL_PASSWORD} + jpa: + properties: + hibernate: + create_empty_composites: + enabled: true diff --git a/src/main/resources/application-discord.yml b/src/main/resources/application-discord.yml index 7acb534b9..9106452cc 100644 --- a/src/main/resources/application-discord.yml +++ b/src/main/resources/application-discord.yml @@ -2,3 +2,4 @@ discord: token: ${DISCORD_BOT_TOKEN:} server-id: ${DISCORD_SERVER_ID:} command-channel-id: ${DISCORD_COMMAND_CHANNEL_ID:} + admin-channel-id: ${DISCORD_ADMIN_CHANNEL_ID:} diff --git a/src/main/resources/application-payment.yml b/src/main/resources/application-payment.yml new file mode 100644 index 000000000..8237f9de3 --- /dev/null +++ b/src/main/resources/application-payment.yml @@ -0,0 +1,2 @@ +toss: + secret-key: ${PAYMENT_TOSS_SECRET_KEY:} diff --git a/src/main/resources/application-security.yml b/src/main/resources/application-security.yml index bbdb261ff..d01bda4d2 100644 --- a/src/main/resources/application-security.yml +++ b/src/main/resources/application-security.yml @@ -20,6 +20,9 @@ jwt: REFRESH_TOKEN: secret: ${JWT_REFRESH_TOKEN_SECRET:} expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800} + EMAIL_VERIFICATION_TOKEN: + secret: ${JWT_EMAIL_VERIFICATION_TOKEN_SECRET:} + expiration-time: ${JWT_EMAIL_VERIFICATION_TOKEN_EXPIRATION_TIME:1800} issuer: ${JWT_ISSUER:} auth: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e82c6f9d9..899546814 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,7 +11,9 @@ spring: - actuator - discord - email + - payment logging: level: com.gdschongik.gdsc.domain.*.api.*: debug + com.gdschongik.gdsc.infra.feign: debug diff --git a/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java index 8e827c08c..f4e1eb55d 100644 --- a/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java +++ b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java @@ -1,12 +1,20 @@ package com.gdschongik.gdsc.config; -import com.gdschongik.gdsc.global.config.RedisConfig; -import com.gdschongik.gdsc.global.property.RedisProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Import; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; -@TestConfiguration -@EnableConfigurationProperties({RedisProperty.class}) -@Import({RedisConfig.class}) -public class TestRedisConfig {} +public class TestRedisConfig implements BeforeAllCallback { + private static final String REDIS_IMAGE = "redis:alpine"; + private static final int REDIS_PORT = 6379; + private GenericContainer redis; + + @Override + public void beforeAll(ExtensionContext context) { + redis = new GenericContainer(DockerImageName.parse(REDIS_IMAGE)).withExposedPorts(REDIS_PORT); + redis.start(); + System.setProperty("spring.data.redis.host", redis.getHost()); + System.setProperty("spring.data.redis.port", String.valueOf(redis.getMappedPort(REDIS_PORT))); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/common/vo/MoneyTest.java b/src/test/java/com/gdschongik/gdsc/domain/common/vo/MoneyTest.java new file mode 100644 index 000000000..65f8dde6e --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/common/vo/MoneyTest.java @@ -0,0 +1,94 @@ +package com.gdschongik.gdsc.domain.common.vo; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MoneyTest { + + @Nested + class 금액_동등성_확인할때 { + + @Test + void 값과_스케일_모두_같으면_동일한_금액이다() { + // given + Money money1 = Money.from(1000L); + Money money2 = Money.from(1000L); + + // when & then + assertThat(money1).isEqualTo(money2); + } + + @Test + void 스케일이_달라도_같은_값이면_동일한_금액이다() { + // given + Money money1 = Money.from(1000L); + Money money2 = Money.from(BigDecimal.valueOf(1000.0)); + Money money3 = Money.from(BigDecimal.valueOf(1000.00)); + Money money4 = Money.from(BigDecimal.valueOf(1000.000)); + Money money5 = Money.from(BigDecimal.valueOf(1000.0000)); + + // when & then + assertThat(money1) + .isEqualTo(money2) + .isEqualTo(money3) + .isEqualTo(money4) + .isEqualTo(money5); + } + + @Test + void 다른_값이면_다른_금액이다() { + // given + Money money1 = Money.from(BigDecimal.valueOf(1000.01)); + Money money2 = Money.from(BigDecimal.valueOf(1000.02)); + + // when & then + assertThat(money1).isNotEqualTo(money2); + } + } + + // hashCode + @Nested + class 금액_해시코드_확인할때 { + + @Test + void 값과_스케일_모두_같으면_동일한_해시코드이다() { + // given + Money money1 = Money.from(1000L); + Money money2 = Money.from(1000L); + + // when & then + int expected = money2.hashCode(); + assertThat(money1.hashCode()).isEqualTo(expected); + } + + @Test + void 스케일이_달라도_같은_값이면_동일한_해시코드이다() { + // given + Money money1 = Money.from(1000L); + Money money2 = Money.from(BigDecimal.valueOf(1000.0)); + Money money3 = Money.from(BigDecimal.valueOf(1000.00)); + Money money4 = Money.from(BigDecimal.valueOf(1000.000)); + Money money5 = Money.from(BigDecimal.valueOf(1000.0000)); + + // when & then + assertThat(money1.hashCode()) + .isEqualTo(money2.hashCode()) + .isEqualTo(money3.hashCode()) + .isEqualTo(money4.hashCode()) + .isEqualTo(money5.hashCode()); + } + + @Test + void 다른_값이면_다른_해시코드이다() { + // given + Money money1 = Money.from(BigDecimal.valueOf(1000.01)); + Money money2 = Money.from(BigDecimal.valueOf(1000.02)); + + // when & then + assertThat(money1.hashCode()).isNotEqualTo(money2.hashCode()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/coupon/application/CouponServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/coupon/application/CouponServiceTest.java new file mode 100644 index 000000000..553c62aac --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/coupon/application/CouponServiceTest.java @@ -0,0 +1,194 @@ +package com.gdschongik.gdsc.domain.coupon.application; + +import static com.gdschongik.gdsc.global.common.constant.CouponConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static java.math.BigDecimal.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.coupon.dao.CouponRepository; +import com.gdschongik.gdsc.domain.coupon.dao.IssuedCouponRepository; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponCreateRequest; +import com.gdschongik.gdsc.domain.coupon.dto.request.CouponIssueRequest; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.IntegrationTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class CouponServiceTest extends IntegrationTest { + + @Autowired + CouponService couponService; + + @Autowired + CouponRepository couponRepository; + + @Autowired + IssuedCouponRepository issuedCouponRepository; + + @Nested + class 쿠폰_생성할때 { + + @Test + void 성공한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + + // when + couponService.createCoupon(request); + + // then + assertThat(couponRepository.findById(1L)).isPresent(); + } + + @Test + void 할인금액이_양수가_아니라면_실패한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ZERO); + + // when & then + assertThatThrownBy(() -> couponService.createCoupon(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_DISCOUNT_AMOUNT_NOT_POSITIVE.getMessage()); + } + } + + @Nested + class 쿠폰_발급할때 { + + @Test + void 성공한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + createMember(); + + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L, 2L)); + + // when + couponService.createIssuedCoupon(issueRequest); + + // then + assertThat(issuedCouponRepository.findAll()).hasSize(2); + } + + @Test + void 존재하지_않는_유저이면_제외하고_성공한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + createMember(); + + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L, 2L, 3L)); + + // when + couponService.createIssuedCoupon(issueRequest); + + // then + assertThat(issuedCouponRepository.findAll()).hasSize(2); + } + + @Test + void 존재하지_않는_쿠폰이면_실패한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + createMember(); + + CouponIssueRequest issueRequest = new CouponIssueRequest(2L, List.of(1L, 2L)); + + // when & then + assertThatThrownBy(() -> couponService.createIssuedCoupon(issueRequest)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_FOUND.getMessage()); + } + } + + @Nested + class 쿠폰_회수할때 { + + @Test + void 성공한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L)); + couponService.createIssuedCoupon(issueRequest); + + // when + couponService.revokeIssuedCoupon(1L); + + // then + assertThat(issuedCouponRepository.findAll()).hasSize(1).first().satisfies(issuedCoupon -> assertThat( + issuedCoupon.getHasRevoked()) + .isTrue()); + } + + @Test + void 존재하지_않는_발급쿠폰이면_실패한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L)); + couponService.createIssuedCoupon(issueRequest); + + // when & then + assertThatThrownBy(() -> couponService.revokeIssuedCoupon(2L)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ISSUED_COUPON_NOT_FOUND.getMessage()); + } + + @Test + void 이미_회수한_발급쿠폰이면_실패한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L)); + couponService.createIssuedCoupon(issueRequest); + + issuedCouponRepository.findById(1L).ifPresent(coupon -> { + coupon.revoke(); + issuedCouponRepository.save(coupon); + }); + + // when & then + assertThatThrownBy(() -> couponService.revokeIssuedCoupon(1L)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_REVOKABLE_ALREADY_REVOKED.getMessage()); + } + + @Test + void 이미_사용한_발급쿠폰이면_실패한다() { + // given + CouponCreateRequest request = new CouponCreateRequest(COUPON_NAME, ONE); + couponService.createCoupon(request); + + createMember(); + CouponIssueRequest issueRequest = new CouponIssueRequest(1L, List.of(1L)); + couponService.createIssuedCoupon(issueRequest); + + issuedCouponRepository.findById(1L).ifPresent(coupon -> { + coupon.use(); + issuedCouponRepository.save(coupon); + }); + + // when & then + assertThatThrownBy(() -> couponService.revokeIssuedCoupon(1L)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_REVOKABLE_ALREADY_USED.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/CouponTest.java b/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/CouponTest.java new file mode 100644 index 000000000..72e2b8879 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/CouponTest.java @@ -0,0 +1,38 @@ +package com.gdschongik.gdsc.domain.coupon.domain; + +import static com.gdschongik.gdsc.global.common.constant.CouponConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static java.math.BigDecimal.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CouponTest { + + @Nested + class 쿠폰_생성할때 { + + @Test + void 성공한다() { + // when + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + + // then + assertThat(coupon).isNotNull(); + } + + @Test + void 할인금액이_양수가_아니라면_실패한다() { + // given + Money discountAmount = Money.from(ZERO); + + // when & then + assertThatThrownBy(() -> Coupon.createCoupon(COUPON_NAME, discountAmount)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_DISCOUNT_AMOUNT_NOT_POSITIVE.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCouponTest.java b/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCouponTest.java new file mode 100644 index 000000000..d5df0341f --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/coupon/domain/IssuedCouponTest.java @@ -0,0 +1,108 @@ +package com.gdschongik.gdsc.domain.coupon.domain; + +import static com.gdschongik.gdsc.global.common.constant.CouponConstant.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static java.math.BigDecimal.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class IssuedCouponTest { + + @Nested + class 발급쿠폰_사용할때 { + + @Test + void 성공하면_사용여부는_true이다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + + // when + issuedCoupon.use(); + + // then + assertThat(issuedCoupon.hasUsed()).isTrue(); + } + + @Test + void 이미_사용한_쿠폰이면_실패한다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + issuedCoupon.use(); + + // when & then + assertThatThrownBy(issuedCoupon::use) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_USABLE_ALREADY_USED.getMessage()); + } + + @Test + void 이미_회수한_쿠폰이면_실패한다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + issuedCoupon.revoke(); + + // when & then + assertThatThrownBy(issuedCoupon::use) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_USABLE_REVOKED.getMessage()); + } + } + + @Nested + class 발급쿠폰_회수할때 { + + @Test + void 성공하면_회수여부는_true이다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + + // when + issuedCoupon.revoke(); + + // then + assertThat(issuedCoupon.getHasRevoked()).isTrue(); + } + + @Test + void 이미_회수한_발급쿠폰이면_실패한다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + issuedCoupon.revoke(); + + // when & then + assertThatThrownBy(issuedCoupon::revoke) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_REVOKABLE_ALREADY_REVOKED.getMessage()); + } + + @Test + void 이미_사용한_발급쿠폰이면_실패한다() { + // given + Coupon coupon = Coupon.createCoupon(COUPON_NAME, Money.from(ONE)); + Member member = Member.createGuestMember(OAUTH_ID); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + issuedCoupon.use(); + + // when & then + assertThatThrownBy(issuedCoupon::revoke) + .isInstanceOf(CustomException.class) + .hasMessageContaining(COUPON_NOT_REVOKABLE_ALREADY_USED.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/discord/DiscordValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/discord/DiscordValidatorTest.java new file mode 100644 index 000000000..8a52e9107 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/discord/DiscordValidatorTest.java @@ -0,0 +1,59 @@ +package com.gdschongik.gdsc.domain.discord; + +import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.discord.domain.DiscordValidator; +import com.gdschongik.gdsc.domain.discord.domain.DiscordVerificationCode; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class DiscordValidatorTest { + + DiscordValidator discordValidator = new DiscordValidator(); + + @Nested + class 디스코드_연동시 { + + @Test + void 인증코드가_일치하지_않는다면_실패한다() { + // given + DiscordVerificationCode discordVerificationCode = + DiscordVerificationCode.create(DISCORD_USERNAME, DISCORD_CODE, DISCORD_CODE_TTL); + + // when & then + assertThatThrownBy(() -> + discordValidator.validateVerifyDiscordCode(1235, discordVerificationCode, false, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(DISCORD_CODE_MISMATCH.getMessage()); + } + + @Test + void 이미_존재하는_디스코드_유저네임이라면_실패한다() { + // given + DiscordVerificationCode discordVerificationCode = + DiscordVerificationCode.create(DISCORD_USERNAME, DISCORD_CODE, DISCORD_CODE_TTL); + + // when & then + assertThatThrownBy(() -> discordValidator.validateVerifyDiscordCode( + DISCORD_CODE, discordVerificationCode, true, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(MEMBER_DISCORD_USERNAME_DUPLICATE.getMessage()); + } + + @Test + void 이미_존재하는_닉네임이라면_실패한다() { + // given + DiscordVerificationCode discordVerificationCode = + DiscordVerificationCode.create(DISCORD_USERNAME, DISCORD_CODE, DISCORD_CODE_TTL); + + // when & then + assertThatThrownBy(() -> discordValidator.validateVerifyDiscordCode( + DISCORD_CODE, discordVerificationCode, false, true)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(MEMBER_NICKNAME_DUPLICATE.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationServiceTest.java new file mode 100644 index 000000000..8eb42e455 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationServiceTest.java @@ -0,0 +1,76 @@ +package com.gdschongik.gdsc.domain.email.application; + +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.gdschongik.gdsc.config.TestRedisConfig; +import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationRequest; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.email.EmailVerificationTokenUtil; +import com.gdschongik.gdsc.global.util.email.MailSender; +import com.gdschongik.gdsc.helper.IntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +@ExtendWith(TestRedisConfig.class) +public class UnivEmailVerificationServiceTest extends IntegrationTest { + + @Autowired + private UnivEmailVerificationLinkSendService univEmailVerificationLinkSendService; + + @Autowired + private UnivEmailVerificationService univEmailVerificationService; + + @Autowired + private EmailVerificationTokenUtil emailVerificationTokenUtil; + + @MockBean + private MailSender mailSender; + + @Nested + class 재학생_메일_인증시 { + + @Test + void 레디스에_이메일인증정보가_존재하지_않으면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + memberRepository.save(member); + String verificationToken = + emailVerificationTokenUtil.generateEmailVerificationToken(member.getId(), UNIV_EMAIL); + UnivEmailVerificationRequest request = new UnivEmailVerificationRequest(verificationToken); + + // when & then + assertThatThrownBy(() -> univEmailVerificationService.verifyMemberUnivEmail(request)) + .isInstanceOf(CustomException.class) + .hasMessage(EMAIL_NOT_SENT.getMessage()); + } + + @Test + void 인증토큰과_레디스에_존재하는_인증정보의_토큰이_다르면_실패한다() { + // given + // TODO: 아래 두줄 createGuestMember로 대체하기 + Member member = memberRepository.save(Member.createGuestMember(OAUTH_ID)); + logoutAndReloginAs(member.getId(), member.getRole()); + + // when + univEmailVerificationLinkSendService.send(UNIV_EMAIL); + + String oldVerificationToken = univEmailVerificationService + .getUnivEmailVerificationFromRedis(member.getId()) + .get() + .getVerificationToken(); + UnivEmailVerificationRequest request = new UnivEmailVerificationRequest(oldVerificationToken); + univEmailVerificationLinkSendService.send("b123456@g.hongik.ac.kr"); + + // then + assertThatThrownBy(() -> univEmailVerificationService.verifyMemberUnivEmail(request)) + .isInstanceOf(CustomException.class) + .hasMessage(EXPIRED_EMAIL_VERIFICATION_TOKEN.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidatorTest.java similarity index 61% rename from src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java rename to src/test/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidatorTest.java index ec99cc414..3a24bfc10 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailValidatorTest.java @@ -1,32 +1,27 @@ -package com.gdschongik.gdsc.domain.email; +package com.gdschongik.gdsc.domain.email.domain; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.exception.ErrorCode; -import com.gdschongik.gdsc.global.util.email.HongikUnivEmailValidator; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -@SpringBootTest -@ActiveProfiles("test") -class HongikUnivEmailValidatorTest { +class UnivEmailValidatorTest { - @Autowired - private HongikUnivEmailValidator hongikUnivEmailValidator; + UnivEmailValidator univEmailValidator = new UnivEmailValidator(); @Test @DisplayName("'g.hongik.ac.kr' 도메인을 가진 이메일을 검증할 수 있다.") void validateEmailDomainTest() { + // given String hongikDomainEmail = "test@g.hongik.ac.kr"; - assertThatCode(() -> hongikUnivEmailValidator.validate(hongikDomainEmail)) + // when & then + assertThatCode(() -> univEmailValidator.validateSendUnivEmailVerificationLink(hongikDomainEmail, false)) .doesNotThrowAnyException(); } @@ -34,7 +29,8 @@ void validateEmailDomainTest() { @ValueSource(strings = {"test@naver.com", "test@mail.hongik.ac.kr", "test@gmail.com", "test@gg.hongik.ac.kr"}) @DisplayName("'g.hongik.ac.kr'가 아닌 도메인을 가진 이메일을 입력하면 예외를 발생시킨다.") void validateEmailDomainMismatchTest(String email) { - assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + // when & then + assertThatThrownBy(() -> univEmailValidator.validateSendUnivEmailVerificationLink(email, false)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.UNIV_EMAIL_DOMAIN_MISMATCH.getMessage()); } @@ -42,9 +38,12 @@ void validateEmailDomainMismatchTest(String email) { @Test @DisplayName("Email의 '@' 앞 부분에는 연속되지 않은 점이 포함될 수 있다.") void validateEmailFormatWithDotsTest() { + // given String email = "t.e.s.t@g.hongik.ac.kr"; - assertThatCode(() -> hongikUnivEmailValidator.validate(email)).doesNotThrowAnyException(); + // when & then + assertThatCode(() -> univEmailValidator.validateSendUnivEmailVerificationLink(email, false)) + .doesNotThrowAnyException(); } @ParameterizedTest @@ -61,7 +60,8 @@ void validateEmailFormatWithDotsTest() { }) @DisplayName("Email의 '@' 앞 부분에 '&', '=', ''', '-', '+', ',', '<', '>'가 포함되는 경우 예외를 발생시킨다.") void validateEmailFormatMismatchTest(String email) { - assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + // when & then + assertThatThrownBy(() -> univEmailValidator.validateSendUnivEmailVerificationLink(email, false)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH.getMessage()); } @@ -69,9 +69,23 @@ void validateEmailFormatMismatchTest(String email) { @Test @DisplayName("Email의 '@' 앞 부분에 '.'이 2개 연속 오는 경우 예외를 발생시킨다.") void validateEmailFormatMismatchWithDotsTest() { + // given String email = "te..st@g.hongik.ac.kr"; - assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + + // when & then + assertThatThrownBy(() -> univEmailValidator.validateSendUnivEmailVerificationLink(email, false)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH.getMessage()); } + + @Test + void 이미_가입된_재학생_메일이라면_실패한다() { + // given + String hongikDomainEmail = "test@g.hongik.ac.kr"; + + // when & then + assertThatThrownBy(() -> univEmailValidator.validateSendUnivEmailVerificationLink(hongikDomainEmail, true)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIV_EMAIL_ALREADY_SATISFIED.getMessage()); + } } diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/application/AdminMemberServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/application/AdminMemberServiceTest.java index 48eef3197..534c28729 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/member/application/AdminMemberServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/member/application/AdminMemberServiceTest.java @@ -1,5 +1,6 @@ package com.gdschongik.gdsc.domain.member.application; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; import static org.assertj.core.api.Assertions.*; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; @@ -8,7 +9,7 @@ import com.gdschongik.gdsc.domain.member.dto.request.MemberUpdateRequest; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.exception.ErrorCode; -import com.gdschongik.gdsc.integration.IntegrationTest; +import com.gdschongik.gdsc.helper.IntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +24,7 @@ class AdminMemberServiceTest extends IntegrationTest { @Test void status가_DELETED라면_예외_발생() { // given - Member member = Member.createGuestMember("oAuthId"); + Member member = Member.createGuestMember(OAUTH_ID); member.withdraw(); memberRepository.save(member); diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/application/MemberIntegrationTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/application/MemberIntegrationTest.java new file mode 100644 index 000000000..e4de3b57b --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/member/application/MemberIntegrationTest.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.member.application; + +import static com.gdschongik.gdsc.domain.member.domain.MemberRole.ASSOCIATE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.gdschongik.gdsc.domain.member.application.handler.MemberAssociateEventHandler; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberAssociateEvent; +import com.gdschongik.gdsc.helper.IntegrationTest; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@Slf4j +public class MemberIntegrationTest extends IntegrationTest { + @Autowired + private MemberAssociateEventHandler memberAssociateEventHandler; + + @Autowired + private MemberRepository memberRepository; + + @Test + void 준회원_승급조건_만족됐으면_MemberRole은_ASSOCIATE이다() { + // given + Member member = createMember(); + + // when + memberAssociateEventHandler.advanceToAssociate(new MemberAssociateEvent(member.getId())); + member = memberRepository.save(member); + + // then + assertThat(member.getRole()).isEqualTo(ASSOCIATE); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberServiceTest.java new file mode 100644 index 000000000..a1af8c6b6 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberServiceTest.java @@ -0,0 +1,63 @@ +package com.gdschongik.gdsc.domain.member.application; + +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.dto.response.MemberDashboardResponse; +import com.gdschongik.gdsc.domain.recruitment.application.OnboardingRecruitmentService; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.helper.IntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class OnboardingMemberServiceTest extends IntegrationTest { + + @Autowired + private OnboardingMemberService onboardingMemberService; + + @Nested + class 대시보드_조회할때 { + + /** + * {@link Period#isOpen()}에서 LocalDateTime.now()를 사용하기 때문에 고정된 리쿠르팅을 반환하도록 설정 + * @see OnboardingRecruitmentService#findCurrentRecruitmentRound() + */ + @BeforeEach + void setUp() { + RecruitmentRound recruitmentRound = createRecruitmentRound(); + when(onboardingRecruitmentService.findCurrentRecruitmentRound()).thenReturn(recruitmentRound); + } + + @Test + void 정회원_미신청시_멤버십_응답은_null이다() { + // given + createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + + // when + MemberDashboardResponse response = onboardingMemberService.getDashboard(); + + // then + assertThat(response.currentMembership()).isNull(); + } + + @Test + void 기본정보_미작성시_멤버_기본정보는_모두_null이다() { + // given + memberRepository.save(Member.createGuestMember(OAUTH_ID)); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + + // when + MemberDashboardResponse response = onboardingMemberService.getDashboard(); + + // then - 전체 필드가 null인지 확인 + assertThat(response.member().basicInfo()).hasAllNullFieldsOrProperties(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/dao/MemberRepositoryTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/dao/MemberRepositoryTest.java new file mode 100644 index 000000000..61a25a03a --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/member/dao/MemberRepositoryTest.java @@ -0,0 +1,126 @@ +package com.gdschongik.gdsc.domain.member.dao; + +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.domain.member.domain.MemberRole.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.dto.request.MemberQueryOption; +import com.gdschongik.gdsc.helper.RepositoryTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +class MemberRepositoryTest extends RepositoryTest { + + private static final MemberQueryOption GUEST_QUERY_OPTION = + new MemberQueryOption(null, null, null, null, null, null, null, List.of(GUEST)); + + private static final MemberQueryOption ASSOCIATE_QUERY_OPTION = + new MemberQueryOption(null, null, null, null, null, null, null, List.of(ASSOCIATE)); + + @Autowired + private MemberRepository memberRepository; + + private Member getMember() { + Member member = Member.createGuestMember(OAUTH_ID); + return memberRepository.save(member); + } + + private void flushAndClearBeforeExecute() { + testEntityManager.flush(); + testEntityManager.clear(); + } + + @Nested + class 멤버_상태로_조회할때 { + @Test + void NORMAL이라면_조회_성공한다() { + // given + Member member = getMember(); + + // when + List members = memberRepository.findAll(); + + // then + assertThat(members).contains(member); + } + + @Test + void DELETED라면_조회되지_않는다() { + // given + Member member = getMember(); + member.withdraw(); + + // when + List members = memberRepository.findAll(); + + // then + assertThat(members).doesNotContain(member); + } + } + + @Nested + class 역할로_조회할때 { + + @Test + void 기본_회원정보_작성후_준회원_승급전_이라면_GUEST로_조회된다() { + // given + Member member = getMember(); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, UNIV_EMAIL); + + flushAndClearBeforeExecute(); + + // when + Page members = memberRepository.searchMembers(GUEST_QUERY_OPTION, PageRequest.of(0, 10)); + + // then + Member guest = memberRepository.findById(1L).get(); + assertThat(members).contains(guest); + } + + @Test + void 기본_회원정보_작성후_준회원_승급후라면_ASSOCIATE로_조회된다() { + // given + Member member = getMember(); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, UNIV_EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + + flushAndClearBeforeExecute(); + + // when + Page members = memberRepository.searchMembers(ASSOCIATE_QUERY_OPTION, PageRequest.of(0, 10)); + + // then + Member user = memberRepository.findById(1L).get(); + assertThat(members).contains(user); + } + + @Test + void 기본_회원정보_작성후_준회원_승급후라면_GUEST로_조회되지_않는다() { + // given + Member member = getMember(); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, UNIV_EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + + flushAndClearBeforeExecute(); + + // when + Page members = memberRepository.searchMembers(GUEST_QUERY_OPTION, PageRequest.of(0, 10)); + + // then + Member user = memberRepository.findById(1L).get(); + assertThat(members).doesNotContain(user); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberTest.java index 4334c1037..7fda0bdbe 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberTest.java @@ -1,32 +1,376 @@ package com.gdschongik.gdsc.domain.member.domain; +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.*; +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.domain.member.domain.MemberRole.*; +import static com.gdschongik.gdsc.domain.member.domain.MemberStatus.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import static org.assertj.core.api.Assertions.*; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class MemberTest { + @Nested + class 게스트_회원가입시 { + + @Test + void MemberRole은_GUEST이다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + MemberRole role = member.getRole(); + + // then + assertThat(role).isEqualTo(MemberRole.GUEST); + } + + @Test + void MemberStatus는_NORMAL이다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + MemberStatus status = member.getStatus(); + + // then + assertThat(status).isEqualTo(MemberStatus.NORMAL); + } + + @Test + void 모든_준회원_가입조건은_인증되지_않은_상태이다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + AssociateRequirement requirement = member.getAssociateRequirement(); + + // then + assertThat(requirement.getUnivStatus()).isEqualTo(PENDING); + assertThat(requirement.getDiscordStatus()).isEqualTo(PENDING); + assertThat(requirement.getBevyStatus()).isEqualTo(PENDING); + assertThat(requirement.getInfoStatus()).isEqualTo(PENDING); + } + } + + @Nested + class 준회원_가입조건_인증시도시 { + + @Test + void 기본회원정보_작성시_준회원_가입조건중_기본정보_인증상태가_인증된다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + + // then + AssociateRequirement requirement = member.getAssociateRequirement(); + assertThat(requirement.getInfoStatus()).isEqualTo(SATISFIED); + } + + @Test + void 재학생이메일_인증시_준회원_가입조건중_재학생이메일_인증상태가_인증된다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + member.completeUnivEmailVerification(UNIV_EMAIL); + + // then + AssociateRequirement requirement = member.getAssociateRequirement(); + assertThat(requirement.getUnivStatus()).isEqualTo(SATISFIED); + } + + @Test + void 디스코드_인증시_준회원_가입조건중_디스코드_인증상태가_인증된다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + + // then + AssociateRequirement requirement = member.getAssociateRequirement(); + assertThat(requirement.getDiscordStatus()).isEqualTo(SATISFIED); + } + + @Test + void Bevy_인증시_준회원_가입조건중_Bevy_인증상태가_인증된다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + member.verifyBevy(); + + // then + AssociateRequirement requirement = member.getAssociateRequirement(); + assertThat(requirement.getBevyStatus()).isEqualTo(SATISFIED); + } + } + + @Nested + class 준회원으로_승급시도시 { + + @Test + void 기본_회원정보_작성하지_않았으면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyBevy(); + + // when & then + assertThatThrownBy(member::advanceToAssociate) + .isInstanceOf(CustomException.class) + .hasMessage(BASIC_INFO_NOT_SATISFIED.getMessage()); + } + + @Test + void 디스코드_인증하지_않았으면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyBevy(); + + // when & then + assertThatThrownBy(member::advanceToAssociate) + .isInstanceOf(CustomException.class) + .hasMessage(DISCORD_NOT_SATISFIED.getMessage()); + } + + @Test + void Bevy_연동하지_않았으면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + + // when & then + assertThatThrownBy(member::advanceToAssociate) + .isInstanceOf(CustomException.class) + .hasMessage(BEVY_NOT_SATISFIED.getMessage()); + } + + @Test + void 이미_준회원으로_승급_돼있으면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + + // when & then + assertThatThrownBy(member::advanceToAssociate) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_ALREADY_ASSOCIATE.getMessage()); + } + + @Test + void 모든_준회원_가입조건이_인증되었으면_성공한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + + // when + member.advanceToAssociate(); + + // then + assertThat(member.getRole()).isEqualTo(ASSOCIATE); + } + } + + @Nested + class 회원탈퇴시 { + + @Test + void 이미_탈퇴한_유저면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.withdraw(); + + // when & then + assertThatThrownBy(member::withdraw) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_DELETED.getMessage()); + } + + @Test + void 회원탈퇴시_이전에_탈퇴하지_않은_유저면_성공한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when + member.withdraw(); + + // then + assertThat(member.getStatus()).isEqualTo(DELETED); + } + } + + @Nested + class 회원수정시 { + @Test + void 탈퇴하지_않은_유저면_성공한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateMemberInfo( + MODIFIED_STUDENT_ID, NAME, PHONE_NUMBER, D022, UNIV_EMAIL, DISCORD_USERNAME, NICKNAME); + + // then + assertThat(member.getStudentId()).isEqualTo(MODIFIED_STUDENT_ID); + } + + @Test + void 탈퇴한_유저면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.withdraw(); + + // when & then + assertThatThrownBy(() -> { + member.updateMemberInfo( + MODIFIED_STUDENT_ID, NAME, PHONE_NUMBER, D022, UNIV_EMAIL, DISCORD_USERNAME, NICKNAME); + }) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_DELETED.getMessage()); + } + } + @Test - void 회원가입시_MemberRole은_GUEST이다() { + void 디스코드인증시_탈퇴한_유저면_실패한다() { // given - Member member = Member.createGuestMember("testOauthId"); + Member member = Member.createGuestMember(OAUTH_ID); - // when - MemberRole role = member.getRole(); + member.withdraw(); - // then - assertThat(role).isEqualTo(MemberRole.GUEST); + // when & then + assertThatThrownBy(() -> { + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + }) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_DELETED.getMessage()); } @Test - void 회원가입시_MemberStatus는_NORMAL이다() { + void Bevy인증시_탈퇴한_유저면_실패한다() { // given - Member member = Member.createGuestMember("testOauthId"); + Member member = Member.createGuestMember(OAUTH_ID); + + member.withdraw(); + + // when & then + assertThatThrownBy(member::verifyBevy) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_DELETED.getMessage()); + } + + @Nested + class 정회원으로_승급_시도시 { + @Test + void 이미_정회원이라면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + member.advanceToRegular(); + + // when & then + assertThatThrownBy(member::advanceToRegular) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_ALREADY_REGULAR.getMessage()); + } + + @Test + void MemberRole이_GUEST_이라면_실패한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + // when & then + assertThatThrownBy(member::advanceToRegular) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBER_NOT_ASSOCIATE.getMessage()); + } + + @Test + void 준회원이라면_성공한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + + // when + member.advanceToRegular(); + + // then + assertThat(member.getRole()).isEqualTo(REGULAR); + } + } + + @Nested + class 비회원으로_강등시 { + + @Test + void 성공한다() { + // given + Member member = Member.createGuestMember(OAUTH_ID); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); - // when - MemberStatus status = member.getStatus(); + // when + member.demoteToGuest(); - // then - assertThat(status).isEqualTo(MemberStatus.NORMAL); + // then + assertThat(member) + .extracting( + Member::getRole, + Member::getUnivEmail, + Member::getName, + Member::getDepartment, + Member::getStudentId, + Member::getPhone, + Member::getDiscordId, + Member::getNickname, + Member::getDiscordUsername) + .containsExactly(GUEST, null, null, null, null, null, null, null, null); + assertThat(member.getAssociateRequirement()) + .extracting( + AssociateRequirement::getDiscordStatus, + AssociateRequirement::getInfoStatus, + AssociateRequirement::getBevyStatus, + AssociateRequirement::getUnivStatus) + .containsExactly(PENDING, PENDING, PENDING, PENDING); + } } } diff --git a/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberValidatorTest.java new file mode 100644 index 000000000..b05efbfc3 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/member/domain/MemberValidatorTest.java @@ -0,0 +1,53 @@ +package com.gdschongik.gdsc.domain.member.domain; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class MemberValidatorTest { + + MemberValidator memberValidator = new MemberValidator(); + + @Nested + class 준회원으로_일괄_강등시 { + + @Test + void 해당_학기에_이미_시작된_모집기간이_있다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + RecruitmentRound recruitmentRound = RecruitmentRound.create( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + recruitment, + ROUND_TYPE); + List recruitmentRounds = List.of(recruitmentRound); + + // when & then + assertThatThrownBy(() -> memberValidator.validateMemberDemote(recruitmentRounds)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_STARTDATE_ALREADY_PASSED.getMessage()); + } + + @Test + void 해당_학기에_모집회차가_존재하지_않는다면_실패한다() { + // given + List recruitmentRounds = List.of(); + + // when & then + assertThatThrownBy(() -> memberValidator.validateMemberDemote(recruitmentRounds)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/membership/application/MembershipServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/membership/application/MembershipServiceTest.java new file mode 100644 index 000000000..5a16a404e --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/membership/application/MembershipServiceTest.java @@ -0,0 +1,75 @@ +package com.gdschongik.gdsc.domain.membership.application; + +import static com.gdschongik.gdsc.domain.common.model.RequirementStatus.SATISFIED; +import static com.gdschongik.gdsc.domain.member.domain.MemberRole.ASSOCIATE; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.IntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class MembershipServiceTest extends IntegrationTest { + + @Autowired + private MembershipService membershipService; + + @Autowired + private MembershipRepository membershipRepository; + + @Nested + class 멤버십_가입신청시 { + @Test + void RecruitmentRound가_없다면_실패한다() { + // given + createMember(); + logoutAndReloginAs(1L, ASSOCIATE); + Long recruitmentRoundId = 1L; + + // when & then + assertThatThrownBy(() -> membershipService.submitMembership(recruitmentRoundId)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_NOT_FOUND.getMessage()); + } + } + + @Test + void 멤버십_회비납부시_이미_회비납부_했다면_회비납부_실패한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound(); + Membership membership = createMembership(member, recruitmentRound); + membershipService.verifyPaymentStatus(membership.getId()); + + // when & then + assertThatThrownBy(() -> membershipService.verifyPaymentStatus(membership.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBERSHIP_ALREADY_SATISFIED.getMessage()); + } + + @Nested + class 정회원_가입조건_인증시도시 { + @Test + void 멤버십_회비납부시_정회원_가입조건중_회비납부_인증상태가_인증_성공한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound(); + Membership membership = createMembership(member, recruitmentRound); + + // when + membershipService.verifyPaymentStatus(membership.getId()); + membership = membershipRepository.findById(membership.getId()).get(); + + // then + assertThat(membership.getRegularRequirement().getPaymentStatus()).isEqualTo(SATISFIED); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipTest.java b/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipTest.java new file mode 100644 index 000000000..a9765b309 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipTest.java @@ -0,0 +1,48 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.domain.member.domain.Member.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MembershipTest { + + @Nested + class 멤버십_가입신청시 { + + @Test + void 성공한다() { + // given + Member member = createGuestMember(OAUTH_ID); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, + SEMESTER_TYPE, + FEE, + FEE_NAME, + Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE)); + RecruitmentRound recruitmentRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + + // when + Membership membership = Membership.createMembership(member, recruitmentRound); + + // then + assertThat(membership).isNotNull(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidatorTest.java new file mode 100644 index 000000000..311d0d651 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/membership/domain/MembershipValidatorTest.java @@ -0,0 +1,96 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.domain.member.domain.Member.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class MembershipValidatorTest { + + MembershipValidator membershipValidator = new MembershipValidator(); + + private Member createAssociateMember(Long id) { + Member member = createGuestMember(OAUTH_ID); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private RecruitmentRound createRecruitmentRound( + Integer academicYear, + SemesterType semesterType, + Money fee, + LocalDateTime startDate, + LocalDateTime endDate) { + Recruitment recruitment = Recruitment.createRecruitment( + academicYear, semesterType, fee, FEE_NAME, Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE)); + return RecruitmentRound.create(RECRUITMENT_ROUND_NAME, startDate, endDate, recruitment, ROUND_TYPE); + } + + @Nested + class 멤버십_가입신청시 { + + @Test + void 역할이_GUEST라면_멤버십_가입신청에_실패한다() { + // given + Member member = createGuestMember(OAUTH_ID); + + RecruitmentRound recruitmentRound = + createRecruitmentRound(ACADEMIC_YEAR, SEMESTER_TYPE, FEE, START_DATE, END_DATE); + + // when & then + assertThatThrownBy(() -> membershipValidator.validateMembershipSubmit(member, recruitmentRound, false)) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBERSHIP_NOT_APPLICABLE.getMessage()); + } + + @Test + void 해당_리쿠르팅회차의_모집기간이_아니라면_실패한다() { + // given + Member member = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = + createRecruitmentRound(ACADEMIC_YEAR, SEMESTER_TYPE, FEE, START_DATE, END_DATE); + + // when & then + assertThatThrownBy(() -> membershipValidator.validateMembershipSubmit(member, recruitmentRound, false)) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBERSHIP_RECRUITMENT_ROUND_NOT_OPEN.getMessage()); + } + + @Test + void 해당_학기에_이미_멤버십을_생성한_적이_있다면_실패한다() { + // given + Member member = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = + createRecruitmentRound(ACADEMIC_YEAR, SEMESTER_TYPE, FEE, START_DATE, END_DATE); + + Membership membership = Membership.createMembership(member, recruitmentRound); + + // when & then + assertThatThrownBy(() -> membershipValidator.validateMembershipSubmit(member, recruitmentRound, true)) + .isInstanceOf(CustomException.class) + .hasMessage(MEMBERSHIP_ALREADY_SUBMITTED.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/order/application/OrderServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/order/application/OrderServiceTest.java new file mode 100644 index 000000000..a3176a564 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/order/application/OrderServiceTest.java @@ -0,0 +1,457 @@ +package com.gdschongik.gdsc.domain.order.application; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.order.dao.OrderRepository; +import com.gdschongik.gdsc.domain.order.domain.Order; +import com.gdschongik.gdsc.domain.order.domain.OrderStatus; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCancelRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCompleteRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderCreateRequest; +import com.gdschongik.gdsc.domain.order.dto.request.OrderQueryOption; +import com.gdschongik.gdsc.domain.order.dto.response.OrderAdminResponse; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.IntegrationTest; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentCancelRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.request.PaymentConfirmRequest; +import com.gdschongik.gdsc.infra.feign.payment.dto.response.PaymentResponse; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +class OrderServiceTest extends IntegrationTest { + + public static final Money MONEY_20000_WON = Money.from(20000L); + public static final Money MONEY_15000_WON = Money.from(15000L); + public static final Money MONEY_10000_WON = Money.from(10000L); + public static final Money MONEY_5000_WON = Money.from(5000L); + + @Autowired + private OrderService orderService; + + @Autowired + private OrderRepository orderRepository; + + @Nested + class 임시주문_생성할때 { + + @Test + void 성공한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + // when + var request = new OrderCreateRequest( + "HnbMWoSZRq3qK1W3tPXCW", + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000)); + orderService.createPendingOrder(request); + + // then + assertThat(orderRepository.findAll()).hasSize(1); + } + } + + @Nested + class 주문_완료할때 { + + @Test + void 성공한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + String paymentKey = "testPaymentKey"; + + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + + // when + var request = new OrderCompleteRequest(paymentKey, orderNanoId, 15000L); + orderService.completeOrder(request); + + // then + Order completedOrder = orderRepository.findByNanoId(orderNanoId).orElseThrow(); + assertThat(completedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + assertThat(completedOrder.getPaymentKey()).isEqualTo(paymentKey); + + IssuedCoupon usedCoupon = + issuedCouponRepository.findById(issuedCoupon.getId()).orElseThrow(); + assertThat(usedCoupon.hasUsed()).isTrue(); + + verify(paymentClient).confirm(any(PaymentConfirmRequest.class)); + } + + @Test + void 멤버십의_회비납입상태가_SATISFIED로_변경된다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + String paymentKey = "testPaymentKey"; + + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + + // when + var request = new OrderCompleteRequest(paymentKey, orderNanoId, 15000L); + orderService.completeOrder(request); + + // then + Membership verifiedMembership = + membershipRepository.findById(membership.getId()).orElseThrow(); + assertThat(verifiedMembership.getRegularRequirement().isPaymentSatisfied()) + .isTrue(); + } + + @Test + void 정회원으로_승급한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + String paymentKey = "testPaymentKey"; + + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + + // when + var request = new OrderCompleteRequest(paymentKey, orderNanoId, 15000L); + orderService.completeOrder(request); + + // then + Member regularMember = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(regularMember.isRegular()).isTrue(); + } + } + + @Nested + class 주문_취소할때 { + + @Test + void 성공한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + String paymentKey = "testPaymentKey"; + + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + + var completeRequest = new OrderCompleteRequest(paymentKey, orderNanoId, 15000L); + orderService.completeOrder(completeRequest); + + Order completedOrder = orderRepository.findByNanoId(orderNanoId).orElseThrow(); + + ZonedDateTime canceledAt = ZonedDateTime.now(); + PaymentResponse mockCancelResponse = mock(PaymentResponse.class); + PaymentResponse.CancelDto mockCancelDto = mock(PaymentResponse.CancelDto.class); + + when(mockCancelResponse.cancels()).thenReturn(List.of(mockCancelDto)); + when(mockCancelDto.canceledAt()).thenReturn(canceledAt); + when(paymentClient.cancelPayment(eq(paymentKey), any(PaymentCancelRequest.class))) + .thenReturn(mockCancelResponse); + + // when + var cancelRequest = new OrderCancelRequest("테스트 취소 사유"); + orderService.cancelOrder(completedOrder.getId(), cancelRequest); + + // then + Order canceledOrder = + orderRepository.findById(completedOrder.getId()).orElseThrow(); + assertThat(canceledOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); + assertThat(canceledOrder.getCanceledAt()).isNotNull(); + + verify(paymentClient).cancelPayment(eq(paymentKey), any(PaymentCancelRequest.class)); + } + + @Test + void 주문상태가_PENDING이면_실패한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + null, + BigDecimal.valueOf(20000), + BigDecimal.valueOf(0), + BigDecimal.valueOf(20000))); + + Order pendingOrder = orderRepository.findByNanoId(orderNanoId).orElseThrow(); + Long id = pendingOrder.getId(); + + OrderCancelRequest request = new OrderCancelRequest("테스트 취소 사유"); + + // when & then + assertThatThrownBy(() -> orderService.cancelOrder(id, request)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_CANCEL_NOT_COMPLETED.getMessage()); + + verify(paymentClient, never()).cancelPayment(any(), any()); + } + } + + @Disabled // TODO: CI 환경에서만 실패하는 테스트, TZ 관련 설정 확인 필요 + @Nested + class 일자기준으로_주문목록_조회시 { + + @Test + void 조회된다() { + // given + Member member = createAssociateMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + orderService.createPendingOrder(new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + String paymentKey = "testPaymentKey"; + + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + var request = new OrderCompleteRequest(paymentKey, orderNanoId, 15000L); + orderService.completeOrder(request); + + LocalDate date = LocalDate.now(); + OrderQueryOption queryOption = new OrderQueryOption(null, null, null, null, null, null, null, date); + + // when + Page orderResponse = orderService.searchOrders(queryOption, PageRequest.of(0, 10)); + + // then + boolean orderExists = orderResponse.getContent().stream() + .anyMatch(order -> order.nanoId().equals(orderNanoId)); + + assertThat(orderExists).isTrue(); + } + } + + @Nested + class 무료주문_생성할때 { + + @Test + void 멤버십의_회비납입상태가_SATISFIED로_변경된다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_20000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + + var request = new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(20000), + BigDecimal.ZERO); + + // when + orderService.createFreeOrder(request); + + // then + Membership verifiedMembership = + membershipRepository.findById(membership.getId()).orElseThrow(); + assertThat(verifiedMembership.getRegularRequirement().isPaymentSatisfied()) + .isTrue(); + } + + @Test + void 정회원으로_승급한다() { + // given + Member member = createMember(); + logoutAndReloginAs(1L, MemberRole.ASSOCIATE); + RecruitmentRound recruitmentRound = createRecruitmentRound( + RECRUITMENT_ROUND_NAME, + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + ACADEMIC_YEAR, + SEMESTER_TYPE, + ROUND_TYPE, + MONEY_20000_WON); + + Membership membership = createMembership(member, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_20000_WON, member); + + String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; + + var request = new OrderCreateRequest( + orderNanoId, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(20000), + BigDecimal.ZERO); + + // when + orderService.createFreeOrder(request); + + // then + Member regularMember = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(regularMember.isRegular()).isTrue(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfoTest.java b/src/test/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfoTest.java new file mode 100644 index 000000000..7ef58af59 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/order/domain/MoneyInfoTest.java @@ -0,0 +1,58 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Test; + +class MoneyInfoTest { + + @Test + void 최종결제금액은_주문총액에서_쿠폰할인금액을_뺀_금액이다() { + // given + Money totalAmount = Money.from(10000L); + Money discountAmount = Money.from(3000L); + Money finalPaymentAmount = Money.from(7000L); + + // when + MoneyInfo moneyInfo = MoneyInfo.of(totalAmount, discountAmount, finalPaymentAmount); + + // then + Money expectedFinalPaymentAmount = totalAmount.subtract(discountAmount); + assertThat(moneyInfo.getFinalPaymentAmount()).isEqualTo(expectedFinalPaymentAmount); + } + + @Test + void 최종결제금액이_주문총액에서_쿠폰할인금액을_뺀_금액과_다르면_실패한다() { + // given + Money totalAmount = Money.from(10000L); + Money discountAmount = Money.from(3000L); + Money finalPaymentAmount = Money.from(8000L); + + // when & then + assertThatThrownBy(() -> MoneyInfo.of(totalAmount, discountAmount, finalPaymentAmount)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH.getMessage()); + } + + @Test + void 모든_금액이_같으면_같은_객체이다() { + // given + Money totalAmount1 = Money.from(10000L); + Money discountAmount1 = Money.from(3000L); + Money finalPaymentAmount1 = Money.from(7000L); + + Money totalAmount2 = Money.from(10000L); + Money discountAmount2 = Money.from(3000L); + Money finalPaymentAmount2 = Money.from(7000L); + + // when + MoneyInfo moneyInfo1 = MoneyInfo.of(totalAmount1, discountAmount1, finalPaymentAmount1); + MoneyInfo moneyInfo2 = MoneyInfo.of(totalAmount2, discountAmount2, finalPaymentAmount2); + + // then + assertThat(moneyInfo1).isEqualTo(moneyInfo2); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderTest.java b/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderTest.java new file mode 100644 index 000000000..829823fdb --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderTest.java @@ -0,0 +1,187 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OrderTest { + + public static final Money MONEY_5000_WON = Money.from(5000L); + public static final Money MONEY_10000_WON = Money.from(10000L); + public static final Money MONEY_15000_WON = Money.from(15000L); + public static final Money MONEY_20000_WON = Money.from(20000L); + + FixtureHelper fixtureHelper = new FixtureHelper(); + + public Member createAssociateMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private RecruitmentRound createRecruitmentRound( + LocalDateTime startDate, + LocalDateTime endDate, + Integer academicYear, + SemesterType semesterType, + Money fee) { + return fixtureHelper.createRecruitmentRound(startDate, endDate, academicYear, semesterType, fee); + } + + private Membership createMembership(Member member, RecruitmentRound recruitmentRound) { + return fixtureHelper.createMembership(member, recruitmentRound); + } + + @Nested + class 무료주문_생성할때 { + + @Test + void 주문상태는_완료이다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + MoneyInfo freeMoneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_20000_WON, Money.ZERO); + + // when + Order order = Order.createFree("testNanoId", membership, null, freeMoneyInfo); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED); + } + + @Test + void 최종결제금액이_0원이_아니면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + MoneyInfo freeMoneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_15000_WON, MONEY_5000_WON); + + // when & then + assertThatThrownBy(() -> Order.createFree("testNanoId", membership, null, freeMoneyInfo)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_FREE_FINAL_PAYMENT_NOT_ZERO.getMessage()); + } + } + + @Nested + class 주문_취소할때 { + + @Test + void 대기상태이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + Order order = Order.createPending( + "testNanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + + ZonedDateTime canceledAt = ZonedDateTime.now(); + + // when + assertThatThrownBy(() -> order.cancel(canceledAt)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_CANCEL_NOT_COMPLETED.getMessage()); + } + + @Test + void 취소상태이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + Order order = Order.createPending( + "testNanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + order.complete("testPaymentKey", ZonedDateTime.now()); + order.cancel(ZonedDateTime.now()); + + ZonedDateTime canceledAt = ZonedDateTime.now(); + + // when & then + assertThatThrownBy(() -> order.cancel(canceledAt)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_CANCEL_NOT_COMPLETED.getMessage()); + } + + @Test + void 무료주문이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + MoneyInfo freeMoneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_20000_WON, Money.ZERO); + + Order order = Order.createFree("testNanoId", membership, null, freeMoneyInfo); + + ZonedDateTime canceledAt = ZonedDateTime.now(); + + // when & then + assertThatThrownBy(() -> order.cancel(canceledAt)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_CANCEL_FREE_ORDER.getMessage()); + } + + @Test + void 완료상태이면_성공한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2021, + SemesterType.FIRST, + MONEY_10000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + Order order = Order.createPending( + "testNanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + order.complete("testPaymentKey", ZonedDateTime.now()); + + ZonedDateTime canceledAt = ZonedDateTime.now(); + + // when + order.cancel(canceledAt); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED); + assertThat(order.getCanceledAt()).isEqualTo(canceledAt); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderValidatorTest.java new file mode 100644 index 000000000..8d3240a38 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/order/domain/OrderValidatorTest.java @@ -0,0 +1,605 @@ +package com.gdschongik.gdsc.domain.order.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OrderValidatorTest { + + public static final Money MONEY_5000_WON = Money.from(5000L); + public static final Money MONEY_10000_WON = Money.from(10000L); + public static final Money MONEY_15000_WON = Money.from(15000L); + public static final Money MONEY_20000_WON = Money.from(20000L); + + FixtureHelper fixtureHelper = new FixtureHelper(); + OrderValidator orderValidator = new OrderValidator(); + + public Member createAssociateMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private RecruitmentRound createRecruitmentRound( + LocalDateTime startDate, + LocalDateTime endDate, + Integer academicYear, + SemesterType semesterType, + Money fee) { + return fixtureHelper.createRecruitmentRound(startDate, endDate, academicYear, semesterType, fee); + } + + private Membership createMembership(Member member, RecruitmentRound recruitmentRound) { + return fixtureHelper.createMembership(member, recruitmentRound); + } + + private IssuedCoupon createAndIssue(Money money, Member member) { + return fixtureHelper.createAndIssue(money, member); + } + + @Nested + class 임시주문_생성_검증할때 { + + @Test + void 멤버십_대상_멤버와_현재_로그인한_멤버_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Member anotherMember = createAssociateMember(2L); + Membership membership = createMembership(anotherMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_MEMBERSHIP_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 멤버십_회비납부상태_이미_충족되었으면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + membership.verifyPaymentStatus(); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_MEMBERSHIP_ALREADY_PAID.getMessage()); + } + + @Test + void 리크루팅_모집기간이_아니면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + LocalDateTime invalidStartDate = LocalDateTime.now().minusDays(2); + LocalDateTime invalidEndDate = LocalDateTime.now().minusDays(1); + RecruitmentRound recruitmentRound = + createRecruitmentRound(invalidStartDate, invalidEndDate, 2024, SemesterType.FIRST, MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_RECRUITMENT_PERIOD_INVALID.getMessage()); + } + + @Test + void 쿠폰_발급대상_멤버와_현재_로그인한_멤버_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + Member anotherMember = createAssociateMember(2L); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, anotherMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_ISSUED_COUPON_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 회수된_발급쿠폰이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + issuedCoupon.revoke(); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(COUPON_NOT_USABLE_REVOKED.getMessage()); + } + + @Test + void 사용한_발급쿠폰이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + issuedCoupon.use(); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(COUPON_NOT_USABLE_ALREADY_USED.getMessage()); + } + + @Test + void 주문총액이_리크루팅_회비와_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_15000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_TOTAL_AMOUNT_MISMATCH.getMessage()); + } + + @Test + void 쿠폰_미사용시_할인금액이_0이_아니면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON); + + // when & then + assertThatThrownBy( + () -> orderValidator.validatePendingOrderCreate(membership, null, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_DISCOUNT_AMOUNT_NOT_ZERO.getMessage()); + } + + @Test + void 쿠폰_사용시_할인금액이_쿠폰의_할인금액과_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + + MoneyInfo moneyInfo = MoneyInfo.of(MONEY_20000_WON, MONEY_10000_WON, MONEY_10000_WON); + + // when & then + assertThatThrownBy(() -> orderValidator.validatePendingOrderCreate( + membership, issuedCoupon, moneyInfo, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_DISCOUNT_AMOUNT_MISMATCH.getMessage()); + } + } + + @Nested + class 주문_완료_검증할때 { + + @Test + void 이미_완료된_주문이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + Order completedOrder = Order.createPending( + "nanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, Money.ZERO, MONEY_20000_WON)); + completedOrder.complete("paymentKey", ZonedDateTime.now()); + + Optional emptyIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> orderValidator.validateCompleteOrder( + completedOrder, emptyIssuedCoupon, currentMember, MONEY_20000_WON)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_ALREADY_COMPLETED.getMessage()); + } + + @Test + void 발급쿠폰이_사용_불가능하면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + issuedCoupon.use(); // 쿠폰을 사용 불가능한 상태로 만듦 + + Order order = Order.createPending( + "nanoId", membership, issuedCoupon, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatThrownBy(() -> orderValidator.validateCompleteOrder( + order, optionalIssuedCoupon, currentMember, MONEY_15000_WON)) + .isInstanceOf(CustomException.class) + .hasMessage(COUPON_NOT_USABLE_ALREADY_USED.getMessage()); + } + + @Test + void 발급쿠폰_소유자와_현재_멤버가_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + Member anotherMember = createAssociateMember(2L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, anotherMember); + + Order order = Order.createPending( + "nanoId", membership, issuedCoupon, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatThrownBy(() -> orderValidator.validateCompleteOrder( + order, optionalIssuedCoupon, currentMember, MONEY_15000_WON)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_ISSUED_COUPON_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 주문_멤버십_멤버와_현재_멤버가_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + Member anotherMember = createAssociateMember(2L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(anotherMember, recruitmentRound); + + Order order = Order.createPending( + "nanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, Money.ZERO, MONEY_20000_WON)); + + Optional emptyIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> orderValidator.validateCompleteOrder( + order, emptyIssuedCoupon, currentMember, MONEY_20000_WON)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_MEMBERSHIP_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 요청된_금액이_주문의_최종_결제_금액과_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + Order order = Order.createPending( + "nanoId", membership, null, MoneyInfo.of(MONEY_20000_WON, Money.ZERO, MONEY_20000_WON)); + + Optional emptyIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> orderValidator.validateCompleteOrder( + order, emptyIssuedCoupon, currentMember, MONEY_15000_WON)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_COMPLETE_AMOUNT_MISMATCH.getMessage()); + } + + @Test + void 모든_검증을_통과하면_예외가_발생하지_않는다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + MONEY_20000_WON); + Membership membership = createMembership(currentMember, recruitmentRound); + + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + Order order = Order.createPending( + "nanoId", membership, issuedCoupon, MoneyInfo.of(MONEY_20000_WON, MONEY_5000_WON, MONEY_15000_WON)); + + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatCode(() -> orderValidator.validateCompleteOrder( + order, optionalIssuedCoupon, currentMember, MONEY_15000_WON)) + .doesNotThrowAnyException(); + } + } + + @Nested + class 무료주문_생성_검증할때 { + + @Test + void 멤버십_대상_멤버와_현재_로그인한_멤버_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + Member anotherMember = createAssociateMember(2L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(anotherMember, recruitmentRound); + Optional optionalIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_MEMBERSHIP_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 멤버십_회비납부상태_이미_충족되었으면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + membership.verifyPaymentStatus(); + Optional optionalIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_MEMBERSHIP_ALREADY_PAID.getMessage()); + } + + @Test + void 리크루팅_모집기간이_아니면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + LocalDateTime invalidStartDate = LocalDateTime.now().minusDays(2); + LocalDateTime invalidEndDate = LocalDateTime.now().minusDays(1); + RecruitmentRound recruitmentRound = + createRecruitmentRound(invalidStartDate, invalidEndDate, 2024, SemesterType.FIRST, Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + Optional optionalIssuedCoupon = Optional.empty(); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_RECRUITMENT_PERIOD_INVALID.getMessage()); + } + + @Test + void 쿠폰_발급대상_멤버와_현재_로그인한_멤버_다르면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + Member anotherMember = createAssociateMember(2L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, anotherMember); + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(ORDER_ISSUED_COUPON_MEMBER_MISMATCH.getMessage()); + } + + @Test + void 회수된_발급쿠폰이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + issuedCoupon.revoke(); + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(COUPON_NOT_USABLE_REVOKED.getMessage()); + } + + @Test + void 사용한_발급쿠폰이면_실패한다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + issuedCoupon.use(); + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatThrownBy(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .isInstanceOf(CustomException.class) + .hasMessage(COUPON_NOT_USABLE_ALREADY_USED.getMessage()); + } + + @Test + void 모든_검증을_통과하면_예외가_발생하지_않는다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, currentMember); + Optional optionalIssuedCoupon = Optional.of(issuedCoupon); + + // when & then + assertThatCode(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .doesNotThrowAnyException(); + } + + @Test + void 쿠폰없이_모든_검증을_통과하면_예외가_발생하지_않는다() { + // given + Member currentMember = createAssociateMember(1L); + RecruitmentRound recruitmentRound = createRecruitmentRound( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + 2024, + SemesterType.FIRST, + Money.ZERO); + Membership membership = createMembership(currentMember, recruitmentRound); + Optional optionalIssuedCoupon = Optional.empty(); + + // when & then + assertThatCode(() -> + orderValidator.validateFreeOrderCreate(membership, optionalIssuedCoupon, currentMember)) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentServiceTest.java new file mode 100644 index 000000000..4f3e2172b --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentServiceTest.java @@ -0,0 +1,107 @@ +package com.gdschongik.gdsc.domain.recruitment.application; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRepository; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundCreateUpdateRequest; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.IntegrationTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class AdminRecruitmentServiceTest extends IntegrationTest { + + @Autowired + private AdminRecruitmentService adminRecruitmentService; + + @Autowired + private RecruitmentRepository recruitmentRepository; + + @Autowired + private RecruitmentRoundRepository recruitmentRoundRepository; + + @Nested + class 리쿠르팅_생성시 { + + @Test + void 성공한다() { + // given + RecruitmentCreateRequest request = new RecruitmentCreateRequest( + SEMESTER_START_DATE, SEMESTER_END_DATE, ACADEMIC_YEAR, SEMESTER_TYPE, FEE_AMOUNT, FEE_NAME); + + // when + adminRecruitmentService.createRecruitment(request); + + // then + assertThat(recruitmentRepository.findAll()).hasSize(1); + } + } + + @Nested + class 모집회차_생성시 { + + @Test + void 학년도와_학기가_일치하는_리쿠르팅이_존재하지_않는다면_실패한다() { + // given + RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + ACADEMIC_YEAR, SEMESTER_TYPE, RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, ROUND_TYPE); + + // when & then + assertThatThrownBy(() -> adminRecruitmentService.createRecruitmentRound(request)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 모집회차_수정시 { + @Test + void 성공한다() { + // given + LocalDateTime now = LocalDateTime.now().withNano(0); + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(now, now.plusMonths(3))); + recruitmentRepository.save(recruitment); + + RecruitmentRound recruitmentRound = RecruitmentRound.create( + RECRUITMENT_ROUND_NAME, now.plusDays(1), now.plusDays(2), recruitment, ROUND_TYPE); + recruitmentRoundRepository.save(recruitmentRound); + + RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + ACADEMIC_YEAR, SEMESTER_TYPE, "수정된 모집회차 이름", now.plusDays(2), now.plusDays(3), ROUND_TYPE); + + // when + adminRecruitmentService.updateRecruitmentRound(recruitmentRound.getId(), request); + + // then + RecruitmentRound updatedRecruitmentRound = recruitmentRoundRepository + .findById(recruitmentRound.getId()) + .get(); + assertThat(updatedRecruitmentRound.getName()).isEqualTo(request.name()); + assertThat(updatedRecruitmentRound.getPeriod().getStartDate()).isEqualTo(request.startDate()); + assertThat(updatedRecruitmentRound.getPeriod().getEndDate()).isEqualTo(request.endDate()); + } + + @Test + void 모집회차가_존재하지_않는다면_실패한다() { + // given + RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + ACADEMIC_YEAR, SEMESTER_TYPE, RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, ROUND_TYPE); + + // when & then + assertThatThrownBy(() -> adminRecruitmentService.updateRecruitmentRound(1L, request)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/PeriodTest.java b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/PeriodTest.java new file mode 100644 index 000000000..02e3f65b6 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/PeriodTest.java @@ -0,0 +1,47 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.DATE_PRECEDENCE_INVALID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class PeriodTest { + + @Nested + class Period생성시 { + @Test + void 시작일이_종료일보다_앞서면_성공한다() { + // when + Period period = Period.createPeriod(START_DATE, END_DATE); + + // then + assertThat(period.getStartDate()).isEqualTo(START_DATE); + assertThat(period.getEndDate()).isEqualTo(END_DATE); + } + + @Test + void 종료일이_시작일보다_앞서면_실패한다() { + // when & then + assertThatThrownBy(() -> { + Period.createPeriod(END_DATE, START_DATE); + }) + .isInstanceOf(CustomException.class) + .hasMessage(DATE_PRECEDENCE_INVALID.getMessage()); + } + + @Test + void 종료일이_시작일과_같으면_실패한다() { + // when & then + assertThatThrownBy(() -> { + Period.createPeriod(START_DATE, WRONG_END_DATE); + }) + .isInstanceOf(CustomException.class) + .hasMessage(DATE_PRECEDENCE_INVALID.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidatorTest.java new file mode 100644 index 000000000..919593027 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentRoundValidatorTest.java @@ -0,0 +1,217 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +public class RecruitmentRoundValidatorTest { + + RecruitmentRoundValidator recruitmentRoundValidator = new RecruitmentRoundValidator(); + + @Nested + class 모집회차_생성시 { + + @Test + void 모집_시작일과_종료일의_연도가_입력된_학년도와_다르다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + 2025, + SEMESTER_TYPE, + FEE, + FEE_NAME, + Period.createPeriod(LocalDateTime.of(2025, 3, 2, 0, 0), LocalDateTime.of(2025, 8, 31, 0, 0))); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + START_DATE, END_DATE, ROUND_TYPE, recruitment, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS.getMessage()); + } + + @Test + void 학기_시작일과_종료일의_학기가_입력된_학기와_다르다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, + SemesterType.SECOND, + FEE, + FEE_NAME, + Period.createPeriod(LocalDateTime.of(2024, 9, 1, 0, 0), LocalDateTime.of(2025, 2, 28, 0, 0))); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + START_DATE, END_DATE, ROUND_TYPE, recruitment, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS.getMessage()); + } + + @Test + void 모집_시작일과_종료일이_학기_시작일로부터_2주_이내에_있지_않다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + START_DATE, LocalDateTime.of(2024, 4, 10, 0, 0), ROUND_TYPE, recruitment, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS.getMessage()); + } + + @Test + void 학년도_학기_차수가_모두_중복되면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound recruitmentRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + LocalDateTime.of(2024, 3, 8, 0, 0), + LocalDateTime.of(2024, 3, 10, 0, 0), + ROUND_TYPE, + recruitment, + List.of(recruitmentRound))) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_TYPE_OVERLAP.getMessage()); + } + + @Test + void RoundType_1차가_없을때_2차를_생성하려_하면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + START_DATE, END_DATE, RoundType.SECOND, recruitment, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(ROUND_ONE_DOES_NOT_EXIST.getMessage()); + } + + @Test + void 기간이_중복되는_모집회차가_있다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound recruitmentRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundCreate( + START_DATE, END_DATE, ROUND_TYPE, recruitment, List.of(recruitmentRound))) + .isInstanceOf(CustomException.class) + .hasMessage(PERIOD_OVERLAP.getMessage()); + } + } + + @Nested + class 모집회차_수정시 { + + @Test + void 기간이_중복되는_모집회차가_있다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound firstRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + ReflectionTestUtils.setField(firstRound, "id", 1L); + + RecruitmentRound secondRound = RecruitmentRound.create( + RECRUITMENT_ROUND_NAME, ROUND_TWO_START_DATE, ROUND_TWO_END_DATE, recruitment, RoundType.SECOND); + ReflectionTestUtils.setField(secondRound, "id", 2L); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundUpdate( + START_DATE, ROUND_TWO_END_DATE, RoundType.SECOND, secondRound, List.of(firstRound))) + .isInstanceOf(CustomException.class) + .hasMessage(PERIOD_OVERLAP.getMessage()); + } + + @Test + void 차수가_중복되는_모집회차가_있다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound firstRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + ReflectionTestUtils.setField(firstRound, "id", 1L); + + RecruitmentRound secondRound = RecruitmentRound.create( + RECRUITMENT_ROUND_NAME, ROUND_TWO_START_DATE, ROUND_TWO_END_DATE, recruitment, RoundType.SECOND); + ReflectionTestUtils.setField(secondRound, "id", 2L); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundUpdate( + ROUND_TWO_START_DATE, ROUND_TWO_END_DATE, ROUND_TYPE, secondRound, List.of(firstRound))) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_TYPE_OVERLAP.getMessage()); + } + + @Test + void 모집_시작일과_종료일이_학기_시작일로부터_2주_이내에_있지_않다면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound firstRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + ReflectionTestUtils.setField(firstRound, "id", 1L); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundUpdate( + START_DATE, LocalDateTime.of(2024, 4, 10, 0, 0), ROUND_TYPE, firstRound, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS.getMessage()); + } + + @Test + void RoundType_1차를_2차로_수정하려_하면_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound firstRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + ReflectionTestUtils.setField(firstRound, "id", 1L); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundUpdate( + START_DATE, END_DATE, RoundType.SECOND, firstRound, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(ROUND_ONE_DOES_NOT_EXIST.getMessage()); + } + + @Test + void 모집_시작일이_지났다면_수정_실패한다() { + // given + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, SEMESTER_TYPE, FEE, FEE_NAME, Period.createPeriod(START_DATE, END_DATE)); + + RecruitmentRound recruitmentRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + long recruitmentRoundId = 1L; + ReflectionTestUtils.setField(recruitmentRound, "id", recruitmentRoundId); + + // when & then + assertThatThrownBy(() -> recruitmentRoundValidator.validateRecruitmentRoundUpdate( + START_DATE, END_DATE, ROUND_TYPE, recruitmentRound, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_ROUND_STARTDATE_ALREADY_PASSED.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentTest.java b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentTest.java new file mode 100644 index 000000000..1b276f00e --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentTest.java @@ -0,0 +1,34 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class RecruitmentTest { + + @Nested + class 학기생성시 { + @Test + void Period가_제대로_생성된다() { + // given + Period period = Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE); + + // when + Recruitment recruitment = Recruitment.createRecruitment( + ACADEMIC_YEAR, + SEMESTER_TYPE, + FEE, + FEE_NAME, + Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE)); + + // then + assertThat(recruitment.getSemesterPeriod().getStartDate()).isEqualTo(SEMESTER_START_DATE); + assertThat(recruitment.getSemesterPeriod().getEndDate()).isEqualTo(SEMESTER_END_DATE); + assertThat(recruitment.getSemesterPeriod().equals(period)).isTrue(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidatorTest.java new file mode 100644 index 000000000..e83c687e1 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/domain/RecruitmentValidatorTest.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.recruitment.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.global.exception.CustomException; +import org.junit.jupiter.api.Test; + +public class RecruitmentValidatorTest { + + RecruitmentValidator recruitmentValidator = new RecruitmentValidator(); + + @Test + void 학년도_학기가_모두_중복되는_리쿠르팅이라면_실패한다() { + // given + boolean isRecruitmentOverlap = true; + + // when & then + assertThatThrownBy(() -> recruitmentValidator.validateRecruitmentCreate(isRecruitmentOverlap)) + .isInstanceOf(CustomException.class) + .hasMessage(RECRUITMENT_OVERLAP.getMessage()); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java new file mode 100644 index 000000000..64177d3a4 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.helper.IntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class StudyServiceTest extends IntegrationTest { + + @Autowired + private StudyService studyService; + + @Nested + class 스터디_수강신청시 { + + @Test + void 존재하지_않는_스터디라면_실패한다() { + // when & then + assertThatThrownBy(() -> studyService.applyStudy(1L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.STUDY_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java new file mode 100644 index 000000000..4631ab5da --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java @@ -0,0 +1,99 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class StudyHistoryValidatorTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + StudyHistoryValidator studyHistoryValidator = new StudyHistoryValidator(); + + @Nested + class 스터디_수강신청시 { + + @Test + void 이미_해당_스터디를_신청했다면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.plusDays(10), now.plusDays(15)); + Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.plusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + + Member mentee = fixtureHelper.createGuestMember(2L); + StudyHistory studyHistory = StudyHistory.create(mentee, study); + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateApplyStudy(study, List.of(studyHistory))) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_HISTORY_DUPLICATE.getMessage()); + } + + @Test + void 해당_스터디의_신청기간이_아니라면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.plusDays(10), now.plusDays(15)); + Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.minusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateApplyStudy(study, List.of())) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_NOT_APPLICABLE.getMessage()); + } + + @Test + void 이미_듣고_있는_스터디가_있다면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.minusDays(5), now.plusDays(15)); + Period applicationPeriod = Period.createPeriod(now.minusDays(15), now.plusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + + Study anotherStudy = fixtureHelper.createStudy(mentor, period, applicationPeriod); + + Member mentee = fixtureHelper.createGuestMember(2L); + StudyHistory studyHistory = StudyHistory.create(mentee, anotherStudy); + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateApplyStudy(study, List.of(studyHistory))) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_HISTORY_ONGOING_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + class 스터디_수강신청_취소시 { + + @Test + void 해당_스터디의_신청기간이_아니라면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.plusDays(10), now.plusDays(15)); + Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.minusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateCancelStudyApply(study)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_NOT_CANCELABLE_APPLICATION_PERIOD.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java new file mode 100644 index 000000000..131a58783 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java @@ -0,0 +1,154 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.domain.member.domain.Department.D022; +import static com.gdschongik.gdsc.domain.member.domain.Member.createGuestMember; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +public class StudyTest { + + private Member createAssociateMember(Long id) { + Member member = createGuestMember(OAUTH_ID); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + @Nested + class 스터디_개설시 { + + @Test + void 게스트인_회원을_멘토로_지정하면_실패한다() { + // given + Member guestMember = Member.createGuestMember(OAUTH_ID); + Period period = Period.createPeriod(START_DATE, END_DATE); + Period applicationPeriod = Period.createPeriod(START_DATE.minusDays(10), START_DATE.minusDays(5)); + + // when & then + assertThatThrownBy(() -> Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + guestMember, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + STUDY_START_TIME, + STUDY_END_TIME)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_MENTOR_IS_UNAUTHORIZED.getMessage()); + } + + @Test + void 신청기간_시작일이_스터디_시작일보다_늦으면_실패한다() { + // given + Member member = createAssociateMember(1L); + Period period = Period.createPeriod(START_DATE, END_DATE); + Period applicationPeriod = Period.createPeriod(START_DATE.plusDays(1), START_DATE.plusDays(2)); + + // when & then + assertThatThrownBy(() -> Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + member, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + STUDY_START_TIME, + STUDY_END_TIME)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_APPLICATION_START_DATE_INVALID.getMessage()); + } + + @Test + void 온오프라인_스터디에_스터디_시각이_없으면_실패한다() { + // given + Member member = createAssociateMember(1L); + Period period = Period.createPeriod(START_DATE, END_DATE); + Period applicationPeriod = Period.createPeriod(START_DATE.minusDays(5), START_DATE.plusDays(3)); + + // when & then + assertThatThrownBy(() -> Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + member, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + null, + null)) + .isInstanceOf(CustomException.class) + .hasMessage(ON_OFF_LINE_STUDY_TIME_IS_ESSENTIAL.getMessage()); + } + + @Test + void 온오프라인_스터디에_스터디_시작시각이_종료시각보다_늦으면_실패한다() { + // given + Member member = createAssociateMember(1L); + Period period = Period.createPeriod(START_DATE, END_DATE); + Period applicationPeriod = Period.createPeriod(START_DATE.minusDays(5), START_DATE.plusDays(3)); + LocalTime studyStartTime = STUDY_START_TIME; + LocalTime studyEndTime = STUDY_START_TIME.minusHours(2); + + // when & then + assertThatThrownBy(() -> Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + member, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + studyStartTime, + studyEndTime)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_TIME_INVALID.getMessage()); + } + + @Test + void 과제_스터디에_스터디_시각이_있으면_실패한다() { + // given + Member member = createAssociateMember(1L); + Period period = Period.createPeriod(START_DATE, END_DATE); + Period applicationPeriod = Period.createPeriod(START_DATE.minusDays(5), START_DATE.plusDays(3)); + LocalTime studyStartTime = STUDY_START_TIME; + LocalTime studyEndTime = STUDY_END_TIME; + + // when & then + assertThatThrownBy(() -> Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + member, + period, + applicationPeriod, + TOTAL_WEEK, + ASSIGNMENT_STUDY, + DAY_OF_WEEK, + studyStartTime, + studyEndTime)) + .isInstanceOf(CustomException.class) + .hasMessage(ASSIGNMENT_STUDY_CAN_NOT_INPUT_STUDY_TIME.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/CouponConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/CouponConstant.java new file mode 100644 index 000000000..3cc47ee15 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/CouponConstant.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class CouponConstant { + public static final String COUPON_NAME = "테스트 쿠폰 이름"; + + private CouponConstant() {} +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java new file mode 100644 index 000000000..c17bd60d4 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/DiscordConstant.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class DiscordConstant { + private DiscordConstant() {} + + public static final String DISCORD_USERNAME = "유저네임"; + public static final Integer DISCORD_CODE = 1234; + public static final Long DISCORD_CODE_TTL = 300L; +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/MemberConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/MemberConstant.java new file mode 100644 index 000000000..53af108bc --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/MemberConstant.java @@ -0,0 +1,17 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class MemberConstant { + + private MemberConstant() {} + + public static final String OAUTH_ID = "testOauthId"; + public static final String UNIV_EMAIL = "b000000@g.hongik.ac.kr"; + public static final String EMAIL = "test@email.com"; + public static final String DISCORD_USERNAME = "testDiscord"; + public static final String DISCORD_ID = "12341231234123412"; + public static final String NICKNAME = "testNickname"; + public static final String NAME = "김홍익"; + public static final String STUDENT_ID = "C123456"; + public static final String PHONE_NUMBER = "01012345678"; + public static final String MODIFIED_STUDENT_ID = "C123458"; +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/RecruitmentConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/RecruitmentConstant.java new file mode 100644 index 000000000..ac8f06584 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/RecruitmentConstant.java @@ -0,0 +1,29 @@ +package com.gdschongik.gdsc.global.common.constant; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class RecruitmentConstant { + // 1차 모집 상수 + public static final String RECRUITMENT_ROUND_NAME = "2024학년도 1학기 1차 모집"; + public static final LocalDateTime START_DATE = LocalDateTime.of(2024, 3, 2, 0, 0); + public static final LocalDateTime BETWEEN_START_AND_END_DATE = LocalDateTime.of(2024, 3, 3, 0, 0); + public static final LocalDateTime WRONG_END_DATE = LocalDateTime.of(2024, 3, 2, 0, 0); + public static final LocalDateTime END_DATE = LocalDateTime.of(2024, 3, 5, 0, 0); + public static final Integer ACADEMIC_YEAR = 2024; + public static final SemesterType SEMESTER_TYPE = SemesterType.FIRST; + public static final Money FEE = Money.from(20000L); + public static final BigDecimal FEE_AMOUNT = BigDecimal.valueOf(20000); + public static final String FEE_NAME = "2024학년도 1학기 정회원 회비"; + public static final RoundType ROUND_TYPE = RoundType.FIRST; + + // 2차 모집 상수 + public static final String ROUND_TWO_RECRUITMENT_NAME = "2024학년도 1학기 2차 모집"; + public static final LocalDateTime ROUND_TWO_START_DATE = LocalDateTime.of(2024, 3, 8, 0, 0); + public static final LocalDateTime ROUND_TWO_END_DATE = LocalDateTime.of(2024, 3, 10, 0, 0); + + private RecruitmentConstant() {} +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/SemesterConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/SemesterConstant.java new file mode 100644 index 000000000..a95bfd179 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/SemesterConstant.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.global.common.constant; + +import java.time.LocalDateTime; + +public class SemesterConstant { + public static final LocalDateTime SEMESTER_START_DATE = LocalDateTime.of(2024, 3, 2, 0, 0); + public static final LocalDateTime SEMESTER_END_DATE = LocalDateTime.of(2024, 8, 31, 0, 0); + + private SemesterConstant() {} +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java new file mode 100644 index 000000000..e263e49b8 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java @@ -0,0 +1,16 @@ +package com.gdschongik.gdsc.global.common.constant; + +import com.gdschongik.gdsc.domain.study.domain.StudyType; +import java.time.DayOfWeek; +import java.time.LocalTime; + +public class StudyConstant { + private StudyConstant() {} + + public static final Long TOTAL_WEEK = 8L; + public static final StudyType ONLINE_STUDY = StudyType.ONLINE; + public static final StudyType ASSIGNMENT_STUDY = StudyType.ASSIGNMENT; + public static final DayOfWeek DAY_OF_WEEK = DayOfWeek.FRIDAY; + public static final LocalTime STUDY_START_TIME = LocalTime.of(19, 0, 0); + public static final LocalTime STUDY_END_TIME = LocalTime.of(20, 0, 0); +} diff --git a/src/test/java/com/gdschongik/gdsc/integration/DatabaseCleaner.java b/src/test/java/com/gdschongik/gdsc/helper/DatabaseCleaner.java similarity index 63% rename from src/test/java/com/gdschongik/gdsc/integration/DatabaseCleaner.java rename to src/test/java/com/gdschongik/gdsc/helper/DatabaseCleaner.java index e6070531a..2a014d0f1 100644 --- a/src/test/java/com/gdschongik/gdsc/integration/DatabaseCleaner.java +++ b/src/test/java/com/gdschongik/gdsc/helper/DatabaseCleaner.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.integration; +package com.gdschongik.gdsc.helper; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -24,12 +24,27 @@ public void afterPropertiesSet() { em.unwrap(Session.class).doWork(this::extractTableNames); } - private void extractTableNames(Connection conn) throws SQLException { + private void extractTableNames(Connection conn) { tableNames = em.getMetamodel().getEntities().stream() - .map(EntityType::getName) + .map(DatabaseCleaner::getTableName) .toList(); } + private static String getTableName(EntityType entity) { + // TODO: 임시로 Order 테이블만 orders로 변환하도록 처리함. 추후 다른 테이블도 처리해야 함. + if (entity.getName().equals("Order")) { + return "orders"; + } + return convertCamelCaseToSnakeCase(entity.getName()); + } + + /** + * 카멜 케이스로 되어있는 엔티티 이름을 스네이크 케이스로 되어있는 테이블 이름으로 변환한다. + */ + private static String convertCamelCaseToSnakeCase(String name) { + return name.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + } + public void execute() { em.unwrap(Session.class).doWork(this::cleanTables); } diff --git a/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java new file mode 100644 index 000000000..b0849699c --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java @@ -0,0 +1,82 @@ +package com.gdschongik.gdsc.helper; + +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.domain.member.domain.Member.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.domain.Coupon; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Study; +import java.time.LocalDateTime; +import org.springframework.test.util.ReflectionTestUtils; + +public class FixtureHelper { + + public Member createGuestMember(Long id) { + Member member = Member.createGuestMember(OAUTH_ID); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + public Member createAssociateMember(Long id) { + Member member = createGuestMember(id); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + return member; + } + + public Member createRegularMember(Long id) { + Member member = createAssociateMember(id); + member.advanceToRegular(); + return member; + } + + public RecruitmentRound createRecruitmentRound( + LocalDateTime startDate, + LocalDateTime endDate, + Integer academicYear, + SemesterType semesterType, + Money fee) { + Recruitment recruitment = Recruitment.createRecruitment( + academicYear, semesterType, fee, FEE_NAME, Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE)); + + return RecruitmentRound.create(RECRUITMENT_ROUND_NAME, startDate, endDate, recruitment, RoundType.FIRST); + } + + public Membership createMembership(Member member, RecruitmentRound recruitmentRound) { + return Membership.createMembership(member, recruitmentRound); + } + + public IssuedCoupon createAndIssue(Money money, Member member) { + Coupon coupon = Coupon.createCoupon("테스트쿠폰", money); + return IssuedCoupon.issue(coupon, member); + } + + public Study createStudy(Member mentor, Period period, Period applicationPeriod) { + return Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + mentor, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + STUDY_START_TIME, + STUDY_END_TIME); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java new file mode 100644 index 000000000..5a132ee69 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java @@ -0,0 +1,163 @@ +package com.gdschongik.gdsc.helper; + +import static com.gdschongik.gdsc.domain.member.domain.Department.*; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; +import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; +import static org.mockito.Mockito.*; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.common.vo.Money; +import com.gdschongik.gdsc.domain.coupon.dao.CouponRepository; +import com.gdschongik.gdsc.domain.coupon.dao.IssuedCouponRepository; +import com.gdschongik.gdsc.domain.coupon.domain.Coupon; +import com.gdschongik.gdsc.domain.coupon.domain.IssuedCoupon; +import com.gdschongik.gdsc.domain.discord.application.handler.DelegateMemberDiscordEventHandler; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; +import com.gdschongik.gdsc.domain.membership.domain.Membership; +import com.gdschongik.gdsc.domain.recruitment.application.OnboardingRecruitmentService; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRepository; +import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRoundRepository; +import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; +import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; +import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.security.PrincipalDetails; +import com.gdschongik.gdsc.infra.feign.payment.client.PaymentClient; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @Autowired + protected DatabaseCleaner databaseCleaner; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected RecruitmentRepository recruitmentRepository; + + @Autowired + protected MembershipRepository membershipRepository; + + @Autowired + protected CouponRepository couponRepository; + + @Autowired + protected IssuedCouponRepository issuedCouponRepository; + + @Autowired + protected RecruitmentRoundRepository recruitmentRoundRepository; + + @MockBean + protected OnboardingRecruitmentService onboardingRecruitmentService; + + @MockBean + protected PaymentClient paymentClient; + + @MockBean + protected DelegateMemberDiscordEventHandler delegateMemberDiscordEventHandler; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + doNothing().when(delegateMemberDiscordEventHandler).delegate(any()); + } + + protected void logoutAndReloginAs(Long memberId, MemberRole memberRole) { + PrincipalDetails principalDetails = new PrincipalDetails(memberId, memberRole); + Authentication authentication = + new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + protected Member createMember() { + Member member = Member.createGuestMember(OAUTH_ID); + memberRepository.save(member); + + member.completeUnivEmailVerification(UNIV_EMAIL); + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.updateDiscordId(DISCORD_ID); + + return memberRepository.save(member); + } + + protected Member createGuestMember() { + Member guestMember = Member.createGuestMember(OAUTH_ID); + return memberRepository.save(guestMember); + } + + protected Member createAssociateMember() { + Member member = createGuestMember(); + + member.updateBasicMemberInfo(STUDENT_ID, NAME, PHONE_NUMBER, D022, EMAIL); + member.completeUnivEmailVerification(UNIV_EMAIL); + member.verifyDiscord(DISCORD_USERNAME, NICKNAME); + member.verifyBevy(); + member.advanceToAssociate(); + return memberRepository.save(member); + } + + protected Member createRegularMember() { + Member member = createAssociateMember(); + + member.advanceToRegular(); + return memberRepository.save(member); + } + + protected RecruitmentRound createRecruitmentRound() { + Recruitment recruitment = createRecruitment(ACADEMIC_YEAR, SEMESTER_TYPE, FEE); + + RecruitmentRound recruitmentRound = + RecruitmentRound.create(RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, recruitment, ROUND_TYPE); + + return recruitmentRoundRepository.save(recruitmentRound); + } + + protected RecruitmentRound createRecruitmentRound( + String name, + LocalDateTime startDate, + LocalDateTime endDate, + Integer academicYear, + SemesterType semesterType, + RoundType roundType, + Money fee) { + Recruitment recruitment = createRecruitment(academicYear, semesterType, fee); + + RecruitmentRound recruitmentRound = RecruitmentRound.create(name, startDate, endDate, recruitment, roundType); + return recruitmentRoundRepository.save(recruitmentRound); + } + + protected Recruitment createRecruitment(Integer academicYear, SemesterType semesterType, Money fee) { + Recruitment recruitment = Recruitment.createRecruitment( + academicYear, semesterType, fee, FEE_NAME, Period.createPeriod(SEMESTER_START_DATE, SEMESTER_END_DATE)); + return recruitmentRepository.save(recruitment); + } + + protected Membership createMembership(Member member, RecruitmentRound recruitmentRound) { + Membership membership = Membership.createMembership(member, recruitmentRound); + return membershipRepository.save(membership); + } + + protected IssuedCoupon createAndIssue(Money money, Member member) { + Coupon coupon = Coupon.createCoupon("테스트쿠폰", money); + couponRepository.save(coupon); + IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); + return issuedCouponRepository.save(issuedCoupon); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java b/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java new file mode 100644 index 000000000..3b00b1e35 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java @@ -0,0 +1,29 @@ +package com.gdschongik.gdsc.helper; + +import com.gdschongik.gdsc.config.TestQuerydslConfig; +import com.gdschongik.gdsc.config.TestRedisConfig; +import org.junit.jupiter.api.BeforeEach; +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.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import({TestQuerydslConfig.class, TestRedisConfig.class, DatabaseCleaner.class}) +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class RepositoryTest { + + @Autowired + protected DatabaseCleaner databaseCleaner; + + @Autowired + protected TestEntityManager testEntityManager; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/integration/IntegrationTest.java b/src/test/java/com/gdschongik/gdsc/integration/IntegrationTest.java deleted file mode 100644 index c8634f8b6..000000000 --- a/src/test/java/com/gdschongik/gdsc/integration/IntegrationTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.gdschongik.gdsc.integration; - -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -public abstract class IntegrationTest { - - @Autowired - protected DatabaseCleaner databaseCleaner; - - @BeforeEach - void setUp() { - databaseCleaner.execute(); - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 4cd183245..f0eff7cce 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -4,7 +4,13 @@ spring: on-profile: "test" datasource: - url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL;NON_KEYWORDS=YEAR,ORDER discord: enabled: false + +jwt: + token: + EMAIL_VERIFICATION_TOKEN: + secret: 235872q3ywhtf87q243yt98qop42y3whtg8oiq92ayh4gq2g + expiration-time: 1800