Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ jobs:
java-version: '21'
distribution: 'temurin'

- name: Generate application-dev.properties
run: |
mkdir -p src/main/resources
cat <<EOF > src/main/resources/application-dev.properties
MYSQL_HOST=${{ secrets.MYSQL_HOST }}
MYSQL_PORT=${{ secrets.MYSQL_PORT }}
MYSQL_USER=${{ secrets.MYSQL_USER }}
MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}
MYSQL_DATABASE=${{ secrets.MYSQL_DATABASE }}
REDIS_HOST=${{ secrets.REDIS_HOST }}
REDIS_PORT=${{ secrets.REDIS_PORT }}
SECRET_KEY=${{ secrets.SECRET_KEY }}
EOF

- name: set up gradle
uses: gradle/actions/setup-gradle@v3

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package knu_chatbot.application.dto.request;

import lombok.Builder;
import lombok.Getter;

@Getter
public class ChangePasswordServiceRequest {

private String oldPassword;
private String newPassword;
private String confirmNewPassword;

@Builder
public ChangePasswordServiceRequest(String oldPassword, String newPassword, String confirmNewPassword) {
this.oldPassword = oldPassword;
this.newPassword = newPassword;
this.confirmNewPassword = confirmNewPassword;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package knu_chatbot.application.dto.response;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@NoArgsConstructor
@ToString
public class ChangePasswordResponse {

private String message;

@Builder
public ChangePasswordResponse(String message) {
this.message = message;
}

public static ChangePasswordResponse of(String message) {
return ChangePasswordResponse.builder()
.message(message)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package knu_chatbot.application.dto.response;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class LogoutResponse {

private String message;

@Builder
public LogoutResponse(String message) {
this.message = message;
}

public static LogoutResponse of(String message) {
return LogoutResponse.builder()
.message(message)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package knu_chatbot.application.dto.response;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class WithdrawResponse {

private String message;

@Builder
public WithdrawResponse(String message) {
this.message = message;
}

public static WithdrawResponse of(String message) {
return WithdrawResponse.builder()
.message(message)
.build();
}

}
2 changes: 2 additions & 0 deletions src/main/java/knu_chatbot/application/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public enum ErrorCode {
E1006,
E1007,
E1008,
E1009,
E1010,

// 히스토리
E2000,
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/knu_chatbot/application/error/ErrorType.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public enum ErrorType {
USER_COOKIE_REFRESH_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E1006, "쿠키에 RefreshToken이 없습니다.", LogLevel.INFO),
USER_INVALID_REFRESH_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E1007, "유효하지 않은 RefreshToken 입니다.", LogLevel.INFO),
USER_INVALID_ACCESS_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E1008, "유효하지 않은 AccessToken 입니다.", LogLevel.INFO),
USER_OLD_PASSWORD_MISMATCH_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E1009, "이전 비밀번호가 일치하지 않습니다.", LogLevel.INFO),
USER_NEW_PASSWORD_MISMATCH_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E1010, "새로운 비밀번호가 일치하지 않습니다.", LogLevel.INFO),

// 히스토리
HISTORY_INVALID_ERROR(HttpStatus.UNAUTHORIZED, ErrorCode.E2000, "히스토리가 존재하지 않습니다.", LogLevel.INFO);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ public interface MemberRepository {

void deleteRefreshToken(String refreshToken);

void deleteMemberByEmail(String email);

void updatePasswordByEmail(String email, String newEncryptedPassword);
}
45 changes: 37 additions & 8 deletions src/main/java/knu_chatbot/application/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package knu_chatbot.application.service;

import knu_chatbot.application.dto.AuthUser;
import knu_chatbot.application.dto.MemberDto;
import knu_chatbot.application.dto.request.ChangePasswordServiceRequest;
import knu_chatbot.application.dto.request.CheckEmailServiceRequest;
import knu_chatbot.application.dto.request.LoginServiceRequest;
import knu_chatbot.application.dto.request.SignupServiceRequest;
import knu_chatbot.application.dto.response.CheckEmailResponse;
import knu_chatbot.application.dto.response.LoginResponse;
import knu_chatbot.application.dto.response.ReissueTokensResponse;
import knu_chatbot.application.dto.response.SignupResponse;
import knu_chatbot.application.dto.response.*;
import knu_chatbot.application.error.ErrorType;
import knu_chatbot.application.error.KnuChatbotException;
import knu_chatbot.application.repository.MemberRepository;
Expand Down Expand Up @@ -37,7 +36,9 @@ public CheckEmailResponse checkEmail(CheckEmailServiceRequest request) {

@Transactional
public SignupResponse signup(SignupServiceRequest request) {
validatePasswordMatch(request);
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new KnuChatbotException(ErrorType.USER_CONFIRM_PASSWORD_ERROR, request);
}

if (memberRepository.existsByEmail(request.getEmail())) {
throw new KnuChatbotException(ErrorType.USER_INVALID_EMAIL_ERROR, request.getEmail());
Expand Down Expand Up @@ -96,10 +97,38 @@ public MyPageResponse getMyPage(String email) {
return MyPageResponse.from(memberDto);
}

private void validatePasswordMatch(SignupServiceRequest request) {
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new KnuChatbotException(ErrorType.USER_CONFIRM_PASSWORD_ERROR, request);
public LogoutResponse logout(String refreshToken) {
memberRepository.deleteRefreshToken(refreshToken);
return LogoutResponse.of("로그아웃이 완료되었습니다.");
}

@Transactional
public WithdrawResponse withdraw(String email) {
memberRepository.deleteMemberByEmail(email);

// TODO : 히스토리 및 채팅 삭제

return WithdrawResponse.of("회원탈퇴가 완료되었습니다.");
}
Comment on lines +106 to +112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

회원 탈퇴 시 member만 삭제하고 관련 데이터(히스토리, 채팅)는 삭제하지 않고 있습니다. TODO로 명시되어 있지만, 이로 인해 고아 데이터(orphaned data)가 발생하여 데이터 무결성을 해칠 수 있습니다. 회원과 연관된 모든 데이터를 함께 삭제하거나, soft delete를 적용하는 것을 고려해야 합니다. 예를 들어, Member 엔티티에 deleted 플래그를 추가하고 회원 탈퇴 시 이 플래그를 업데이트하는 방식이 있습니다.


@Transactional
public ChangePasswordResponse changePassword(AuthUser authUser, ChangePasswordServiceRequest request) {
MemberDto memberDto = memberRepository.findByEmail(authUser.getEmail());

// 이전 비밀번호 검증
if (!passwordEncryptor.verifyPassword(request.getOldPassword(), memberDto.getPassword())) {
throw new KnuChatbotException(ErrorType.USER_OLD_PASSWORD_MISMATCH_ERROR);
}

// 새로운 비밀번호 검증
if (!request.getNewPassword().equals(request.getConfirmNewPassword())) {
throw new KnuChatbotException(ErrorType.USER_NEW_PASSWORD_MISMATCH_ERROR);
}

String newEncryptedPassword = passwordEncryptor.encryptPassword(request.getNewPassword());
memberRepository.updatePasswordByEmail(authUser.getEmail(),newEncryptedPassword);

return ChangePasswordResponse.of("비밀번호가 변경되었습니다.");
}

}
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/knu_chatbot/infrastructure/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,13 @@ public Member(String email, String password) {
this.email = email;
this.password = password;
}

private void setPassword(String password) {
this.password = password;
}

public void changePassword(String newEncryptedPassword) {
setPassword(newEncryptedPassword);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ public interface MemberJpaRepository extends JpaRepository<Member, Long> {

Optional<Member> findByEmail(String email);

void deleteByEmail(String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ public void save(Member member) {

@Override
public MemberDto findByEmail(String email) {
Member member = memberJpaRepository.findByEmail(email)
.orElseThrow(() -> new KnuChatbotException(ErrorType.USER_INVALID_ERROR));
Member member = getOrThrowMemberByEmail(email);
return memberMapper.convert(member);
}

Expand All @@ -56,4 +55,23 @@ public void deleteRefreshToken(String refreshToken) {
redisTemplate.delete(refreshToken);
}

@Override
public void deleteMemberByEmail(String email) {
memberJpaRepository.deleteByEmail(email);
}

@Override
public void updatePasswordByEmail(String email, String newEncryptedPassword) {
Member member = getOrThrowMemberByEmail(email);

member.changePassword(newEncryptedPassword);

memberJpaRepository.save(member);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

updatePasswordByEmail 메서드는 서비스 계층의 @Transactional 컨텍스트 내에서 호출됩니다. 따라서 getOrThrowMemberByEmail을 통해 조회된 Member 엔티티는 영속성 컨텍스트에 의해 관리되는 상태(managed state)입니다. member.changePassword()를 통해 엔티티의 상태를 변경하면, 트랜잭션이 커밋될 때 변경 감지(dirty checking)에 의해 자동으로 UPDATE 쿼리가 실행됩니다. 따라서 이 save 호출은 불필요하며, 제거하여 코드를 더 간결하게 만들 수 있습니다.

}

private Member getOrThrowMemberByEmail(String email) {
return memberJpaRepository.findByEmail(email)
.orElseThrow(() -> new KnuChatbotException(ErrorType.USER_INVALID_ERROR));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import knu_chatbot.application.dto.AuthUser;
import knu_chatbot.application.dto.response.CheckEmailResponse;
import knu_chatbot.application.dto.response.LoginResponse;
import knu_chatbot.application.dto.response.ReissueTokensResponse;
import knu_chatbot.application.dto.response.SignupResponse;
import knu_chatbot.application.dto.response.*;
import knu_chatbot.application.error.ErrorType;
import knu_chatbot.application.error.KnuChatbotException;
import knu_chatbot.application.service.MemberService;
import knu_chatbot.application.util.JwtProvider;
import knu_chatbot.presentation.ApiResponse;
import knu_chatbot.presentation.annotation.Login;
import knu_chatbot.presentation.request.ChangePasswordRequest;
import knu_chatbot.presentation.request.CheckEmailRequest;
import knu_chatbot.presentation.request.LoginRequest;
import knu_chatbot.presentation.request.SignupRequest;
Expand Down Expand Up @@ -91,7 +89,6 @@ public ApiResponse<AccessTokenResponse> reissueTokens(HttpServletRequest request
return ApiResponse.success(reissueTokensResponse.toAccessTokenResponse());
}

// TODO : 마이페이지
@Operation(
summary = "마이페이지",
description = "마이페이지 정보를 보여준다."
Expand All @@ -101,10 +98,37 @@ public ApiResponse<MyPageResponse> getMyPage(@Parameter(hidden = true) @Login Au
return ApiResponse.success(memberService.getMyPage(authUser.getEmail()));
}

// TODO : 로그아웃
// TODO : 회원 탈퇴
// TODO : 회원 정보 수정
// TODO : 비밀번호 수정
@Operation(
summary = "로그아웃",
description = "로그아웃 한다."
)
@PostMapping("/logout")
public ApiResponse<LogoutResponse> logout(HttpServletRequest request) {
String refreshToken = getRefreshTokenFromCookie(request);
return ApiResponse.success(memberService.logout(refreshToken));
}

@Operation(
summary = "회원 탈퇴"
)
@DeleteMapping
public ApiResponse<WithdrawResponse> withdraw(@Parameter(hidden = true) @Login AuthUser authUser) {
return ApiResponse.success(memberService.withdraw(authUser.getEmail()));
}

@Operation(
summary = "비밀번호 변경",
description = "비밀번호를 수정한다."
)
@PatchMapping
public ApiResponse<ChangePasswordResponse> changePassword(
@Parameter(hidden = true) @Login AuthUser authUser,
@Valid @RequestBody ChangePasswordRequest request
) {
return ApiResponse.success(memberService.changePassword(authUser, request.toServiceRequest()));
}

// TODO : 회원 정보 수정 (현재는 비즈니스 상 수정할 정보가 없음)

private ResponseCookie createCookie(String refreshToken) {
ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, refreshToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package knu_chatbot.presentation.request;

import jakarta.validation.constraints.NotBlank;
import knu_chatbot.application.dto.request.ChangePasswordServiceRequest;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;

@Getter
@NoArgsConstructor
public class ChangePasswordRequest {

@NotBlank(message = "이전 비밀번호는 필수입니다.")
private String oldPassword;

@NotBlank(message = "새로운 비밀번호는 필수입니다.")
@Length(min = 8, max = 20, message = "길이는 최소 8글자, 최대 20글자 입니다.")
private String newPassword;

@NotBlank(message = "새로운 비밀번호 확인은 필수입니다.")
private String confirmNewPassword;

@Builder
public ChangePasswordRequest(String oldPassword, String newPassword, String confirmNewPassword) {
this.oldPassword = oldPassword;
this.newPassword = newPassword;
this.confirmNewPassword = confirmNewPassword;
}

public ChangePasswordServiceRequest toServiceRequest() {
return ChangePasswordServiceRequest.builder()
.oldPassword(oldPassword)
.newPassword(newPassword)
.confirmNewPassword(confirmNewPassword)
.build();
}
}
4 changes: 0 additions & 4 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,5 @@ management.metrics.tags.application=knuchatbot

logging.file.name=/logs/app.log

# discord
discord.environment=${DISCORD_ENVIRONMENT}
discord.webhook-url=${DISCORD_WEBHOOK_URL}

# secret
jwt.secret=${SECRET_KEY}
Loading
Loading