diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml index 79250f20e..a2d48e3bc 100644 --- a/.github/workflows/develop_build_deploy.yml +++ b/.github/workflows/develop_build_deploy.yml @@ -38,7 +38,7 @@ jobs: # Redis 컨테이너 실행 - name: Start containers - run: docker-compose -f ./docker-compose-test.yaml up -d + run: docker compose -f docker-compose-test.yaml up -d # Gradle 빌드 - name: Setup Gradle diff --git a/.github/workflows/production_build_deploy.yml b/.github/workflows/production_build_deploy.yml index 15ff83407..4feae8fcf 100644 --- a/.github/workflows/production_build_deploy.yml +++ b/.github/workflows/production_build_deploy.yml @@ -36,14 +36,14 @@ jobs: java-version: ${{ matrix.java-version }} distribution: ${{ matrix.distribution }} + # Redis 컨테이너 실행 + - name: Start containers + run: docker compose -f docker-compose-test.yaml up -d + # Gradlew 실행 허용 - name: Run chmod to make gradlew executable run: chmod +x ./gradlew - # Redis 컨테이너 실행 - - name: Start containers - run: docker-compose -f ./docker-compose-test.yaml up -d - # Gradle 빌드 - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 diff --git a/.github/workflows/pull_request_gradle_build.yml b/.github/workflows/pull_request_gradle_build.yml index 93673e0c0..5fe92cce3 100644 --- a/.github/workflows/pull_request_gradle_build.yml +++ b/.github/workflows/pull_request_gradle_build.yml @@ -26,7 +26,7 @@ jobs: # Redis 컨테이너 실행 - name: Start containers - run: docker-compose -f ./docker-compose-test.yaml up -d + run: docker compose -f docker-compose-test.yaml up -d - name: Setup Gradle id: gradle diff --git a/build.gradle b/build.gradle index dfddafb7e..c09ffb85b 100644 --- a/build.gradle +++ b/build.gradle @@ -60,7 +60,6 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - testImplementation 'org.testcontainers:testcontainers' // Querydsl implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' @@ -93,6 +92,9 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'io.github.openfeign:feign-jackson' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // Github + implementation 'org.kohsuke:github-api:1.323' } tasks.named('test') { diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/api/AuthController.java b/src/main/java/com/gdschongik/gdsc/domain/auth/api/AuthController.java new file mode 100644 index 000000000..e50fbb60e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/api/AuthController.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.domain.auth.api; + +import static com.gdschongik.gdsc.global.common.constant.JwtConstant.Constants.*; + +import com.gdschongik.gdsc.global.util.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Auth", description = "인증 API입니다.") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final CookieUtil cookieUtil; + + @Operation(summary = "로그아웃", description = "현재 엑세스 토큰 및 리프레시 토큰 쿠키를 만료시킵니다.") + @GetMapping("/logout") + public ResponseEntity logout( + @CookieValue(ACCESS_TOKEN_COOKIE_NAME) Cookie accessToken, + @CookieValue(REFRESH_TOKEN_COOKIE_NAME) Cookie refreshToken, + HttpServletResponse response) { + cookieUtil.deleteCookie(accessToken, response); + cookieUtil.deleteCookie(refreshToken, response); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java b/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java index 4a9d96c1e..e6706422e 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java @@ -6,8 +6,12 @@ import com.gdschongik.gdsc.domain.auth.domain.RefreshToken; import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberManageRole; import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; +import com.gdschongik.gdsc.global.security.MemberAuthInfo; import com.gdschongik.gdsc.global.util.JwtUtil; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -22,8 +26,8 @@ public class JwtService { private final JwtUtil jwtUtil; private final RefreshTokenRepository refreshTokenRepository; - public AccessTokenDto createAccessToken(Long memberId, MemberRole memberRole) { - return jwtUtil.generateAccessToken(memberId, memberRole); + public AccessTokenDto createAccessToken(MemberAuthInfo authInfo) { + return jwtUtil.generateAccessToken(authInfo); } public RefreshTokenDto createRefreshToken(Long memberId) { @@ -86,9 +90,16 @@ public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) { jwtUtil.parseAccessToken(accessTokenValue); return null; } catch (ExpiredJwtException e) { - Long memberId = Long.parseLong(e.getClaims().getSubject()); - MemberRole memberRole = MemberRole.valueOf(e.getClaims().get(TOKEN_ROLE_NAME, String.class)); - return createAccessToken(memberId, memberRole); + Claims claims = e.getClaims(); + + Long memberId = Long.parseLong(claims.getSubject()); + MemberRole memberRole = MemberRole.valueOf(claims.get(TOKEN_ROLE_NAME, String.class)); + MemberManageRole memberManageRole = + MemberManageRole.valueOf(claims.get(TOKEN_MANAGE_ROLE_NAME, String.class)); + MemberStudyRole memberStudyRole = MemberStudyRole.valueOf(claims.get(TOKEN_STUDY_ROLE_NAME, String.class)); + var authInfo = new MemberAuthInfo(memberId, memberRole, memberManageRole, memberStudyRole); + + return createAccessToken(authInfo); } } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java index 503ab11b0..541c4e42c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java @@ -1,5 +1,5 @@ package com.gdschongik.gdsc.domain.auth.dto; -import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.security.MemberAuthInfo; -public record AccessTokenDto(Long memberId, MemberRole memberRole, String tokenValue) {} +public record AccessTokenDto(MemberAuthInfo authInfo, String tokenValue) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java index a2b2fe48c..c29522376 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/RequirementStatus.java @@ -6,7 +6,7 @@ @Getter @AllArgsConstructor public enum RequirementStatus { - PENDING("PENDING"), + UNSATISFIED("UNSATISFIED"), SATISFIED("SATISFIED"); private final String value; diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/MemberDiscordRoleRevokeHandler.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/MemberDiscordRoleRevokeHandler.java new file mode 100644 index 000000000..af3ec1ad2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/handler/MemberDiscordRoleRevokeHandler.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.MemberDemotedToAssociateEvent; +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 MemberDiscordRoleRevokeHandler implements SpringEventHandler { + + private final DiscordUtil discordUtil; + + @Override + public void delegate(Object context) { + MemberDemotedToAssociateEvent event = (MemberDemotedToAssociateEvent) context; + Guild guild = discordUtil.getCurrentGuild(); + Member member = discordUtil.getMemberById(event.discordId()); + Role role = discordUtil.findRoleByName(MEMBER_ROLE_NAME); + + guild.removeRoleFromMember(member, role).queue(); + + log.info( + "[MemberDiscordRoleRevokeHandler] 디스코드 서버 정회원 역할 제거 완료: memberId={}, discordId={}", + event.memberId(), + event.discordId()); + } +} 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 index ef67bdc9a..9e0c4e32f 100644 --- 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 @@ -1,5 +1,7 @@ package com.gdschongik.gdsc.domain.discord.application.listener; +import static com.gdschongik.gdsc.global.common.constant.DiscordConstant.*; + import com.gdschongik.gdsc.domain.discord.application.handler.DiscordIdBatchCommandHandler; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -15,6 +17,8 @@ public class DiscordIdBatchCommandListener extends ListenerAdapter { private final DiscordIdBatchCommandHandler discordIdBatchCommandHandler; public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { - discordIdBatchCommandHandler.delegate(event); + if (event.getName().equals(COMMAND_NAME_BATCH_DISCORD_ID)) { + discordIdBatchCommandHandler.delegate(event); + } } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/MemberDemotedToAssociateEventListener.java b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/MemberDemotedToAssociateEventListener.java new file mode 100644 index 000000000..8482e9b7f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/application/listener/MemberDemotedToAssociateEventListener.java @@ -0,0 +1,26 @@ +package com.gdschongik.gdsc.domain.discord.application.listener; + +import com.gdschongik.gdsc.domain.discord.application.handler.MemberDiscordRoleRevokeHandler; +import com.gdschongik.gdsc.domain.member.domain.MemberDemotedToAssociateEvent; +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 MemberDemotedToAssociateEventListener { + + private final MemberDiscordRoleRevokeHandler memberDiscordRoleRevokeHandler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void demoteMemberToAssociate(MemberDemotedToAssociateEvent event) { + log.info( + "[MemberDemotedToAssociateEventListener] 회원 준회원 강등 이벤트 수신: memberId={}, discordId={}", + event.memberId(), + event.discordId()); + memberDiscordRoleRevokeHandler.delegate(event); + } +} 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 index 31dafe58c..c8d376a3b 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/discord/domain/DiscordValidator.java +++ b/src/main/java/com/gdschongik/gdsc/domain/discord/domain/DiscordValidator.java @@ -3,7 +3,7 @@ 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.domain.member.domain.MemberManageRole; import com.gdschongik.gdsc.global.annotation.DomainService; import com.gdschongik.gdsc.global.exception.CustomException; @@ -32,7 +32,7 @@ public void validateVerifyDiscordCode( } public void validateAdminPermission(Member currentMember) { - if (!currentMember.getRole().equals(MemberRole.ADMIN)) { + if (!currentMember.getManageRole().equals(MemberManageRole.ADMIN)) { throw new CustomException(INVALID_ROLE); } } 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 index 9767fbb7e..66fd9918a 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/email/domain/EmailVerificationStatusService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/email/domain/EmailVerificationStatusService.java @@ -15,7 +15,7 @@ public UnivVerificationStatus determineStatus( } else { return univEmailVerification.isPresent() ? UnivVerificationStatus.IN_PROGRESS - : UnivVerificationStatus.PENDING; + : UnivVerificationStatus.UNSATISFIED; } } } 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 index c16800b27..0c093d3e4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/api/TestMemberController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/api/TestMemberController.java @@ -2,6 +2,7 @@ import com.gdschongik.gdsc.domain.member.application.AdminMemberService; import com.gdschongik.gdsc.domain.member.application.OnboardingMemberService; +import com.gdschongik.gdsc.domain.member.application.TestMemberService; 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; @@ -17,9 +18,17 @@ @RequiredArgsConstructor public class TestMemberController { + private final TestMemberService testMemberService; private final OnboardingMemberService onboardingMemberService; private final AdminMemberService adminMemberService; + @Operation(summary = "게스트 회원 생성", description = "테스트용 API입니다. 깃허브 핸들명을 입력받아 임시 회원을 생성합니다.") + @PostMapping + public ResponseEntity createTemporaryMember(@RequestParam("handle") String githubHandle) { + testMemberService.createTestMember(githubHandle); + return ResponseEntity.ok().build(); + } + @Operation(summary = "임시 토큰 생성", description = "테스트용 API입니다. oauth_id를 입력받아 해당하는 유저의 토큰을 생성합니다.") @PostMapping("/token") public ResponseEntity createTemporaryToken(@Valid @RequestBody MemberTokenRequest request) { @@ -30,7 +39,7 @@ public ResponseEntity createTemporaryToken(@Valid @RequestB @Operation(summary = "게스트로 강등", description = "테스트용 API입니다. 현재 멤버 역할을 게스트로 강등시키기 위해 사용합니다.") @PatchMapping("/demotion") public ResponseEntity demoteToGuest() { - adminMemberService.demoteToGuestAndRegularRequirementToPending(); + adminMemberService.demoteToGuestAndRegularRequirementToUnsatisfied(); 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 573f63c51..38a91e815 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 @@ -88,11 +88,8 @@ public void demoteAllRegularMembersToAssociate(MemberDemoteRequest request) { regularMembers.stream().map(Member::getId).toList()); } - /** - * 정회원 조건 PENDING으로 변경, 준회원 조건 PENDING으로 변경 - */ @Transactional - public void demoteToGuestAndRegularRequirementToPending() { + public void demoteToGuestAndRegularRequirementToUnsatisfied() { validateProfile(); Member member = memberUtil.getCurrentMember(); member.demoteToGuest(); 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 61d8b7775..3ae725a12 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 @@ -62,4 +62,23 @@ public void advanceMemberToRegularByMembership(Long membershipId) { log.info("[CommonMemberService] 정회원 승급 완료: memberId={}", member.getId()); } } + + /** + * 이벤트 핸들러에서 사용되므로, `@Transactional` 을 사용하지 않습니다. + */ + public void demoteMemberToAssociateByMembership(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)); + + member.demoteToAssociate(); + + 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 75a493e20..846d66d48 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 @@ -22,6 +22,7 @@ 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.security.MemberAuthInfo; import com.gdschongik.gdsc.global.util.EnvironmentUtil; import com.gdschongik.gdsc.global.util.MemberUtil; import java.util.Optional; @@ -70,15 +71,16 @@ public MemberBasicInfoResponse getMemberBasicInfo() { 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); + Optional currentRecruitmentRound = onboardingRecruitmentService.findCurrentRecruitmentRound(); + Optional myMembership = currentRecruitmentRound.flatMap( + recruitmentRound -> membershipService.findMyMembership(member, recruitmentRound)); return MemberDashboardResponse.of( - member, univVerificationStatus, currentRecruitmentRound, myMembership.orElse(null)); + member, univVerificationStatus, currentRecruitmentRound.orElse(null), myMembership.orElse(null)); } public MemberTokenResponse createTemporaryToken(MemberTokenRequest request) { @@ -88,7 +90,7 @@ public MemberTokenResponse createTemporaryToken(MemberTokenRequest request) { .findByOauthId(request.oauthId()) .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND)); - AccessTokenDto accessTokenDto = jwtService.createAccessToken(member.getId(), member.getRole()); + AccessTokenDto accessTokenDto = jwtService.createAccessToken(MemberAuthInfo.from(member)); RefreshTokenDto refreshTokenDto = jwtService.createRefreshToken(member.getId()); return new MemberTokenResponse(accessTokenDto.tokenValue(), refreshTokenDto.tokenValue()); diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/TestMemberService.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/TestMemberService.java new file mode 100644 index 000000000..d30703784 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/TestMemberService.java @@ -0,0 +1,49 @@ +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.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; + +@Slf4j +@Service +public class TestMemberService { + + private final MemberRepository memberRepository; + private final RestClient restClient; + + public TestMemberService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + this.restClient = RestClient.builder().baseUrl("https://api.github.com").build(); + } + + @Transactional + public void createTestMember(String githubHandle) { + String githubOauthId = getGithubOauthId(githubHandle); + + if (memberRepository.findByOauthId(githubOauthId).isPresent()) { + throw new CustomException(INTERNAL_SERVER_ERROR); + } + + Member guestMember = Member.createGuestMember(githubOauthId); + memberRepository.save(guestMember); + } + + private String getGithubOauthId(String githubHandle) { + return Optional.ofNullable(restClient + .get() + .uri("/users/{githubHandle}", githubHandle) + .retrieve() + .body(GithubUser.class)) + .map(GithubUser::id) + .orElseThrow(() -> new CustomException(INTERNAL_SERVER_ERROR)); + } + + private record GithubUser(String id) {} +} 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 index 772d08fa7..648a495a9 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/AssociateRequirement.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/AssociateRequirement.java @@ -46,10 +46,10 @@ private AssociateRequirement( public static AssociateRequirement createRequirement() { return AssociateRequirement.builder() - .univStatus(PENDING) - .discordStatus(PENDING) - .bevyStatus(PENDING) - .infoStatus(PENDING) + .univStatus(UNSATISFIED) + .discordStatus(UNSATISFIED) + .bevyStatus(UNSATISFIED) + .infoStatus(UNSATISFIED) .build(); } @@ -119,9 +119,9 @@ public void checkVerifiableUniv() { * 모든 준회원 조건을 강등합니다. */ public void demoteAssociateRequirement() { - bevyStatus = PENDING; - discordStatus = PENDING; - infoStatus = PENDING; - univStatus = PENDING; + bevyStatus = UNSATISFIED; + discordStatus = UNSATISFIED; + infoStatus = UNSATISFIED; + univStatus = UNSATISFIED; } } 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 c278899c8..2270544d3 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 @@ -1,6 +1,8 @@ package com.gdschongik.gdsc.domain.member.domain; +import static com.gdschongik.gdsc.domain.member.domain.MemberManageRole.*; import static com.gdschongik.gdsc.domain.member.domain.MemberRole.*; +import static com.gdschongik.gdsc.domain.member.domain.MemberStudyRole.*; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.domain.common.model.BaseEntity; @@ -34,6 +36,12 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private MemberRole role; + @Enumerated(EnumType.STRING) + private MemberManageRole manageRole; + + @Enumerated(EnumType.STRING) + private MemberStudyRole studyRole; + @Enumerated(EnumType.STRING) private MemberStatus status; @@ -67,6 +75,8 @@ public class Member extends BaseEntity { @Builder(access = AccessLevel.PRIVATE) private Member( MemberRole role, + MemberManageRole manageRole, + MemberStudyRole studyRole, MemberStatus status, String name, String studentId, @@ -80,6 +90,8 @@ private Member( String univEmail, AssociateRequirement associateRequirement) { this.role = role; + this.manageRole = manageRole; + this.studyRole = studyRole; this.status = status; this.name = name; this.studentId = studentId; @@ -99,6 +111,8 @@ public static Member createGuestMember(String oauthId) { return Member.builder() .oauthId(oauthId) .role(GUEST) + .manageRole(NONE) + .studyRole(STUDENT) .status(MemberStatus.NORMAL) .associateRequirement(associateRequirement) .build(); @@ -245,6 +259,8 @@ public void demoteToAssociate() { validateStatusUpdatable(); role = ASSOCIATE; + + registerEvent(new MemberDemotedToAssociateEvent(id, discordId)); } /** @@ -268,6 +284,13 @@ public void demoteToGuest() { associateRequirement.demoteAssociateRequirement(); } + // 기타 역할 변경 로직 + + public void assignToMentor() { + validateStatusUpdatable(); + studyRole = MENTOR; + } + // 기타 상태 변경 로직 public void updateLastLoginAt() { diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberDemotedToAssociateEvent.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberDemotedToAssociateEvent.java new file mode 100644 index 000000000..9dcbacb57 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberDemotedToAssociateEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.member.domain; + +public record MemberDemotedToAssociateEvent(Long memberId, String discordId) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberManageRole.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberManageRole.java new file mode 100644 index 000000000..14c6d3e94 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberManageRole.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.member.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberManageRole { + ADMIN("ROLE_ADMIN"), + NONE("ROLE_NONE"); + + private final String value; +} 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 225332638..6a418cb71 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 @@ -1,15 +1,14 @@ package com.gdschongik.gdsc.domain.member.domain; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.RequiredArgsConstructor; @Getter -@AllArgsConstructor +@RequiredArgsConstructor public enum MemberRole { GUEST("ROLE_GUEST"), ASSOCIATE("ROLE_ASSOCIATE"), - REGULAR("ROLE_REGULAR"), - ADMIN("ROLE_ADMIN"); + REGULAR("ROLE_REGULAR"); private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberStudyRole.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberStudyRole.java new file mode 100644 index 000000000..d5ecca826 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/MemberStudyRole.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.member.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberStudyRole { + MENTOR("ROLE_MENTOR"), + STUDENT("ROLE_STUDENT"); + + private final String value; +} 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 index abfc4b842..e638961e9 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberFullDto.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/MemberFullDto.java @@ -3,7 +3,9 @@ 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.MemberManageRole; import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; import com.gdschongik.gdsc.global.util.formatter.PhoneFormatter; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Optional; @@ -11,6 +13,8 @@ public record MemberFullDto( Long memberId, @Schema(description = "멤버 역할", implementation = MemberRole.class) MemberRole role, + @Schema(description = "멤버 관리자 역할", implementation = MemberManageRole.class) MemberManageRole manageRole, + @Schema(description = "멤버 스터디 역할", implementation = MemberStudyRole.class) MemberStudyRole studyRole, @Schema(description = "회원정보", implementation = MemberBasicInfoDto.class) MemberBasicInfoDto basicInfo, @Schema(description = "인증상태정보", implementation = MemberAssociateRequirementDto.class) MemberAssociateRequirementDto associateRequirement) { @@ -18,6 +22,8 @@ public static MemberFullDto of(Member member, UnivVerificationStatus univVerific return new MemberFullDto( member.getId(), member.getRole(), + member.getManageRole(), + member.getStudyRole(), MemberBasicInfoDto.from(member), MemberAssociateRequirementDto.of(member, univVerificationStatus)); } 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 index 7152df0af..19674e9f9 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dto/UnivVerificationStatus.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/UnivVerificationStatus.java @@ -6,7 +6,7 @@ @Getter @AllArgsConstructor public enum UnivVerificationStatus { - PENDING("PENDING"), + UNSATISFIED("UNSATISFIED"), IN_PROGRESS("IN_PROGRESS"), SATISFIED("SATISFIED"); 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 index ca88b1b23..85b996ee4 100644 --- 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 @@ -11,7 +11,7 @@ public record MemberDashboardResponse( MemberFullDto member, - RecruitmentRoundFullDto currentRecruitmentRound, + @Nullable RecruitmentRoundFullDto currentRecruitmentRound, @Nullable MembershipFullDto currentMembership) { public static MemberDashboardResponse of( Member member, @@ -20,7 +20,7 @@ public static MemberDashboardResponse of( Membership currentMembership) { return new MemberDashboardResponse( MemberFullDto.of(member, univVerificationStatus), - RecruitmentRoundFullDto.from(currentRecruitmentRound), + currentRecruitmentRound == null ? null : RecruitmentRoundFullDto.from(currentRecruitmentRound), currentMembership == null ? null : MembershipFullDto.from(currentMembership)); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipEventHandler.java similarity index 54% rename from src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java rename to src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipEventHandler.java index 4580949d3..6b252fdbd 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipVerifiedEventHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipEventHandler.java @@ -1,6 +1,7 @@ package com.gdschongik.gdsc.domain.membership.application; import com.gdschongik.gdsc.domain.member.application.CommonMemberService; +import com.gdschongik.gdsc.domain.membership.domain.MembershipPaymentRevokedEvent; import com.gdschongik.gdsc.domain.membership.domain.MembershipVerifiedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,13 +11,19 @@ @Slf4j @Component @RequiredArgsConstructor -public class MembershipVerifiedEventHandler { +public class MembershipEventHandler { private final CommonMemberService commonMemberService; @EventListener public void handleMembershipVerifiedEvent(MembershipVerifiedEvent event) { - log.info("[MembershipVerifiedEventHandler] 멤버십 인증 이벤트 수신: membershipId={}", event.membershipId()); + log.info("[MembershipEventHandler] 멤버십 인증 이벤트 수신: membershipId={}", event.membershipId()); commonMemberService.advanceMemberToRegularByMembership(event.membershipId()); } + + @EventListener + public void handleMembershipPaymentRevokedEvent(MembershipPaymentRevokedEvent event) { + log.info("[MembershipEventHandler] 멤버십 회비납입 취소 이벤트 수신: membershipId={}", event.membershipId()); + commonMemberService.demoteMemberToAssociateByMembership(event.membershipId()); + } } 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 index bc24e7f0c..717fef1bf 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/application/MembershipService.java @@ -97,4 +97,21 @@ public void deleteMembership(Member member) { myMembershipOpt.ifPresent(membershipRepository::delete); } + + /** + * 이벤트 핸들러에서 사용되므로, `@Transactional` 을 사용하지 않습니다. + */ + public void revokePaymentStatus(Long orderId) { + Order order = orderRepository.findById(orderId).orElseThrow(() -> new CustomException(ORDER_NOT_FOUND)); + + Membership membership = membershipRepository + .findById(order.getMembershipId()) + .orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND)); + + membership.revokePaymentStatus(); + + membershipRepository.save(membership); + + log.info("[MembershipService] 멤버십 회비납입 취소 완료: membershipId={}", membership.getId()); + } } 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 index fdad73bb7..128836c8a 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/Membership.java +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/Membership.java @@ -75,6 +75,21 @@ public void verifyPaymentStatus() { registerEvent(new MembershipVerifiedEvent(id)); } + public void revokePaymentStatus() { + validatePaymentStatusRevocable(); + + regularRequirement.updatePaymentStatus(UNSATISFIED); + + registerEvent(new MembershipPaymentRevokedEvent(id)); + } + + private void validatePaymentStatusRevocable() { + // TODO: 이벤트로 트리거되는 로직이더라도 예외 던지도록 수정 + if (!regularRequirement.isPaymentSatisfied()) { + throw new CustomException(MEMBERSHIP_PAYMENT_NOT_REVOCABLE_NOT_SATISFIED); + } + } + // 데이터 전달 로직 public boolean isRegularRequirementAllSatisfied() { diff --git a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipPaymentRevokedEvent.java b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipPaymentRevokedEvent.java new file mode 100644 index 000000000..128b9734a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/MembershipPaymentRevokedEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.membership.domain; + +public record MembershipPaymentRevokedEvent(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 index d522fcd30..9bc49ebeb 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/membership/domain/RegularRequirement.java +++ b/src/main/java/com/gdschongik/gdsc/domain/membership/domain/RegularRequirement.java @@ -29,7 +29,7 @@ private RegularRequirement(RequirementStatus paymentStatus) { public static RegularRequirement createUnsatisfiedRequirement() { return RegularRequirement.builder() - .paymentStatus(RequirementStatus.PENDING) + .paymentStatus(RequirementStatus.UNSATISFIED) .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 index 3af0f182f..8065544bc 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderEventHandler.java +++ b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderEventHandler.java @@ -1,6 +1,7 @@ package com.gdschongik.gdsc.domain.order.application; import com.gdschongik.gdsc.domain.membership.application.MembershipService; +import com.gdschongik.gdsc.domain.order.domain.OrderCanceledEvent; import com.gdschongik.gdsc.domain.order.domain.OrderCompletedEvent; import com.gdschongik.gdsc.domain.order.domain.OrderCreatedEvent; import lombok.RequiredArgsConstructor; @@ -32,4 +33,10 @@ public void handleOrderCompletedEvent(OrderCompletedEvent orderCompletedEvent) { log.info("[OrderEventHandler] 주문 완료 이벤트 수신: nanoId={}", orderCompletedEvent.nanoId()); membershipService.verifyPaymentStatus(orderCompletedEvent.nanoId()); } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCanceledEvent(OrderCanceledEvent orderCanceledEvent) { + log.info("[OrderEventHandler] 주문 취소 이벤트 수신: orderId={}", orderCanceledEvent.orderId()); + membershipService.revokePaymentStatus(orderCanceledEvent.orderId()); + } } 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 index e3f61802e..cfbf6dd72 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/order/application/OrderService.java @@ -129,6 +129,8 @@ public void cancelOrder(Long orderId, OrderCancelRequest request) { order.cancel(canceledAt); + orderRepository.save(order); + log.info("[OrderService] 주문 취소: orderId={}", order.getId()); } 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 index 35c49206e..d2d7d7b9b 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepositoryImpl.java +++ b/src/main/java/com/gdschongik/gdsc/domain/order/dao/OrderCustomRepositoryImpl.java @@ -68,6 +68,8 @@ private List getIdsByQueryOption( .from(order) .innerJoin(recruitmentRound) .on(order.recruitmentRoundId.eq(recruitmentRound.id)) + .innerJoin(member) + .on(order.memberId.eq(member.id)) .where(matchesOrderQueryOption(queryOption), predicate) .orderBy(orderSpecifiers) .fetch(); 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 index 37d82f9fa..08dc19ba1 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java @@ -141,10 +141,11 @@ public void complete(String paymentKey, ZonedDateTime approvedAt) { * 상태 변경 및 취소 시각을 저장하며, 예외를 발생시키지 않도록 외부 취소 요청 전에 validateCancelable을 호출합니다. */ public void cancel(ZonedDateTime canceledAt) { - // TODO: 취소 이벤트 발행을 통해 멤버십 및 멤버 상태에 대한 변경 로직 추가 validateCancelable(); this.status = OrderStatus.CANCELED; this.canceledAt = canceledAt; + + registerEvent(new OrderCanceledEvent(id)); } public void validateCancelable() { diff --git a/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCanceledEvent.java b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCanceledEvent.java new file mode 100644 index 000000000..6a0d1a437 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/order/domain/OrderCanceledEvent.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.order.domain; + +public record OrderCanceledEvent(Long orderId) {} 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 index 90ee88fa5..b77495e70 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/recruitment/api/AdminRecruitmentController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/api/AdminRecruitmentController.java @@ -2,7 +2,8 @@ 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.request.RecruitmentRoundCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundUpdateRequest; 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; @@ -50,8 +51,7 @@ public ResponseEntity> getAllRecruitmentRoun @Operation(summary = "모집회차 생성", description = "새로운 모집회차를 생성합니다. 모집기간은 학기 시작일로부터 2주 이내입니다.") @PostMapping("/rounds") - public ResponseEntity createRecruitmentRound( - @Valid @RequestBody RecruitmentRoundCreateUpdateRequest request) { + public ResponseEntity createRecruitmentRound(@Valid @RequestBody RecruitmentRoundCreateRequest request) { adminRecruitmentService.createRecruitmentRound(request); return ResponseEntity.ok().build(); } @@ -59,7 +59,7 @@ public ResponseEntity createRecruitmentRound( @Operation(summary = "모집회차 수정", description = "기존 모집회차를 수정합니다. 학년도와 학기는 수정 대상이 아닙니다.") @PutMapping("/rounds/{recruitmentRoundId}") public ResponseEntity updateRecruitmentRound( - @PathVariable Long recruitmentRoundId, @Valid @RequestBody RecruitmentRoundCreateUpdateRequest request) { + @PathVariable Long recruitmentRoundId, @Valid @RequestBody RecruitmentRoundUpdateRequest 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 index e69f8b104..23bab5cd0 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentService.java @@ -11,7 +11,8 @@ 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.request.RecruitmentRoundCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundUpdateRequest; 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; @@ -63,7 +64,7 @@ public List getAllRecruitmentRounds() { } @Transactional - public void createRecruitmentRound(RecruitmentRoundCreateUpdateRequest request) { + public void createRecruitmentRound(RecruitmentRoundCreateRequest request) { Recruitment recruitment = recruitmentRepository .findByAcademicYearAndSemesterType(request.academicYear(), request.semesterType()) .orElseThrow(() -> new CustomException(RECRUITMENT_NOT_FOUND)); @@ -87,7 +88,7 @@ public void createRecruitmentRound(RecruitmentRoundCreateUpdateRequest request) } @Transactional - public void updateRecruitmentRound(Long recruitmentRoundId, RecruitmentRoundCreateUpdateRequest request) { + public void updateRecruitmentRound(Long recruitmentRoundId, RecruitmentRoundUpdateRequest request) { List recruitmentRounds = recruitmentRoundRepository.findAllByAcademicYearAndSemesterType( request.academicYear(), request.semesterType()); 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 index f2f43a5ec..3913d6a46 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/OnboardingRecruitmentService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/application/OnboardingRecruitmentService.java @@ -2,8 +2,6 @@ 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; @@ -17,11 +15,10 @@ public class OnboardingRecruitmentService { private final RecruitmentRoundRepository recruitmentRoundRepository; // TODO: 모집기간과 별도로 표시기간 사용하여 필터링하도록 변경 - public RecruitmentRound findCurrentRecruitmentRound() { + public Optional findCurrentRecruitmentRound() { return recruitmentRoundRepository.findAll().stream() .filter(RecruitmentRound::isOpen) // isOpen -> isDisplayable - .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.RECRUITMENT_ROUND_OPEN_NOT_FOUND)); + .findFirst(); } /** 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/RecruitmentRoundCreateRequest.java similarity index 95% rename from src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateUpdateRequest.java rename to src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateRequest.java index 80b377fdc..c19ae5fb7 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateUpdateRequest.java +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundCreateRequest.java @@ -10,7 +10,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; -public record RecruitmentRoundCreateUpdateRequest( +public record RecruitmentRoundCreateRequest( @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) Integer academicYear, @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, diff --git a/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundUpdateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundUpdateRequest.java new file mode 100644 index 000000000..72f1cf5f6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/recruitment/dto/request/RecruitmentRoundUpdateRequest.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 RecruitmentRoundUpdateRequest( + @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) + Integer academicYear, + @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, + @NotBlank @Schema(description = "이름") String name, + @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/study/api/MentorStudyDetailController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java new file mode 100644 index 000000000..e74145482 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java @@ -0,0 +1,65 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.MentorStudyDetailService; +import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; +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.PatchMapping; +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; + +@Tag(name = "Mentor StudyDetail", description = "멘토 스터디 상세 관리 API입니다.") +@RestController +@RequestMapping("/mentor/study-details") +@RequiredArgsConstructor +public class MentorStudyDetailController { + + private final MentorStudyDetailService mentorStudyDetailService; + + @Operation(summary = "스터디 과제 수정", description = "멘토만 과제를 수정할 수 있습니다.") + @PatchMapping("/{studyDetailId}/assignments") + public ResponseEntity updateStudyAssignment( + @PathVariable Long studyDetailId, @Valid @RequestBody AssignmentCreateUpdateRequest request) { + mentorStudyDetailService.updateStudyAssignment(studyDetailId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 과제 개설", description = "멘토만 과제를 개설할 수 있습니다.") + @PutMapping("/{studyDetailId}/assignments") + public ResponseEntity publishStudyAssignment( + @PathVariable Long studyDetailId, @Valid @RequestBody AssignmentCreateUpdateRequest request) { + mentorStudyDetailService.publishStudyAssignment(studyDetailId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 주차별 과제 목록 조회", description = "주차별 스터디 과제 목록을 조회합니다.") + @GetMapping("/assignments") + public ResponseEntity> getWeeklyAssignments(@RequestParam(name = "studyId") Long studyId) { + List response = mentorStudyDetailService.getWeeklyAssignments(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "스터디 과제 상세 조회", description = "멘토가 자신의 스터디 과제를 조회합니다.") + @GetMapping("/{studyDetailId}/assignments") + public ResponseEntity getStudyAssignment(@PathVariable Long studyDetailId) { + AssignmentResponse response = mentorStudyDetailService.getAssignment(studyDetailId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "스터디 과제 휴강 처리", description = "해당 주차 과제를 휴강 처리합니다.") + @PatchMapping("/{studyDetailId}/assignments/cancel") + public ResponseEntity cancelStudyAssignment(@PathVariable Long studyDetailId) { + mentorStudyDetailService.cancelStudyAssignment(studyDetailId); + 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 deleted file mode 100644 index 5e6f2a344..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyMentorController.java +++ /dev/null @@ -1,23 +0,0 @@ -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/MentorStudyDetailService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java new file mode 100644 index 000000000..a6c3c5485 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java @@ -0,0 +1,86 @@ +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.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyDetailValidator; +import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; +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 +public class MentorStudyDetailService { + + private final MemberUtil memberUtil; + private final StudyDetailRepository studyDetailRepository; + private final StudyDetailValidator studyDetailValidator; + + @Transactional(readOnly = true) + public List getWeeklyAssignments(Long studyId) { + List studyDetails = studyDetailRepository.findAllByStudyId(studyId); + return studyDetails.stream().map(AssignmentResponse::from).toList(); + } + + @Transactional(readOnly = true) + public AssignmentResponse getAssignment(Long studyDetailId) { + StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + return AssignmentResponse.from(studyDetail); + } + + @Transactional + public void cancelStudyAssignment(Long studyDetailId) { + Member currentMember = memberUtil.getCurrentMember(); + StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + + studyDetailValidator.validateCancelStudyAssignment(currentMember, studyDetail); + + studyDetail.cancelAssignment(); + studyDetailRepository.save(studyDetail); + + log.info("[MentorStudyDetailService] 과제 휴강 처리: studyDetailId={}", studyDetail.getId()); + } + + @Transactional + public void publishStudyAssignment(Long studyDetailId, AssignmentCreateUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + + studyDetailValidator.validatePublishStudyAssignment(currentMember, studyDetail, request); + + studyDetail.publishAssignment(request.title(), request.deadLine(), request.descriptionNotionLink()); + studyDetailRepository.save(studyDetail); + + log.info("[MentorStudyDetailService] 과제 개설 완료: studyDetailId={}", studyDetailId); + } + + @Transactional + public void updateStudyAssignment(Long studyDetailId, AssignmentCreateUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + + studyDetailValidator.validateUpdateStudyAssignment(currentMember, studyDetail, request); + + studyDetail.updateAssignment(request.title(), request.deadLine(), request.descriptionNotionLink()); + studyDetailRepository.save(studyDetail); + + log.info("[MentorStudyDetailService] 과제 수정 완료: studyDetailId={}", studyDetailId); + } +} 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 index fe5910c0d..350872753 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java @@ -1,6 +1,10 @@ package com.gdschongik.gdsc.domain.study.dao; import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudyDetailRepository extends JpaRepository {} +public interface StudyDetailRepository extends JpaRepository { + + List findAllByStudyId(Long studyId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java new file mode 100644 index 000000000..e340b458e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java @@ -0,0 +1,78 @@ +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.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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AssignmentHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "assignment_history_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_detail_id") + private StudyDetail studyDetail; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(columnDefinition = "TEXT") + private String submissionLink; + + private String commitHash; + + private Long contentLength; + + @Enumerated(EnumType.STRING) + private AssignmentSubmissionStatus submissionStatus; + + @Enumerated(EnumType.STRING) + private SubmissionFailureType submissionFailureType; + + @Builder(access = AccessLevel.PRIVATE) + private AssignmentHistory( + StudyDetail studyDetail, + Member member, + String submissionLink, + String commitHash, + Long contentLength, + AssignmentSubmissionStatus submissionStatus) { + this.studyDetail = studyDetail; + this.member = member; + this.submissionLink = submissionLink; + this.commitHash = commitHash; + this.contentLength = contentLength; + this.submissionStatus = submissionStatus; + } + + public static AssignmentHistory create( + StudyDetail studyDetail, Member member, String submissionLink, String commitHash, Long contentLength) { + return AssignmentHistory.builder() + .studyDetail(studyDetail) + .member(member) + .submissionLink(submissionLink) + .commitHash(commitHash) + .contentLength(contentLength) + .submissionStatus(AssignmentSubmissionStatus.PENDING) + .build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java new file mode 100644 index 000000000..4ac62cd11 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AssignmentSubmissionStatus { + PENDING("제출 전"), + FAILURE("제출 실패"), + SUCCESS("제출 성공"), + CANCELLED("과제 휴강"); + + private final String value; +} 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 index 154ce8a90..6057fa5ea 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java @@ -5,6 +5,7 @@ import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; import com.gdschongik.gdsc.domain.study.domain.vo.Session; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -69,4 +70,16 @@ public static StudyDetail createStudyDetail(Study study, Long week, String atten .assignment(Assignment.createEmptyAssignment()) .build(); } + + public void cancelAssignment() { + assignment = Assignment.cancelAssignment(); + } + + public void publishAssignment(String title, LocalDateTime deadLine, String descriptionNotionLink) { + assignment = Assignment.generateAssignment(title, deadLine, descriptionNotionLink); + } + + public void updateAssignment(String title, LocalDateTime deadLine, String descriptionNotionLink) { + assignment = Assignment.generateAssignment(title, deadLine, descriptionNotionLink); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java new file mode 100644 index 000000000..59ab1afb8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java @@ -0,0 +1,57 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; + +@DomainService +public class StudyDetailValidator { + + public void validateCancelStudyAssignment(Member member, StudyDetail studyDetail) { + validateStudyMentorAuthorization(member, studyDetail); + } + + public void validatePublishStudyAssignment( + Member member, StudyDetail studyDetail, AssignmentCreateUpdateRequest request) { + validateStudyMentorAuthorization(member, studyDetail); + validateDeadLine(request.deadLine()); + } + + // 해당 스터디의 멘토가 아니라면 스터디에 대한 권한이 없다. + private void validateStudyMentorAuthorization(Member member, StudyDetail studyDetail) { + if (!member.getId().equals(studyDetail.getStudy().getMentor().getId())) { + throw new CustomException(STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR); + } + } + + private void validateDeadLine(LocalDateTime deadline) { + if (deadline.isBefore(LocalDateTime.now())) { + throw new CustomException(ASSIGNMENT_DEADLINE_INVALID); + } + } + + public void validateUpdateStudyAssignment( + Member currentMember, StudyDetail studyDetail, AssignmentCreateUpdateRequest request) { + + validateStudyMentorAuthorization(currentMember, studyDetail); + validateUpdateDeadline(LocalDateTime.now(), studyDetail.getAssignment().getDeadline(), request.deadLine()); + } + + /** + * 과제 마감기한이 수정 시점보다 앞서거나 수정할 마감기한이 기존 마감기한보다 앞서면 안된다. + */ + private void validateUpdateDeadline( + LocalDateTime currentTime, LocalDateTime deadLine, LocalDateTime updateDeadLine) { + if (currentTime.isAfter(deadLine)) { + throw new CustomException(STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE); + } + + if (deadLine.isAfter(updateDeadLine)) { + throw new CustomException(STUDY_DETAIL_ASSIGNMENT_INVALID_UPDATE_DEADLINE); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java new file mode 100644 index 000000000..252b556e9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubmissionFailureType { + NOT_SUBMITTED("미제출"), + WORD_COUNT_INSUFFICIENT("글자수 부족"), + LOCATION_UNIDENTIFIABLE("위치 확인불가"); + + private final String value; +} 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 index c426f35d6..ea480bed8 100644 --- 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 @@ -1,5 +1,9 @@ package com.gdschongik.gdsc.domain.study.domain.vo; +import static com.gdschongik.gdsc.domain.study.domain.StudyStatus.*; +import static com.gdschongik.gdsc.domain.study.domain.StudyStatus.CANCELLED; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + import com.gdschongik.gdsc.domain.study.domain.Difficulty; import com.gdschongik.gdsc.domain.study.domain.StudyStatus; import jakarta.persistence.Column; @@ -46,6 +50,19 @@ private Assignment( } public static Assignment createEmptyAssignment() { - return Assignment.builder().status(StudyStatus.NONE).build(); + return Assignment.builder().status(NONE).build(); + } + + public static Assignment cancelAssignment() { + return Assignment.builder().status(CANCELLED).build(); + } + + public static Assignment generateAssignment(String title, LocalDateTime deadline, String descriptionLink) { + return Assignment.builder() + .title(title) + .deadline(deadline) + .descriptionLink(descriptionLink) + .status(StudyStatus.OPEN) + .build(); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/AssignmentCreateUpdateRequest.java similarity index 81% rename from src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java rename to src/main/java/com/gdschongik/gdsc/domain/study/dto/request/AssignmentCreateUpdateRequest.java index 160e492eb..23c82e948 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/request/AssignmentCreateRequest.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/AssignmentCreateUpdateRequest.java @@ -1,11 +1,11 @@ -package com.gdschongik.gdsc.domain.study.domain.request; +package com.gdschongik.gdsc.domain.study.dto.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( +public record AssignmentCreateUpdateRequest( @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/dto/response/AssignmentResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java new file mode 100644 index 000000000..33414fed9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AssignmentResponse( + Long studyDetailId, + @Schema(description = "과제 제목") String title, + @Schema(description = "마감 기한") String deadline, + @Schema(description = "과제 명세 링크") String descriptionLink, + @Schema(description = "과제 상태") StudyStatus assignmentStatus) { + public static AssignmentResponse from(StudyDetail studyDetail) { + Assignment assignment = studyDetail.getAssignment(); + return new AssignmentResponse( + studyDetail.getId(), + assignment.getTitle(), + assignment.getDeadline().toString(), + assignment.getDescriptionLink(), + assignment.getStatus()); + } +} 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 49599aa37..2b3bf7cb7 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 @@ -1,7 +1,9 @@ package com.gdschongik.gdsc.global.common.constant; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @AllArgsConstructor @@ -12,7 +14,8 @@ public enum JwtConstant { private final String cookieName; - private static class Constants { + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public 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/SecurityConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java index ebb0770c3..15e402d9d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java @@ -2,14 +2,11 @@ public class SecurityConstant { - public static final String LANDING_STATUS_PARAM = "landing-status"; - public static final String ACCESS_TOKEN_PARAM = "access"; - public static final String REFRESH_TOKEN_PARAM = "refresh"; public static final String TOKEN_ROLE_NAME = "role"; + public static final String TOKEN_MANAGE_ROLE_NAME = "manageRole"; + public static final String TOKEN_STUDY_ROLE_NAME = "studyRole"; public static final String GITHUB_NAME_ATTR_KEY = "id"; public static final String ACCESS_TOKEN_HEADER_PREFIX = "Bearer "; - public static final String ACCESS_TOKEN_HEADER_NAME = "Authorization"; - public static final String OAUTH_BASE_URI_COOKIE_NAME = "oauth-base-uri"; public static final String OAUTH_REDIRECT_PATH_SEGMENT = "/social-login/redirect"; private SecurityConstant() {} 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 e307e0269..a4623c5d8 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 java.util.List; - public class UrlConstant { private UrlConstant() {} @@ -16,12 +14,6 @@ private UrlConstant() {} 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_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/config/GithubConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/GithubConfig.java new file mode 100644 index 000000000..eb5dd6ac5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/GithubConfig.java @@ -0,0 +1,21 @@ +package com.gdschongik.gdsc.global.config; + +import com.gdschongik.gdsc.global.property.GithubProperty; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class GithubConfig { + + private final GithubProperty githubProperty; + + @Bean + public GitHub github() throws IOException { + return new GitHubBuilder().withOAuthToken(githubProperty.getSecretKey()).build(); + } +} 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 4b304a2c7..e27273d6b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java @@ -3,6 +3,7 @@ import com.gdschongik.gdsc.global.property.BasicAuthProperty; import com.gdschongik.gdsc.global.property.DiscordProperty; import com.gdschongik.gdsc.global.property.EmailProperty; +import com.gdschongik.gdsc.global.property.GithubProperty; import com.gdschongik.gdsc.global.property.JwtProperty; import com.gdschongik.gdsc.global.property.PaymentProperty; import com.gdschongik.gdsc.global.property.RedisProperty; @@ -15,7 +16,8 @@ BasicAuthProperty.class, DiscordProperty.class, EmailProperty.class, - PaymentProperty.class + PaymentProperty.class, + GithubProperty.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 077a68923..290114147 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java @@ -116,6 +116,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authenticated() .requestMatchers("/admin/**") .hasRole("ADMIN") + .requestMatchers("/mentor/**") + .hasRole("MENTOR") .anyRequest() .authenticated()); @@ -172,10 +174,11 @@ 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.addAllowedOriginPattern(LOCAL_PROXY_CLIENT_ONBOARDING_URL); + configuration.addAllowedHeader("*"); configuration.addAllowedMethod("*"); configuration.setAllowCredentials(true); 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 843ecbbcb..17686780c 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -18,8 +18,6 @@ public enum ErrorCode { EXPIRED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 JWT 토큰입니다."), AUTH_NOT_EXIST(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보가 존재하지 않습니다."), AUTH_NOT_PARSABLE(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보 파싱에 실패했습니다."), - BASE_URI_COOKIE_NOT_FOUND(HttpStatus.NOT_FOUND, "Base URI 쿠키가 존재하지 않습니다."), - NOT_ALLOWED_BASE_URI(HttpStatus.FORBIDDEN, "허용되지 않은 Base URI입니다."), INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), INVALID_ROLE(HttpStatus.FORBIDDEN, "권한이 없습니다."), @@ -76,6 +74,7 @@ public enum ErrorCode { MEMBERSHIP_ALREADY_SATISFIED(HttpStatus.CONFLICT, "이미 이번 학기에 정회원 승급을 완료한 회원입니다."), MEMBERSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 멤버십이 존재하지 않습니다."), MEMBERSHIP_RECRUITMENT_ROUND_NOT_OPEN(HttpStatus.CONFLICT, "리크루팅 회차 모집기간이 아닙니다."), + MEMBERSHIP_PAYMENT_NOT_REVOCABLE_NOT_SATISFIED(HttpStatus.CONFLICT, "회비납부를 완료한 경우에만 멤버십 회비납부상태를 취소할 수 있습니다."), // Recruitment DATE_PRECEDENCE_INVALID(HttpStatus.BAD_REQUEST, "종료일이 시작일과 같거나 앞설 수 없습니다."), @@ -88,7 +87,6 @@ public enum ErrorCode { 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보다 커야 합니다."), @@ -109,6 +107,12 @@ public enum ErrorCode { STUDY_NOT_APPLICABLE(HttpStatus.CONFLICT, "스터디 신청기간이 아닙니다."), STUDY_NOT_CANCELABLE_APPLICATION_PERIOD(HttpStatus.CONFLICT, "스터디 신청기간이 아니라면 취소할 수 없습니다."), + // StudyDetail + STUDY_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 상세 정보입니다."), + STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR(HttpStatus.CONFLICT, "해당 스터디의 멘토만 수정할 수 있습니다."), + STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE(HttpStatus.CONFLICT, "마감기한이 지난 과제의 마감기한을 수정할 수 없습니다"), + STUDY_DETAIL_ASSIGNMENT_INVALID_UPDATE_DEADLINE(HttpStatus.CONFLICT, "수정하려고 하는 과제의 마감기한은 기존의 마감기한보다 빠르면 안됩니다."), + // StudyHistory STUDY_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 수강 기록입니다."), STUDY_HISTORY_DUPLICATE(HttpStatus.CONFLICT, "이미 해당 스터디를 신청했습니다."), @@ -135,7 +139,10 @@ public enum ErrorCode { // Order - MoneyInfo ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액은 주문총액에서 할인금액을 뺀 값이어야 합니다."), - ; + + // Assignment + ASSIGNMENT_CAN_NOT_BE_UPDATED(HttpStatus.CONFLICT, "휴강인 과제는 수정할 수 없습니다."), + ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/gdschongik/gdsc/global/property/GithubProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/GithubProperty.java new file mode 100644 index 000000000..2e95cb533 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/GithubProperty.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 = "github") +public class GithubProperty { + 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 b363fcce0..5a7b62cb0 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java @@ -3,7 +3,6 @@ import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.GITHUB_NAME_ATTR_KEY; import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.member.domain.MemberRole; import lombok.Getter; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -11,14 +10,10 @@ @Getter public class CustomOAuth2User extends DefaultOAuth2User { - private final Long memberId; - private final MemberRole memberRole; - private final LandingStatus landingStatus; + private final MemberAuthInfo memberAuthInfo; public CustomOAuth2User(OAuth2User oAuth2User, Member member) { super(oAuth2User.getAuthorities(), oAuth2User.getAttributes(), GITHUB_NAME_ATTR_KEY); - memberId = member.getId(); - memberRole = member.getRole(); - landingStatus = LandingStatus.TO_DASHBOARD; + memberAuthInfo = MemberAuthInfo.from(member); } } 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 02d6e822d..08c1d687d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java @@ -1,15 +1,12 @@ package com.gdschongik.gdsc.global.security; import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; -import static com.gdschongik.gdsc.global.common.constant.UrlConstant.*; -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.global.exception.CustomException; import com.gdschongik.gdsc.global.util.CookieUtil; -import jakarta.servlet.http.Cookie; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -27,53 +24,30 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler public CustomSuccessHandler(JwtService jwtService, CookieUtil cookieUtil) { this.jwtService = jwtService; this.cookieUtil = cookieUtil; + setUseReferer(true); } @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) - throws IOException { - String baseUri = determineTargetUrl(request, response); - + throws IOException, ServletException { CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); // 토큰 생성 후 쿠키에 저장 - AccessTokenDto accessTokenDto = - jwtService.createAccessToken(oAuth2User.getMemberId(), oAuth2User.getMemberRole()); - RefreshTokenDto refreshTokenDto = jwtService.createRefreshToken(oAuth2User.getMemberId()); + MemberAuthInfo memberAuthInfo = oAuth2User.getMemberAuthInfo(); + AccessTokenDto accessTokenDto = jwtService.createAccessToken(memberAuthInfo); + RefreshTokenDto refreshTokenDto = jwtService.createRefreshToken(memberAuthInfo.memberId()); cookieUtil.addTokenCookies(response, accessTokenDto.tokenValue(), refreshTokenDto.tokenValue()); - // 임시로 헤더에 엑세스 토큰 추가 - response.addHeader(ACCESS_TOKEN_HEADER_NAME, ACCESS_TOKEN_HEADER_PREFIX + accessTokenDto.tokenValue()); - - String redirectUri = UriComponentsBuilder.fromHttpUrl(baseUri) - .path(OAUTH_REDIRECT_PATH_SEGMENT) - .queryParam(LANDING_STATUS_PARAM, oAuth2User.getLandingStatus().name()) - .queryParam(ACCESS_TOKEN_PARAM, accessTokenDto.tokenValue()) - .queryParam(REFRESH_TOKEN_PARAM, refreshTokenDto.tokenValue()) - .toUriString(); - - getRedirectStrategy().sendRedirect(request, response, redirectUri); + super.onAuthenticationSuccess(request, response, authentication); } @Override protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) { - Cookie baseUriCookie = cookieUtil - .findCookie(request, OAUTH_BASE_URI_COOKIE_NAME) - .orElseThrow(() -> new CustomException(BASE_URI_COOKIE_NOT_FOUND)); - - String baseUri = baseUriCookie.getValue(); - validateBaseUri(baseUri); - - cookieUtil.deleteCookie(response, baseUriCookie); - - return baseUri; - } - - private void validateBaseUri(String baseUri) { - if (!baseUri.endsWith(ROOT_DOMAIN) && !LOCAL_CLIENT_URLS.contains(baseUri)) { - log.error("허용되지 않은 BASE URI로의 리다이렉트 요청 발생: {}", baseUri); - throw new CustomException(NOT_ALLOWED_BASE_URI); - } + String baseUrl = super.determineTargetUrl(request, response); + return UriComponentsBuilder.fromHttpUrl(baseUrl) + .path(OAUTH_REDIRECT_PATH_SEGMENT) + .build() + .toUriString(); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java index 2d8c664de..dab5c451a 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java @@ -5,7 +5,6 @@ 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.member.domain.MemberRole; import com.gdschongik.gdsc.global.common.constant.JwtConstant; import com.gdschongik.gdsc.global.util.CookieUtil; import jakarta.servlet.FilterChain; @@ -42,7 +41,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (accessTokenHeaderValue != null) { AccessTokenDto accessTokenDto = jwtService.retrieveAccessToken(accessTokenHeaderValue); if (accessTokenDto != null) { - setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + setAuthenticationToContext(PrincipalDetails.from(accessTokenDto)); filterChain.doFilter(request, response); return; } @@ -58,7 +57,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // AT가 유효하면 통과 if (accessTokenDto != null) { - setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + UserDetails userDetails = PrincipalDetails.from(accessTokenDto); + setAuthenticationToContext(userDetails); filterChain.doFilter(request, response); return; } @@ -72,7 +72,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse AccessTokenDto accessToken = reissueAccessToken.get(); RefreshTokenDto refreshToken = jwtService.createRefreshToken(refreshTokenDto.memberId()); cookieUtil.addTokenCookies(response, accessToken.tokenValue(), refreshToken.tokenValue()); - setAuthenticationToContext(accessToken.memberId(), accessToken.memberRole()); + UserDetails userDetails = PrincipalDetails.from(accessToken); + setAuthenticationToContext(userDetails); } // AT, RT 둘 다 만료되었으면 실패 @@ -92,8 +93,7 @@ private String extractAccessTokenFromHeader(HttpServletRequest request) { .orElse(null); } - private void setAuthenticationToContext(Long memberId, MemberRole memberRole) { - UserDetails userDetails = new PrincipalDetails(memberId, memberRole); + private void setAuthenticationToContext(UserDetails userDetails) { Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java b/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java deleted file mode 100644 index 01aa94fb5..000000000 --- a/src/main/java/com/gdschongik/gdsc/global/security/LandingStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.gdschongik.gdsc.global.security; - -public enum LandingStatus { - TO_DASHBOARD; -} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/MemberAuthInfo.java b/src/main/java/com/gdschongik/gdsc/global/security/MemberAuthInfo.java new file mode 100644 index 000000000..63e18ecfb --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/MemberAuthInfo.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.global.security; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberManageRole; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; + +/** + * 엑세스 토큰 및 시큐리티 내부 로직에서 사용되는 회원 정보 DTO입니다. + */ +public record MemberAuthInfo(Long memberId, MemberRole role, MemberManageRole manageRole, MemberStudyRole studyRole) { + public static MemberAuthInfo from(Member member) { + return new MemberAuthInfo(member.getId(), member.getRole(), member.getManageRole(), member.getStudyRole()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java b/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java index 80427cbb5..38e0649ce 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java @@ -1,22 +1,38 @@ package com.gdschongik.gdsc.global.security; +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberManageRole; import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; +import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -@AllArgsConstructor +@RequiredArgsConstructor public class PrincipalDetails implements UserDetails { private final Long memberId; - private final MemberRole memberRole; + private final MemberRole role; + private final MemberManageRole manageRole; + private final MemberStudyRole studyRole; + + public static PrincipalDetails from(AccessTokenDto token) { + MemberAuthInfo authInfo = token.authInfo(); + return new PrincipalDetails(authInfo.memberId(), authInfo.role(), authInfo.manageRole(), authInfo.studyRole()); + } @Override public Collection getAuthorities() { - return Collections.singleton(new SimpleGrantedAuthority(memberRole.getValue())); + Collection authorities = new ArrayList<>(); + + authorities.add(new SimpleGrantedAuthority(role.getValue())); + authorities.add(new SimpleGrantedAuthority(manageRole.getValue())); + authorities.add(new SimpleGrantedAuthority(studyRole.getValue())); + + return authorities; } @Override diff --git a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java index b8722d1b9..2c6d5fb0f 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java @@ -1,59 +1,38 @@ package com.gdschongik.gdsc.global.util; +import static com.gdschongik.gdsc.global.common.constant.UrlConstant.*; + import com.gdschongik.gdsc.global.common.constant.JwtConstant; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.util.Arrays; -import java.util.Optional; -import lombok.RequiredArgsConstructor; import org.springframework.boot.web.server.Cookie; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor public class CookieUtil { - private final EnvironmentUtil environmentUtil; - public void addTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { - - String sameSite = determineSameSitePolicy(); - - ResponseCookie accessTokenCookie = - generateTokenCookie(JwtConstant.ACCESS_TOKEN.getCookieName(), accessToken, sameSite); + ResponseCookie accessTokenCookie = generateTokenCookie(JwtConstant.ACCESS_TOKEN.getCookieName(), accessToken); ResponseCookie refreshTokenCookie = - generateTokenCookie(JwtConstant.REFRESH_TOKEN.getCookieName(), refreshToken, sameSite); + generateTokenCookie(JwtConstant.REFRESH_TOKEN.getCookieName(), refreshToken); response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); } - private ResponseCookie generateTokenCookie(String cookieName, String tokenValue, String sameSite) { + private ResponseCookie generateTokenCookie(String cookieName, String tokenValue) { return ResponseCookie.from(cookieName, tokenValue) .path("/") .secure(true) - .sameSite(sameSite) + .sameSite(Cookie.SameSite.LAX.attributeValue()) + .domain(ROOT_DOMAIN) .httpOnly(true) .build(); } - private String determineSameSitePolicy() { - if (environmentUtil.isProdProfile()) { - return Cookie.SameSite.LAX.attributeValue(); - } - return Cookie.SameSite.NONE.attributeValue(); - } - - public Optional findCookie(HttpServletRequest request, String cookieName) { - return Arrays.stream(request.getCookies()) - .filter(cookie -> cookie.getName().equals(cookieName)) - .findFirst(); - } - - public void deleteCookie(HttpServletResponse response, jakarta.servlet.http.Cookie cookie) { + public void deleteCookie(jakarta.servlet.http.Cookie cookie, HttpServletResponse response) { cookie.setPath("/"); cookie.setValue(""); cookie.setMaxAge(0); 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 7b1f608f5..a8099f6b4 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java @@ -1,18 +1,20 @@ package com.gdschongik.gdsc.global.util; -import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.TOKEN_ROLE_NAME; +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberManageRole; import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; 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 com.gdschongik.gdsc.global.security.MemberAuthInfo; 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; @@ -33,14 +35,27 @@ public class JwtUtil { private final JwtProperty jwtProperty; - public AccessTokenDto generateAccessToken(Long memberId, MemberRole memberRole) { + public AccessTokenDto generateAccessToken(MemberAuthInfo authInfo) { Date issuedAt = new Date(); Date expiredAt = new Date(issuedAt.getTime() + jwtProperty.getToken().get(JwtConstant.ACCESS_TOKEN).expirationMilliTime()); Key key = getKey(JwtConstant.ACCESS_TOKEN); - String tokenValue = buildToken(memberId, memberRole, issuedAt, expiredAt, key); - return new AccessTokenDto(memberId, memberRole, tokenValue); + String tokenValue = buildAccessToken(authInfo, issuedAt, expiredAt, key); + return new AccessTokenDto(authInfo, tokenValue); + } + + private String buildAccessToken(MemberAuthInfo authInfo, Date issuedAt, Date expiredAt, Key key) { + return Jwts.builder() + .setIssuer(jwtProperty.getIssuer()) + .setSubject(authInfo.memberId().toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .claim(TOKEN_ROLE_NAME, authInfo.role().name()) + .claim(TOKEN_MANAGE_ROLE_NAME, authInfo.manageRole().name()) + .claim(TOKEN_STUDY_ROLE_NAME, authInfo.studyRole().name()) + .signWith(key) + .compact(); } public RefreshTokenDto generateRefreshToken(Long memberId) { @@ -49,24 +64,18 @@ public RefreshTokenDto generateRefreshToken(Long memberId) { Date expiredAt = new Date(issuedAt.getTime() + refreshTokenProperty.expirationMilliTime()); Key key = getKey(JwtConstant.REFRESH_TOKEN); - String tokenValue = buildToken(memberId, null, issuedAt, expiredAt, key); + String tokenValue = buildRefreshToken(memberId, issuedAt, expiredAt, key); return new RefreshTokenDto(memberId, tokenValue, refreshTokenProperty.expirationTime()); } - private String buildToken(Long memberId, MemberRole memberRole, Date issuedAt, Date expiredAt, Key key) { - - JwtBuilder jwtBuilder = Jwts.builder() + private String buildRefreshToken(Long memberId, Date issuedAt, Date expiredAt, Key key) { + return Jwts.builder() .setIssuer(jwtProperty.getIssuer()) .setSubject(memberId.toString()) .setIssuedAt(issuedAt) .setExpiration(expiredAt) - .signWith(key); - - if (memberRole != null) { - jwtBuilder.claim(TOKEN_ROLE_NAME, memberRole.name()); - } - - return jwtBuilder.compact(); + .signWith(key) + .compact(); } private Key getKey(JwtConstant jwtConstant) { @@ -78,10 +87,13 @@ public AccessTokenDto parseAccessToken(String accessTokenValue) throws ExpiredJw try { Jws claims = getClaims(JwtConstant.ACCESS_TOKEN, accessTokenValue); - return new AccessTokenDto( + MemberAuthInfo parsedAuthInfo = new MemberAuthInfo( Long.parseLong(claims.getBody().getSubject()), MemberRole.valueOf(claims.getBody().get(TOKEN_ROLE_NAME, String.class)), - accessTokenValue); + MemberManageRole.valueOf(claims.getBody().get(TOKEN_MANAGE_ROLE_NAME, String.class)), + MemberStudyRole.valueOf(claims.getBody().get(TOKEN_STUDY_ROLE_NAME, String.class))); + + return new AccessTokenDto(parsedAuthInfo, accessTokenValue); } catch (ExpiredJwtException e) { throw e; } catch (Exception e) { diff --git a/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java b/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java new file mode 100644 index 000000000..e3235dfb7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.infra.client.github; + +import lombok.RequiredArgsConstructor; +import org.kohsuke.github.GitHub; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GithubClient { + + private final GitHub github; +} diff --git a/src/main/resources/application-github.yml b/src/main/resources/application-github.yml new file mode 100644 index 000000000..131a13821 --- /dev/null +++ b/src/main/resources/application-github.yml @@ -0,0 +1,2 @@ +github: + secret-key: ${GITHUB_SECRET_KEY:} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 899546814..1c4604ac1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,7 @@ spring: - discord - email - payment + - github logging: level: diff --git a/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java index f4e1eb55d..8e827c08c 100644 --- a/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java +++ b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java @@ -1,20 +1,12 @@ package com.gdschongik.gdsc.config; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.utility.DockerImageName; +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; -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))); - } -} +@TestConfiguration +@EnableConfigurationProperties({RedisProperty.class}) +@Import({RedisConfig.class}) +public class TestRedisConfig {} 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 index 8eb42e455..f08f177ce 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationServiceTest.java @@ -4,7 +4,6 @@ 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; @@ -13,11 +12,9 @@ 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 @@ -38,7 +35,7 @@ class 재학생_메일_인증시 { @Test void 레디스에_이메일인증정보가_존재하지_않으면_실패한다() { // given - Member member = Member.createGuestMember(OAUTH_ID); + Member member = createGuestMember(); memberRepository.save(member); String verificationToken = emailVerificationTokenUtil.generateEmailVerificationToken(member.getId(), UNIV_EMAIL); @@ -53,8 +50,7 @@ class 재학생_메일_인증시 { @Test void 인증토큰과_레디스에_존재하는_인증정보의_토큰이_다르면_실패한다() { // given - // TODO: 아래 두줄 createGuestMember로 대체하기 - Member member = memberRepository.save(Member.createGuestMember(OAUTH_ID)); + Member member = createGuestMember(); logoutAndReloginAs(member.getId(), member.getRole()); // when 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 index a1af8c6b6..4438d08e3 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/member/application/OnboardingMemberServiceTest.java @@ -11,6 +11,7 @@ import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; import com.gdschongik.gdsc.helper.IntegrationTest; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -31,7 +32,8 @@ class 대시보드_조회할때 { @BeforeEach void setUp() { RecruitmentRound recruitmentRound = createRecruitmentRound(); - when(onboardingRecruitmentService.findCurrentRecruitmentRound()).thenReturn(recruitmentRound); + when(onboardingRecruitmentService.findCurrentRecruitmentRound()) + .thenReturn(Optional.ofNullable(recruitmentRound)); } @Test 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 7fda0bdbe..5904d96df 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 @@ -50,10 +50,10 @@ class 게스트_회원가입시 { 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); + assertThat(requirement.getUnivStatus()).isEqualTo(UNSATISFIED); + assertThat(requirement.getDiscordStatus()).isEqualTo(UNSATISFIED); + assertThat(requirement.getBevyStatus()).isEqualTo(UNSATISFIED); + assertThat(requirement.getInfoStatus()).isEqualTo(UNSATISFIED); } } @@ -370,7 +370,7 @@ class 비회원으로_강등시 { AssociateRequirement::getInfoStatus, AssociateRequirement::getBevyStatus, AssociateRequirement::getUnivStatus) - .containsExactly(PENDING, PENDING, PENDING, PENDING); + .containsExactly(UNSATISFIED, UNSATISFIED, UNSATISFIED, UNSATISFIED); } } } 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 index a3176a564..5fc713ee8 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/order/application/OrderServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/order/application/OrderServiceTest.java @@ -1,5 +1,6 @@ package com.gdschongik.gdsc.domain.order.application; +import static com.gdschongik.gdsc.global.common.constant.OrderConstant.*; import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import static org.assertj.core.api.Assertions.*; @@ -29,6 +30,7 @@ import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -49,6 +51,18 @@ class OrderServiceTest extends IntegrationTest { @Autowired private OrderRepository orderRepository; + @Override + protected void doStubTemplate() { + stubPaymentConfirm(); + } + + private void stubPaymentConfirm() { + ZonedDateTime approvedAt = ZonedDateTime.now(); + PaymentResponse mockPaymentResponse = mock(PaymentResponse.class); + when(mockPaymentResponse.approvedAt()).thenReturn(approvedAt); + when(paymentClient.confirm(any(PaymentConfirmRequest.class))).thenReturn(mockPaymentResponse); + } + @Nested class 임시주문_생성할때 { @@ -72,7 +86,7 @@ class 임시주문_생성할때 { // when var request = new OrderCreateRequest( - "HnbMWoSZRq3qK1W3tPXCW", + ORDER_NANO_ID, membership.getId(), issuedCoupon.getId(), BigDecimal.valueOf(20000), @@ -105,30 +119,22 @@ class 주문_완료할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, 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); + var request = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); orderService.completeOrder(request); // then - Order completedOrder = orderRepository.findByNanoId(orderNanoId).orElseThrow(); + Order completedOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); assertThat(completedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); - assertThat(completedOrder.getPaymentKey()).isEqualTo(paymentKey); + assertThat(completedOrder.getPaymentKey()).isEqualTo(ORDER_PAYMENT_KEY); IssuedCoupon usedCoupon = issuedCouponRepository.findById(issuedCoupon.getId()).orElseThrow(); @@ -154,24 +160,16 @@ class 주문_완료할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, 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); + var request = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); orderService.completeOrder(request); // then @@ -198,24 +196,16 @@ class 주문_완료할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, 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); + var request = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); orderService.completeOrder(request); // then @@ -227,6 +217,22 @@ class 주문_완료할때 { @Nested class 주문_취소할때 { + @BeforeEach + void setUp() { + stubPaymentCancel(); + } + + private void stubPaymentCancel() { + 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(ORDER_PAYMENT_KEY), any(PaymentCancelRequest.class))) + .thenReturn(mockCancelResponse); + } + @Test void 성공한다() { // given @@ -244,38 +250,21 @@ class 주문_취소할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, 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); + var completeRequest = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 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); + Order completedOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); // when - var cancelRequest = new OrderCancelRequest("테스트 취소 사유"); + var cancelRequest = new OrderCancelRequest(ORDER_CANCEL_REASON); orderService.cancelOrder(completedOrder.getId(), cancelRequest); // then @@ -284,7 +273,7 @@ class 주문_취소할때 { assertThat(canceledOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); assertThat(canceledOrder.getCanceledAt()).isNotNull(); - verify(paymentClient).cancelPayment(eq(paymentKey), any(PaymentCancelRequest.class)); + verify(paymentClient).cancelPayment(eq(ORDER_PAYMENT_KEY), any(PaymentCancelRequest.class)); } @Test @@ -303,19 +292,18 @@ class 주문_취소할때 { Membership membership = createMembership(member, recruitmentRound); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, membership.getId(), null, BigDecimal.valueOf(20000), BigDecimal.valueOf(0), BigDecimal.valueOf(20000))); - Order pendingOrder = orderRepository.findByNanoId(orderNanoId).orElseThrow(); + Order pendingOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); Long id = pendingOrder.getId(); - OrderCancelRequest request = new OrderCancelRequest("테스트 취소 사유"); + OrderCancelRequest request = new OrderCancelRequest(ORDER_CANCEL_REASON); // when & then assertThatThrownBy(() -> orderService.cancelOrder(id, request)) @@ -324,6 +312,136 @@ class 주문_취소할때 { verify(paymentClient, never()).cancelPayment(any(), any()); } + + @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); + + orderService.createPendingOrder(new OrderCreateRequest( + ORDER_NANO_ID, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + var completeRequest = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); + orderService.completeOrder(completeRequest); + + Order completedOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); + + var cancelRequest = new OrderCancelRequest(ORDER_CANCEL_REASON); + + // when + orderService.cancelOrder(completedOrder.getId(), cancelRequest); + + // then + Membership verifiedMembership = + membershipRepository.findById(membership.getId()).orElseThrow(); + assertThat(verifiedMembership.getRegularRequirement().isPaymentSatisfied()) + .isFalse(); + } + + @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); + + orderService.createPendingOrder(new OrderCreateRequest( + ORDER_NANO_ID, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + var completeRequest = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); + orderService.completeOrder(completeRequest); + + Order completedOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); + Member orderCompletedMember = + memberRepository.findById(member.getId()).orElseThrow(); + + var cancelRequest = new OrderCancelRequest(ORDER_CANCEL_REASON); + + // when + orderService.cancelOrder(completedOrder.getId(), cancelRequest); + + // then + Member orderCanceledMember = + memberRepository.findById(member.getId()).orElseThrow(); + + assertThat(orderCompletedMember.isRegular()).isTrue(); + assertThat(orderCanceledMember.isAssociate()).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); + + orderService.createPendingOrder(new OrderCreateRequest( + ORDER_NANO_ID, + membership.getId(), + issuedCoupon.getId(), + BigDecimal.valueOf(20000), + BigDecimal.valueOf(5000), + BigDecimal.valueOf(15000))); + + var completeRequest = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); + orderService.completeOrder(completeRequest); + + Order completedOrder = orderRepository.findByNanoId(ORDER_NANO_ID).orElseThrow(); + + // when + var cancelRequest = new OrderCancelRequest(ORDER_CANCEL_REASON); + orderService.cancelOrder(completedOrder.getId(), cancelRequest); + + // then + Order canceledOrder = + orderRepository.findById(completedOrder.getId()).orElseThrow(); + assertThat(canceledOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); + assertThat(canceledOrder.getCanceledAt()).isNotNull(); + + verify(memberDiscordRoleRevokeHandler).delegate(any()); + } } @Disabled // TODO: CI 환경에서만 실패하는 테스트, TZ 관련 설정 확인 필요 @@ -347,22 +465,15 @@ class 일자기준으로_주문목록_조회시 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_5000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; orderService.createPendingOrder(new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, 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); + var request = new OrderCompleteRequest(ORDER_PAYMENT_KEY, ORDER_NANO_ID, 15000L); orderService.completeOrder(request); LocalDate date = LocalDate.now(); @@ -373,7 +484,7 @@ class 일자기준으로_주문목록_조회시 { // then boolean orderExists = orderResponse.getContent().stream() - .anyMatch(order -> order.nanoId().equals(orderNanoId)); + .anyMatch(order -> order.nanoId().equals(ORDER_NANO_ID)); assertThat(orderExists).isTrue(); } @@ -399,10 +510,8 @@ class 무료주문_생성할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_20000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; - var request = new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, membership.getId(), issuedCoupon.getId(), BigDecimal.valueOf(20000), @@ -436,10 +545,8 @@ class 무료주문_생성할때 { Membership membership = createMembership(member, recruitmentRound); IssuedCoupon issuedCoupon = createAndIssue(MONEY_20000_WON, member); - String orderNanoId = "HnbMWoSZRq3qK1W3tPXCW"; - var request = new OrderCreateRequest( - orderNanoId, + ORDER_NANO_ID, membership.getId(), issuedCoupon.getId(), BigDecimal.valueOf(20000), 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 index 4f3e2172b..ca1ee1fbc 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/recruitment/application/AdminRecruitmentServiceTest.java @@ -11,7 +11,8 @@ 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.domain.recruitment.dto.request.RecruitmentRoundCreateRequest; +import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentRoundUpdateRequest; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.helper.IntegrationTest; import java.time.LocalDateTime; @@ -53,7 +54,7 @@ class 모집회차_생성시 { @Test void 학년도와_학기가_일치하는_리쿠르팅이_존재하지_않는다면_실패한다() { // given - RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + RecruitmentRoundCreateRequest request = new RecruitmentRoundCreateRequest( ACADEMIC_YEAR, SEMESTER_TYPE, RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, ROUND_TYPE); // when & then @@ -77,7 +78,7 @@ class 모집회차_수정시 { RECRUITMENT_ROUND_NAME, now.plusDays(1), now.plusDays(2), recruitment, ROUND_TYPE); recruitmentRoundRepository.save(recruitmentRound); - RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + RecruitmentRoundUpdateRequest request = new RecruitmentRoundUpdateRequest( ACADEMIC_YEAR, SEMESTER_TYPE, "수정된 모집회차 이름", now.plusDays(2), now.plusDays(3), ROUND_TYPE); // when @@ -95,7 +96,7 @@ class 모집회차_수정시 { @Test void 모집회차가_존재하지_않는다면_실패한다() { // given - RecruitmentRoundCreateUpdateRequest request = new RecruitmentRoundCreateUpdateRequest( + RecruitmentRoundUpdateRequest request = new RecruitmentRoundUpdateRequest( ACADEMIC_YEAR, SEMESTER_TYPE, RECRUITMENT_ROUND_NAME, START_DATE, END_DATE, ROUND_TYPE); // when & then diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailServiceTest.java new file mode 100644 index 000000000..f428d04c7 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailServiceTest.java @@ -0,0 +1,50 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +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; + +public class MentorStudyDetailServiceTest extends IntegrationTest { + + @Autowired + private MentorStudyDetailService mentorStudyDetailService; + + @Autowired + private StudyDetailRepository studyDetailRepository; + + @Nested + class 스터디_과제_휴강_처리시 { + + @Test + void 성공한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = createAssociateMember(); + Study study = createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = createStudyDetail(study, now, now.plusDays(7)); + logoutAndReloginAs(studyDetail.getStudy().getMentor().getId(), MemberRole.ASSOCIATE); + + // when + mentorStudyDetailService.cancelStudyAssignment(studyDetail.getId()); + + // then + StudyDetail cancelledStudyDetail = + studyDetailRepository.findById(studyDetail.getId()).get(); + assertThat(cancelledStudyDetail.getAssignment().getStatus()).isEqualTo(StudyStatus.CANCELLED); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailTest.java new file mode 100644 index 000000000..b4bd8023c --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailTest.java @@ -0,0 +1,59 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +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.helper.FixtureHelper; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class StudyDetailTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + + @Nested + class 과제_휴강_처리시 { + @Test + void 과제_상태가_휴강이_된다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + LocalDateTime now = LocalDateTime.now(); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + + // when + studyDetail.cancelAssignment(); + + // then + assertThat(studyDetail.getAssignment().getStatus()).isEqualTo(StudyStatus.CANCELLED); + } + } + + @Nested + class 과제_개설시 { + + @Test + void 성공한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + LocalDateTime now = LocalDateTime.now(); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + + // when + studyDetail.publishAssignment(ASSIGNMENT_TITLE, now.plusDays(1), DESCRIPTION_LINK); + + // then + assertThat(studyDetail.getAssignment().getStatus()).isEqualTo(StudyStatus.OPEN); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java new file mode 100644 index 000000000..0c536814f --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java @@ -0,0 +1,160 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +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.domain.study.dto.request.AssignmentCreateUpdateRequest; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class StudyDetailValidatorTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + StudyDetailValidator studyDetailValidator = new StudyDetailValidator(); + + @Nested + class 과제_휴강_처리시 { + + @Test + void 멘토가_아니라면_실패한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = fixtureHelper.createAssociateMember(1L); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + Member anotherMember = fixtureHelper.createAssociateMember(2L); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validateCancelStudyAssignment(anotherMember, studyDetail)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR.getMessage()); + } + } + + @Nested + class 과제_개설시 { + + @Test + void 멘토가_아니라면_실패한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = fixtureHelper.createAssociateMember(1L); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + Member anotherMember = fixtureHelper.createAssociateMember(2L); + AssignmentCreateUpdateRequest request = + new AssignmentCreateUpdateRequest(ASSIGNMENT_TITLE, DESCRIPTION_LINK, now.plusDays(2)); + + // when & then + assertThatThrownBy(() -> + studyDetailValidator.validatePublishStudyAssignment(anotherMember, studyDetail, request)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR.getMessage()); + } + + @Test + void 마감_기한이_개설_시점_보다_앞서면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + LocalDateTime now = LocalDateTime.now(); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + AssignmentCreateUpdateRequest request = + new AssignmentCreateUpdateRequest(ASSIGNMENT_TITLE, DESCRIPTION_LINK, now.minusDays(2)); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validatePublishStudyAssignment(mentor, studyDetail, request)) + .isInstanceOf(CustomException.class) + .hasMessage(ASSIGNMENT_DEADLINE_INVALID.getMessage()); + } + } + + @Nested + class 과제_수정시 { + + @Test + void 멘토가_아니라면_실패한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = fixtureHelper.createAssociateMember(1L); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(10)); + Member anotherMember = fixtureHelper.createAssociateMember(2L); + studyDetail.publishAssignment(ASSIGNMENT_TITLE, now.plusDays(2), DESCRIPTION_LINK); + + AssignmentCreateUpdateRequest request = + new AssignmentCreateUpdateRequest(ASSIGNMENT_TITLE, DESCRIPTION_LINK, now.plusDays(3)); + + // when & then + assertThatThrownBy(() -> + studyDetailValidator.validateUpdateStudyAssignment(anotherMember, studyDetail, request)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR.getMessage()); + } + + @Test + void 기존마감기한이_수정시점보다_앞서면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + StudyDetail studyDetail = + fixtureHelper.createStudyDetail(study, assignmentCreatedDate, assignmentCreatedDate.plusDays(1)); + studyDetail.publishAssignment(ASSIGNMENT_TITLE, assignmentCreatedDate.plusDays(1), DESCRIPTION_LINK); + + LocalDateTime assignmentUpdateDate = assignmentCreatedDate.plusDays(3); + AssignmentCreateUpdateRequest request = + new AssignmentCreateUpdateRequest(ASSIGNMENT_TITLE, DESCRIPTION_LINK, assignmentUpdateDate); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validateUpdateStudyAssignment(mentor, studyDetail, request)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE.getMessage()); + } + + @Test + void 수정할_마감기한이_기존마감기한_보다_앞서면_실패한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = fixtureHelper.createAssociateMember(1L); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(10)); + LocalDateTime savedDeadLine = now.minusDays(1); + studyDetail.publishAssignment(ASSIGNMENT_TITLE, savedDeadLine, DESCRIPTION_LINK); + + LocalDateTime updatedDeadLine = savedDeadLine.minusDays(4); + AssignmentCreateUpdateRequest request = + new AssignmentCreateUpdateRequest(ASSIGNMENT_TITLE, DESCRIPTION_LINK, updatedDeadLine); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validateUpdateStudyAssignment(mentor, studyDetail, request)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/OrderConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/OrderConstant.java new file mode 100644 index 000000000..5addd7d9c --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/OrderConstant.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class OrderConstant { + + public static final String ORDER_NANO_ID = "HnbMWoSZRq3qK1W3tPXCW"; + public static final String ORDER_PAYMENT_KEY = "testPaymentKey"; + public static final String ORDER_CANCEL_REASON = "테스트 주문 취소 사유"; + + private OrderConstant() {} +} 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 index e263e49b8..b9839811b 100644 --- a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java @@ -13,4 +13,11 @@ private StudyConstant() {} 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); + + // StudyDetail + public static final String ATTENDANCE_NUMBER = "1234"; + + // Assignment + public static final String ASSIGNMENT_TITLE = "testTitle"; + public static final String DESCRIPTION_LINK = "www.link.com"; } diff --git a/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java index b0849699c..f6a4c1cdf 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java +++ b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java @@ -18,6 +18,7 @@ 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 com.gdschongik.gdsc.domain.study.domain.StudyDetail; import java.time.LocalDateTime; import org.springframework.test.util.ReflectionTestUtils; @@ -79,4 +80,8 @@ public Study createStudy(Member mentor, Period period, Period applicationPeriod) STUDY_START_TIME, STUDY_END_TIME); } + + public StudyDetail createStudyDetail(Study study, LocalDateTime startDate, LocalDateTime endDate) { + return StudyDetail.createStudyDetail(study, 1L, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); + } } diff --git a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java index 5a132ee69..3401a349a 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java +++ b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java @@ -4,6 +4,7 @@ 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 static org.mockito.Mockito.*; import com.gdschongik.gdsc.domain.common.model.SemesterType; @@ -13,9 +14,13 @@ 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.discord.application.handler.MemberDiscordRoleRevokeHandler; +import com.gdschongik.gdsc.domain.discord.application.handler.SpringEventHandler; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberManageRole; import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.member.domain.MemberStudyRole; import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; import com.gdschongik.gdsc.domain.membership.domain.Membership; import com.gdschongik.gdsc.domain.recruitment.application.OnboardingRecruitmentService; @@ -25,6 +30,10 @@ 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.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.global.security.PrincipalDetails; import com.gdschongik.gdsc.infra.feign.payment.client.PaymentClient; import java.time.LocalDateTime; @@ -44,6 +53,9 @@ public abstract class IntegrationTest { @Autowired protected DatabaseCleaner databaseCleaner; + @Autowired + protected RedisCleaner redisCleaner; + @Autowired protected MemberRepository memberRepository; @@ -62,6 +74,12 @@ public abstract class IntegrationTest { @Autowired protected RecruitmentRoundRepository recruitmentRoundRepository; + @Autowired + protected StudyRepository studyRepository; + + @Autowired + protected StudyDetailRepository studyDetailRepository; + @MockBean protected OnboardingRecruitmentService onboardingRecruitmentService; @@ -71,14 +89,41 @@ public abstract class IntegrationTest { @MockBean protected DelegateMemberDiscordEventHandler delegateMemberDiscordEventHandler; + @MockBean + protected MemberDiscordRoleRevokeHandler memberDiscordRoleRevokeHandler; + @BeforeEach void setUp() { databaseCleaner.execute(); + redisCleaner.execute(); + doStubDiscordEventHandler(); + doStubTemplate(); + } + + /** + * stubbing에 사용할 템플릿 메서드입니다. + * 하위 클래스에서 이 메서드를 오버라이드하여 stubbing을 수행합니다. + * 오버라이드된 경우, `@BeforeEach`의 맨 마지막에 호출됩니다. + */ + protected void doStubTemplate() { + // 기본적으로 아무 것도 하지 않습니다. 필요한 경우에만 오버라이드하여 사용합니다. + } + + /** + * {@link SpringEventHandler#delegate} 메서드를 stubbing합니다. + * 해당 핸들러 메서드는 스프링 이벤트를 수신하여 JDA를 통해 디스코드 관련 로직을 처리합니다. + * JDA는 외부 API의 커넥션을 필요로 하기 때문에 테스트에서는 `@MockBean`으로 주입한 핸들러를 stubbing하여 verify로 호출 여부만 확인합니다. + * 기본적으로 아무 것도 하지 않도록 설정합니다. + */ + private void doStubDiscordEventHandler() { doNothing().when(delegateMemberDiscordEventHandler).delegate(any()); + doNothing().when(memberDiscordRoleRevokeHandler).delegate(any()); } protected void logoutAndReloginAs(Long memberId, MemberRole memberRole) { - PrincipalDetails principalDetails = new PrincipalDetails(memberId, memberRole); + // TODO: MemberManageRole, MemberStudyRole 추가 + PrincipalDetails principalDetails = + new PrincipalDetails(memberId, memberRole, MemberManageRole.NONE, MemberStudyRole.STUDENT); Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); @@ -160,4 +205,26 @@ protected IssuedCoupon createAndIssue(Money money, Member member) { IssuedCoupon issuedCoupon = IssuedCoupon.issue(coupon, member); return issuedCouponRepository.save(issuedCoupon); } + + protected Study createStudy(Member mentor, Period period, Period applicationPeriod) { + Study study = Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + mentor, + period, + applicationPeriod, + TOTAL_WEEK, + ONLINE_STUDY, + DAY_OF_WEEK, + STUDY_START_TIME, + STUDY_END_TIME); + + return studyRepository.save(study); + } + + protected StudyDetail createStudyDetail(Study study, LocalDateTime startDate, LocalDateTime endDate) { + StudyDetail studyDetail = + StudyDetail.createStudyDetail(study, 1L, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); + return studyDetailRepository.save(studyDetail); + } } diff --git a/src/test/java/com/gdschongik/gdsc/helper/RedisCleaner.java b/src/test/java/com/gdschongik/gdsc/helper/RedisCleaner.java new file mode 100644 index 000000000..813d1c7d7 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/helper/RedisCleaner.java @@ -0,0 +1,27 @@ +package com.gdschongik.gdsc.helper; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class RedisCleaner implements InitializingBean { + private RedisClient redisClient; + private StatefulRedisConnection connection; + private RedisCommands commands; + + @Override + public void afterPropertiesSet() { + RedisURI redisUri = RedisURI.Builder.redis("localhost", 6379).build(); + redisClient = RedisClient.create(redisUri); + connection = redisClient.connect(); + commands = connection.sync(); + } + + public void execute() { + commands.flushdb(); + } +} diff --git a/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java b/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java index 3b00b1e35..978874ab2 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java +++ b/src/test/java/com/gdschongik/gdsc/helper/RepositoryTest.java @@ -1,7 +1,6 @@ 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; @@ -11,7 +10,7 @@ import org.springframework.test.context.ActiveProfiles; @DataJpaTest -@Import({TestQuerydslConfig.class, TestRedisConfig.class, DatabaseCleaner.class}) +@Import({TestQuerydslConfig.class, DatabaseCleaner.class, RedisCleaner.class}) @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) public abstract class RepositoryTest { @@ -22,8 +21,12 @@ public abstract class RepositoryTest { @Autowired protected TestEntityManager testEntityManager; + @Autowired + protected RedisCleaner redisCleaner; + @BeforeEach void setUp() { databaseCleaner.execute(); + redisCleaner.execute(); } }