diff --git a/src/main/java/com/nexters/goalpanzi/application/member/MemberService.java b/src/main/java/com/nexters/goalpanzi/application/member/MemberService.java index eaec6595..5f74e393 100644 --- a/src/main/java/com/nexters/goalpanzi/application/member/MemberService.java +++ b/src/main/java/com/nexters/goalpanzi/application/member/MemberService.java @@ -1,12 +1,14 @@ package com.nexters.goalpanzi.application.member; import com.nexters.goalpanzi.application.member.dto.request.UpdateProfileCommand; +import com.nexters.goalpanzi.application.mission.handler.DeleteMemberEvent; import com.nexters.goalpanzi.domain.member.Member; import com.nexters.goalpanzi.domain.member.repository.MemberRepository; import com.nexters.goalpanzi.exception.AlreadyExistsException; import com.nexters.goalpanzi.exception.ErrorCode; import com.nexters.goalpanzi.exception.NotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +17,7 @@ public class MemberService { private final MemberRepository memberRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public void updateProfile(final UpdateProfileCommand request) { @@ -35,4 +38,10 @@ private void validateNickname(final String nickname) { throw new AlreadyExistsException(ErrorCode.ALREADY_EXIST_NICKNAME, nickname); }); } + + @Transactional + public void deleteMember(final Long memberId) { + eventPublisher.publishEvent(new DeleteMemberEvent(memberId)); + memberRepository.getMember(memberId).delete(); + } } diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/dto/response/MissionDetailResponse.java b/src/main/java/com/nexters/goalpanzi/application/mission/dto/response/MissionDetailResponse.java index fe025535..96c00cf9 100644 --- a/src/main/java/com/nexters/goalpanzi/application/mission/dto/response/MissionDetailResponse.java +++ b/src/main/java/com/nexters/goalpanzi/application/mission/dto/response/MissionDetailResponse.java @@ -3,19 +3,29 @@ import com.nexters.goalpanzi.domain.mission.DayOfWeek; import com.nexters.goalpanzi.domain.mission.Mission; import com.nexters.goalpanzi.domain.mission.TimeOfDay; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import java.util.List; public record MissionDetailResponse( + @Schema(description = "미션 ID", requiredMode = Schema.RequiredMode.REQUIRED) Long missionId, + @Schema(description = "회원(방장) ID", requiredMode = Schema.RequiredMode.REQUIRED) Long hostMemberId, + @Schema(description = "목표 행동", requiredMode = Schema.RequiredMode.REQUIRED) String description, + @Schema(description = "미션 시작 날짜", requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime missionStartDate, + @Schema(description = "미션 종료 날짜", requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime missionEndDate, + @Schema(description = "인증 업로드 시간", requiredMode = Schema.RequiredMode.REQUIRED) TimeOfDay timeOfDay, + @Schema(description = "경쟁 빈도", requiredMode = Schema.RequiredMode.REQUIRED) List missionDays, + @Schema(description = "보드칸 개수", requiredMode = Schema.RequiredMode.REQUIRED) Integer boardCount, + @Schema(description = "초대 코드", requiredMode = Schema.RequiredMode.REQUIRED) String invitationCode ) { diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/handler/DeleteMemberEvent.java b/src/main/java/com/nexters/goalpanzi/application/mission/handler/DeleteMemberEvent.java new file mode 100644 index 00000000..9088b019 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/handler/DeleteMemberEvent.java @@ -0,0 +1,6 @@ +package com.nexters.goalpanzi.application.mission.handler; + +public record DeleteMemberEvent( + Long memberId +) { +} diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/handler/MissionMemberEventHandler.java b/src/main/java/com/nexters/goalpanzi/application/mission/handler/MissionMemberEventHandler.java new file mode 100644 index 00000000..575573ef --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/handler/MissionMemberEventHandler.java @@ -0,0 +1,25 @@ +package com.nexters.goalpanzi.application.mission.handler; + +import com.nexters.goalpanzi.domain.common.BaseEntity; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionVerificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MissionMemberEventHandler { + + private final MissionMemberRepository missionMemberRepository; + private final MissionVerificationRepository missionVerificationRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + void handelDeleteMemberEvent(final DeleteMemberEvent event) { + missionMemberRepository.findAllByMemberId(event.memberId()) + .forEach(BaseEntity::delete); + missionVerificationRepository.findByMemberId(event.memberId()) + .forEach(BaseEntity::delete); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/common/BaseEntity.java b/src/main/java/com/nexters/goalpanzi/domain/common/BaseEntity.java index 39bf9c3c..414a6848 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/common/BaseEntity.java +++ b/src/main/java/com/nexters/goalpanzi/domain/common/BaseEntity.java @@ -25,4 +25,8 @@ public class BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/nexters/goalpanzi/domain/member/Member.java b/src/main/java/com/nexters/goalpanzi/domain/member/Member.java index 663fbdfc..f3b1e551 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/member/Member.java +++ b/src/main/java/com/nexters/goalpanzi/domain/member/Member.java @@ -1,12 +1,21 @@ package com.nexters.goalpanzi.domain.member; import com.nexters.goalpanzi.domain.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; @Entity +@SQLRestriction("deleted_at is NULL") @Table(name = "member") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/Mission.java b/src/main/java/com/nexters/goalpanzi/domain/mission/Mission.java index 575401f4..f4eff057 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/Mission.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/Mission.java @@ -13,11 +13,13 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; import java.util.List; @Entity +@SQLRestriction("deleted_at is NULL") @Table(name = "mission") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java index 4bb8ffe5..37531db9 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java @@ -2,12 +2,22 @@ import com.nexters.goalpanzi.domain.common.BaseEntity; import com.nexters.goalpanzi.domain.member.Member; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; @Entity +@SQLRestriction("deleted_at is NULL") @Table(name = "mission_member") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter diff --git a/src/main/java/com/nexters/goalpanzi/presentation/member/MemberController.java b/src/main/java/com/nexters/goalpanzi/presentation/member/MemberController.java index b314ce6d..f77bcd01 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/member/MemberController.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/member/MemberController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -28,4 +29,14 @@ public ResponseEntity updateProfile( return ResponseEntity.ok().build(); } + + @Override + @DeleteMapping + public ResponseEntity deleteMember( + @LoginMemberId final Long memberId + ) { + memberService.deleteMember(memberId); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/nexters/goalpanzi/presentation/member/MemberControllerDocs.java b/src/main/java/com/nexters/goalpanzi/presentation/member/MemberControllerDocs.java index 14d6e390..625bf373 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/member/MemberControllerDocs.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/member/MemberControllerDocs.java @@ -8,16 +8,19 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "회원") public interface MemberControllerDocs { @Operation(summary = "프로필 생성", description = "캐릭터, 닉네임을 설정합니다.") - @PatchMapping("/profile") ResponseEntity updateProfile( @Parameter(in = ParameterIn.HEADER, hidden = true) @LoginMemberId final Long userId, @RequestBody @Valid final UpdateProfileRequest request ); + + @Operation(summary = "회원 탈퇴") + ResponseEntity deleteMember( + @Parameter(in = ParameterIn.HEADER, hidden = true) @LoginMemberId final Long memberId + ); } diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionMemberController.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionMemberController.java index 257624dc..5a05f3d0 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionMemberController.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionMemberController.java @@ -30,7 +30,7 @@ public ResponseEntity joinMission( @LoginMemberId final Long memberId, @RequestBody final JoinMissionRequest request ) { - missionMemberService.joinMission(memberId, request.missionCode()); + missionMemberService.joinMission(memberId, request.invitationCode()); return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionRequest.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionRequest.java index e507802c..b5db3c8e 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionRequest.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionRequest.java @@ -4,24 +4,30 @@ import com.nexters.goalpanzi.domain.mission.DayOfWeek; import com.nexters.goalpanzi.domain.mission.TimeOfDay; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; import java.util.List; public record CreateMissionRequest( + @NotNull @Schema(description = "목표 행동", requiredMode = Schema.RequiredMode.REQUIRED) String description, + @NotNull @Schema(description = "미션 시작 시간", requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime missionStartDate, + @NotNull @Schema(description = "미션 종료 시간", requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime missionEndDate, + @NotNull @Schema(description = "인증 업로드 시간", requiredMode = Schema.RequiredMode.REQUIRED) TimeOfDay timeOfDay, + @NotEmpty @Schema(description = "경쟁 빈도", requiredMode = Schema.RequiredMode.REQUIRED) List missionDays, diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/JoinMissionRequest.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/JoinMissionRequest.java index 500c6a88..30f2cd44 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/JoinMissionRequest.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/JoinMissionRequest.java @@ -1,6 +1,6 @@ package com.nexters.goalpanzi.presentation.mission.dto; public record JoinMissionRequest( - String missionCode + String invitationCode ) { } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f051ce83..2a94f523 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -7,6 +7,7 @@ spring: database-platform: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: validate + show-sql: true data: redis: host: localhost diff --git a/src/test/java/com/nexters/goalpanzi/acceptance/MemberAcceptanceTest.java b/src/test/java/com/nexters/goalpanzi/acceptance/MemberAcceptanceTest.java new file mode 100644 index 00000000..79127d49 --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/acceptance/MemberAcceptanceTest.java @@ -0,0 +1,42 @@ +package com.nexters.goalpanzi.acceptance; + +import com.nexters.goalpanzi.application.auth.dto.request.GoogleLoginCommand; +import com.nexters.goalpanzi.application.auth.dto.response.LoginResponse; +import com.nexters.goalpanzi.application.mission.dto.response.MissionDetailResponse; +import com.nexters.goalpanzi.common.jwt.JwtProvider; +import com.nexters.goalpanzi.domain.member.repository.MemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import static com.nexters.goalpanzi.acceptance.AcceptanceStep.*; +import static com.nexters.goalpanzi.fixture.MemberFixture.EMAIL; +import static com.nexters.goalpanzi.fixture.MemberFixture.ID_TOKEN; +import static com.nexters.goalpanzi.fixture.TokenFixture.BEARER; +import static org.assertj.core.api.Assertions.assertThat; + +public class MemberAcceptanceTest extends AcceptanceTest { + + @Autowired + private MemberRepository memberRepository; + + @Test + void 회원이_탈퇴한다() { + LoginResponse login = 구글_로그인(new GoogleLoginCommand(ID_TOKEN, EMAIL)).as(LoginResponse.class); + MissionDetailResponse mission = 미션_생성(login.accessToken()).as(MissionDetailResponse.class); + 미션_참여(mission.invitationCode(), login.accessToken()); + + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, BEARER + login.accessToken()) + .when().delete("/api/member") + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + + assertThat(memberRepository.findAll()).isEmpty(); + } +} diff --git a/src/test/java/com/nexters/goalpanzi/domain/mission/MissionTest.java b/src/test/java/com/nexters/goalpanzi/domain/mission/MissionTest.java index 5015bd17..20822f76 100644 --- a/src/test/java/com/nexters/goalpanzi/domain/mission/MissionTest.java +++ b/src/test/java/com/nexters/goalpanzi/domain/mission/MissionTest.java @@ -23,7 +23,8 @@ class MissionTest { LocalDateTime.now().plusDays(7), TimeOfDay.EVERYDAY, List.of(DayOfWeek.FRIDAY), - BOARD_COUNT + BOARD_COUNT, + InvitationCode.generate() ); assertAll( @@ -42,7 +43,8 @@ class MissionTest { LocalDateTime.now().plusDays(7), TimeOfDay.EVERYDAY, List.of(DayOfWeek.FRIDAY), - 0 + 0, + InvitationCode.generate() )); } @@ -55,7 +57,8 @@ class MissionTest { LocalDateTime.now().minusDays(7), TimeOfDay.EVERYDAY, List.of(DayOfWeek.FRIDAY), - BOARD_COUNT + BOARD_COUNT, + InvitationCode.generate() )); } } \ No newline at end of file