diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java index 24e04d2c..36878533 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java @@ -21,7 +21,7 @@ public interface AppAuthApi { @Operation(summary = "OAuth 로그인", description = """ provider(현재 kconnect)에서 받은 code·codeVerifier·redirectUri로 로그인한다. - 처음 로그인하면 회원이 만들어진다. termsAgreementRequired가 true면 필수 약관 동의가 필요하다.""") + 처음 로그인하면 회원이 만들어진다. signUpRequired가 true면 회원가입(전화번호·필수 약관 동의)이 필요하다.""") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = OAuthErrorCode.class, codes = { "UNSUPPORTED_OAUTH_PROVIDER", diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java index 3b26ba62..6525bd93 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java @@ -1,9 +1,10 @@ package kr.ac.kookmin.stream.api.app.core.auth.response; /** - * @param termsAgreementRequired 필수 약관에 아직 동의하지 않았으면 true. 앱은 이 값으로 약관 화면을 띄운다 + * @param signUpRequired 회원가입(전화번호·필수 약관 동의)을 마치지 않았으면 true. 앱은 이 값으로 회원가입 화면을 띄운다 */ public record OAuthLoginResponse( String accessToken, - boolean termsAgreementRequired + String name, + boolean signUpRequired ) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java index 1f887967..a530c56f 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java @@ -7,7 +7,6 @@ import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; -import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService; import kr.ac.kookmin.stream.security.AuthTokenIssuer; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -24,7 +23,6 @@ public class OAuthLoginUseCase { private final OAuthService oauthService; private final MemberService memberService; - private final MemberTermService memberTermService; private final AuthTokenIssuer authTokenIssuer; public OAuthLoginResponse login(OAuthLoginCommand command) { @@ -32,9 +30,9 @@ public OAuthLoginResponse login(OAuthLoginCommand command) { Member member = findOrRegister(userInfo); String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment()); - // 회원을 이번에 만들었는지가 아니라 동의 기록으로 판단한다. 가입 후 동의하지 않고 이탈한 회원도 다시 약관 화면을 본다 - boolean termsAgreementRequired = !memberTermService.hasAgreedRequiredTerms(member.getId()); - return new OAuthLoginResponse(accessToken, termsAgreementRequired); + // 회원을 이번에 만들었는지가 아니라 저장된 전화번호·동의 기록으로 판단한다. 회원가입 화면에서 이탈한 회원도 다시 가입 화면을 본다 + boolean signUpRequired = !memberService.isSignedUp(member.getId()); + return new OAuthLoginResponse(accessToken, member.getName(), signUpRequired); } private Member findOrRegister(OAuthUserInfo userInfo) { diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java new file mode 100644 index 00000000..9fd73ee0 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.api.app.core.member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.member.request.MemberSignUpRequest; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; + +/** + * 학생 앱 회원 API의 문서 명세. 구현은 {@link AppMemberController}가 맡는다. + */ +@Tag(name = "회원", description = "학생 앱 회원가입") +public interface AppMemberApi { + + /** 회원가입. 로그인 응답의 signUpRequired가 true인 회원이 전화번호와 약관 동의 여부를 등록한다. */ + @Operation(summary = "회원가입", + description = """ + 로그인한 회원의 전화번호와 약관 동의 여부를 저장한다. + 전화번호는 하이픈을 넣어도 되고 빼도 된다. 필수 약관(PRIVACY_POLICY, TERMS_OF_SERVICE)에 모두 동의해야 한다.""") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = MemberErrorCode.class, codes = { + "MEMBER_NOT_FOUND", + "REQUIRED_TERMS_NOT_AGREED", + "ALREADY_SIGNED_UP", + "PHONE_NUMBER_ALREADY_EXISTS" + }) + ApiResponse signUp(AppApiUser apiUser, MemberSignUpRequest request); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java new file mode 100644 index 00000000..b40c3a98 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java @@ -0,0 +1,30 @@ +package kr.ac.kookmin.stream.api.app.core.member; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.member.request.MemberSignUpRequest; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/members") +@RequiredArgsConstructor +public class AppMemberController implements AppMemberApi { + + private final MemberService memberService; + + @Override + @PostMapping("/sign-up") + public ApiResponse signUp( + AppApiUser apiUser, + @Valid @RequestBody MemberSignUpRequest request + ) { + memberService.signUp(apiUser.userId(), request.toCommand()); + return ApiResponse.success(); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/MemberSignUpRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/MemberSignUpRequest.java new file mode 100644 index 00000000..04122980 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/MemberSignUpRequest.java @@ -0,0 +1,51 @@ +package kr.ac.kookmin.stream.api.app.core.member.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; +import kr.ac.kookmin.stream.member.domain.member.domain.TermType; + +public record MemberSignUpRequest( + @NotBlank(message = "전화번호를 입력해 주세요.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phoneNumber, + + // 필수 약관 여부는 TermType이 정하므로 여기서는 동의 값을 강제하지 않는다 + @NotEmpty(message = "약관 동의 여부를 입력해 주세요.") + @Valid + List<@NotNull(message = "약관 동의 항목이 비어 있습니다.") TermAgreementRequest> termAgreements +) { + + public MemberSignUpCommand toCommand() { + validateTermTypesNotDuplicated(); + + Map agreements = new EnumMap<>(TermType.class); + termAgreements.forEach(agreement -> agreements.put(agreement.termType(), agreement.agreed())); + return new MemberSignUpCommand(phoneNumber, agreements); + } + + // 같은 약관이 두 번 오면 IllegalArgumentException → GlobalExceptionHandler가 INVALID_INPUT(400)으로 응답한다 + private void validateTermTypesNotDuplicated() { + long distinctTermTypeCount = termAgreements.stream() + .map(TermAgreementRequest::termType) + .distinct() + .count(); + if (distinctTermTypeCount != termAgreements.size()) { + throw new IllegalArgumentException("같은 약관의 동의 여부가 중복되었습니다."); + } + } + + public record TermAgreementRequest( + @NotNull(message = "약관 종류를 입력해 주세요.") + TermType termType, + + @NotNull(message = "약관 동의 여부를 입력해 주세요.") + Boolean agreed + ) {} +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java index 276fb937..2a0dc73a 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -53,6 +54,15 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentEx .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), e.getMessage())); } + // JSON 문법 오류, enum에 없는 값 등 요청 본문을 읽지 못한 경우. 파서 메시지는 내부 타입 이름을 담고 있어 응답에는 넣지 않는다 + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleNotReadable(HttpMessageNotReadableException e) { + log.warn("[requestId={}, userId={}] (INVALID_INPUT) {}", + MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getMessage()); + return ResponseEntity.status(CommonErrorCode.INVALID_INPUT.status()) + .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), "요청 본문 형식이 올바르지 않습니다.")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleInternal(Exception e) { log.error("[requestId={}, userId={}] {}", diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java index 261e9301..b273e031 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java @@ -20,13 +20,15 @@ public class Member { // 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인 전 이관 회원은 null이다 private String academicStatus; private String email; + // 회원가입 때 받는 휴대전화 번호(숫자만). 가입 전 회원과 이관 회원은 null이다 + private String phoneNumber; private String fcmToken; private Role role; private CouncilDepartment councilDepartment; // 로그인으로 처음 가입하는 회원. 운영진 권한·학생회 부서는 가입 뒤 따로 부여한다 public static Member create(String studentId, String name, Department department, String academicStatus) { - return new Member(null, studentId, name, department, academicStatus, null, null, Role.STUDENT, null); + return new Member(null, studentId, name, department, academicStatus, null, null, null, Role.STUDENT, null); } public static Member of( @@ -36,11 +38,13 @@ public static Member of( Department department, String academicStatus, String email, + String phoneNumber, String fcmToken, Role role, CouncilDepartment councilDepartment ) { - return new Member(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment); + return new Member( + id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment); } // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다. 바뀐 값이 있으면 true를 돌려준다 @@ -55,4 +59,12 @@ public boolean updateProfile(String name, Department department, String academic this.academicStatus = academicStatus; return true; } + + public void registerPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public boolean hasPhoneNumber() { + return phoneNumber != null; + } } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java index 1bfc9911..9b1d9695 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java @@ -12,7 +12,10 @@ public enum MemberErrorCode implements ErrorCode { MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), - DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다."); + DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다."), + REQUIRED_TERMS_NOT_AGREED(ErrorStatus.BAD_REQUEST, "필수 약관에 모두 동의해 주세요."), + ALREADY_SIGNED_UP(ErrorStatus.CONFLICT, "이미 회원가입을 마쳤습니다."), + PHONE_NUMBER_ALREADY_EXISTS(ErrorStatus.CONFLICT, "이미 사용 중인 전화번호입니다."); private final int status; private final String message; diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberSignUpCommand.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberSignUpCommand.java new file mode 100644 index 00000000..3c89a5f8 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberSignUpCommand.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.member.domain.member.domain; + +import java.util.Map; + +/** + * 회원가입 요청. 전화번호는 하이픈을 지운 숫자만 담는다. 중복 검사와 저장이 같은 형식을 쓰게 하기 위함이다. + */ +public record MemberSignUpCommand( + String phoneNumber, + Map termAgreements +) { + + public MemberSignUpCommand { + phoneNumber = phoneNumber.replace("-", ""); + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberTermAgreement.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberTermAgreement.java index e330dc6e..291605e2 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberTermAgreement.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberTermAgreement.java @@ -28,4 +28,19 @@ public static MemberTermAgreement of( ) { return new MemberTermAgreement(id, memberId, termType, termVersion, agreed, agreedAt); } + + // 현재 약관 버전으로 동의 기록을 만든다. 동의하지 않았으면 동의 시각은 null이다 + public static MemberTermAgreement create(Long memberId, TermType termType, boolean agreed, LocalDateTime now) { + return new MemberTermAgreement(null, memberId, termType, termType.version(), agreed, agreed ? now : null); + } + + // 이미 기록이 있는 약관은 새 행을 만들지 않고 현재 버전으로 덮어쓴다. 버전과 동의 여부가 그대로면 최초 동의 시각을 지키도록 건너뛴다 + public void update(boolean agreed, LocalDateTime now) { + if (termType.version().equals(this.termVersion) && this.agreed == agreed) { + return; + } + this.termVersion = termType.version(); + this.agreed = agreed; + this.agreedAt = agreed ? now : null; + } } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java index a70eb1ce..cd663d64 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java @@ -10,9 +10,12 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public enum TermType { - PRIVACY_POLICY(true), // 개인정보 이용 동의 - TERMS_OF_SERVICE(true); // 서비스 이용 약관 동의 + PRIVACY_POLICY(true, "v1"), // 개인정보 이용 동의 + TERMS_OF_SERVICE(true, "v1"); // 서비스 이용 약관 동의 /** 가입 시 반드시 동의해야 하는 약관인지 여부 */ private final boolean required; + + /** 현재 약관 버전. 동의 기록에 함께 남긴다 */ + private final String version; } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java index c7e98d8e..d2e74f8a 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java @@ -7,6 +7,7 @@ public interface MemberRepository { Optional findById(Long id); Optional findByStudentId(String studentId); + boolean existsByPhoneNumber(String phoneNumber); List findAllByIds(List ids); List searchIdsByKeyword(String keyword); Member save(Member member); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java index 2ad7a2ec..86db290b 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java @@ -5,4 +5,5 @@ public interface MemberTermAgreementRepository { List findAllByMemberId(Long memberId); + void saveAll(List agreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index 7cd62f93..0c584bc8 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -4,6 +4,7 @@ import java.util.Map; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; public interface MemberService { Member getById(Long id); @@ -12,4 +13,6 @@ public interface MemberService { List searchIdsByKeyword(String keyword); Member updateProfile(Long id, MemberProfileCommand command); Member registerOrUpdateByStudentId(MemberProfileCommand command); + Member signUp(Long memberId, MemberSignUpCommand command); + boolean isSignedUp(Long memberId); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java index 0abd77db..49f847d4 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java @@ -1,5 +1,9 @@ package kr.ac.kookmin.stream.member.domain.member.service; +import java.util.Map; +import kr.ac.kookmin.stream.member.domain.member.domain.TermType; + public interface MemberTermService { boolean hasAgreedRequiredTerms(Long memberId); + void agree(Long memberId, Map termAgreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index b646fbe4..a8ad7c32 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -10,8 +10,10 @@ import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +23,7 @@ class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final MemberTermService memberTermService; @Override public Member getById(Long id) { @@ -61,6 +64,46 @@ public Member registerOrUpdateByStudentId(MemberProfileCommand command) { return applyProfile(found.get(), command); } + // 전화번호 저장과 약관 동의를 한 트랜잭션으로 묶는다. 필수 약관에 동의하지 않으면 전화번호 저장도 롤백된다 + @Override + @Transactional + public Member signUp(Long memberId, MemberSignUpCommand command) { + Member member = getById(memberId); + validateNotSignedUp(member); + validatePhoneNumberNotDuplicated(member, command.phoneNumber()); + + member.registerPhoneNumber(command.phoneNumber()); + Member saved = memberRepository.save(member); + memberTermService.agree(memberId, command.termAgreements()); + return saved; + } + + @Override + public boolean isSignedUp(Long memberId) { + return isSignedUp(getById(memberId)); + } + + // 전화번호와 필수 약관 동의가 모두 있어야 가입을 마친 것으로 본다 + private boolean isSignedUp(Member member) { + return member.hasPhoneNumber() && memberTermService.hasAgreedRequiredTerms(member.getId()); + } + + private void validateNotSignedUp(Member member) { + if (isSignedUp(member)) { + throw new BusinessException(MemberErrorCode.ALREADY_SIGNED_UP); + } + } + + // 필수 약관이 늘어 다시 가입하는 회원은 자기 번호를 그대로 보낼 수 있으므로 같은 번호면 중복 검사를 건너뛴다 + private void validatePhoneNumberNotDuplicated(Member member, String phoneNumber) { + if (phoneNumber.equals(member.getPhoneNumber())) { + return; + } + if (memberRepository.existsByPhoneNumber(phoneNumber)) { + throw new BusinessException(MemberErrorCode.PHONE_NUMBER_ALREADY_EXISTS); + } + } + private Member register(MemberProfileCommand command) { Member member = Member.create( command.studentId(), diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java index 855ecb16..40a35149 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java @@ -1,14 +1,21 @@ package kr.ac.kookmin.stream.member.domain.member.service.impl; +import java.time.LocalDateTime; import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement; import kr.ac.kookmin.stream.member.domain.member.domain.TermType; import kr.ac.kookmin.stream.member.domain.member.repository.MemberTermAgreementRepository; import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -26,4 +33,44 @@ public boolean hasAgreedRequiredTerms(Long memberId) { .filter(TermType::required) .allMatch(agreedTermTypes::contains); } + + // 요청에 없는 약관은 동의하지 않은 것으로 보고 기록을 남기지 않는다 + @Override + @Transactional + public void agree(Long memberId, Map termAgreements) { + validateRequiredTermsAgreed(termAgreements); + + Map existingAgreements = memberTermAgreementRepository.findAllByMemberId(memberId) + .stream() + .collect(Collectors.toMap(MemberTermAgreement::getTermType, Function.identity())); + LocalDateTime now = LocalDateTime.now(); + List agreements = termAgreements.entrySet().stream() + .map(entry -> toAgreement(memberId, entry.getKey(), entry.getValue(), existingAgreements, now)) + .toList(); + memberTermAgreementRepository.saveAll(agreements); + } + + private void validateRequiredTermsAgreed(Map termAgreements) { + boolean requiredTermsAgreed = Arrays.stream(TermType.values()) + .filter(TermType::required) + .allMatch(termType -> Boolean.TRUE.equals(termAgreements.get(termType))); + if (!requiredTermsAgreed) { + throw new BusinessException(MemberErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + } + + private MemberTermAgreement toAgreement( + Long memberId, + TermType termType, + boolean agreed, + Map existingAgreements, + LocalDateTime now + ) { + MemberTermAgreement existing = existingAgreements.get(termType); + if (existing == null) { + return MemberTermAgreement.create(memberId, termType, agreed, now); + } + existing.update(agreed, now); + return existing; + } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/ConstraintViolationUtil.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/ConstraintViolationUtil.java new file mode 100644 index 00000000..d3760c26 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/ConstraintViolationUtil.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.db.common; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * DB 제약 위반 예외 판별 유틸. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ConstraintViolationUtil { + + /** MySQL은 제약 이름 앞에 테이블명을 붙여 줄 수 있어 포함 여부로 비교한다. */ + public static boolean isViolated(DataIntegrityViolationException e, String constraintName) { + return e.getCause() instanceof ConstraintViolationException violation + && violation.getConstraintName() != null + && violation.getConstraintName().contains(constraintName); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index 4c08e488..5363247b 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -4,11 +4,11 @@ import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; import lombok.RequiredArgsConstructor; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @@ -49,17 +49,10 @@ public LockerApplication save(LockerApplication application) { return lockerApplicationJpaRepository.saveAndFlush(LockerApplicationJpaEntity.from(application)) .toDomain(); } catch (DataIntegrityViolationException e) { - if (isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { + if (ConstraintViolationUtil.isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); } throw e; } } - - /** MySQL은 제약 이름 앞에 테이블명을 붙여 줄 수 있어 포함 여부로 비교한다. */ - private boolean isViolated(DataIntegrityViolationException e, String constraintName) { - return e.getCause() instanceof ConstraintViolationException violation - && violation.getConstraintName() != null - && violation.getConstraintName().contains(constraintName); - } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java index a3069ec3..2ea39bfc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java @@ -17,8 +17,8 @@ import lombok.NoArgsConstructor; @Entity -// uk_members_active_student_id(V1)는 generated column active_student_id에 걸려 있다. -// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. +// uk_members_active_student_id(V1)·uk_members_active_phone_number(V16)는 generated column +// active_student_id·active_phone_number에 걸려 있다. Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table(name = "members") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class MemberJpaEntity extends BaseSoftDeleteEntity { @@ -43,6 +43,9 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { private String email; + @Column(name = "phone_number", length = 20) + private String phoneNumber; + @Column(name = "fcm_token") private String fcmToken; @@ -61,6 +64,7 @@ private MemberJpaEntity(Member member) { this.department = member.getDepartment(); this.academicStatus = member.getAcademicStatus(); this.email = member.getEmail(); + this.phoneNumber = member.getPhoneNumber(); this.fcmToken = member.getFcmToken(); this.role = member.getRole(); this.councilDepartment = member.getCouncilDepartment(); @@ -71,6 +75,7 @@ public static MemberJpaEntity from(Member member) { } public Member toDomain() { - return Member.of(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment); + return Member.of( + id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java index 2877d272..a1b224b7 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java @@ -12,6 +12,8 @@ public interface MemberJpaRepository extends JpaRepository findByStudentIdAndIsDeletedFalse(String studentId); + boolean existsByPhoneNumberAndIsDeletedFalse(String phoneNumber); + List findAllByIdInAndIsDeletedFalse(List ids); @Query(""" diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java index 89998df2..9996c87a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java @@ -2,15 +2,21 @@ import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class MemberRepositoryImpl implements MemberRepository { + private static final String PHONE_NUMBER_UNIQUE_CONSTRAINT = "uk_members_active_phone_number"; + private final MemberJpaRepository memberJpaRepository; @Override @@ -23,6 +29,11 @@ public Optional findByStudentId(String studentId) { return memberJpaRepository.findByStudentIdAndIsDeletedFalse(studentId).map(MemberJpaEntity::toDomain); } + @Override + public boolean existsByPhoneNumber(String phoneNumber) { + return memberJpaRepository.existsByPhoneNumberAndIsDeletedFalse(phoneNumber); + } + @Override public List findAllByIds(List ids) { if (ids.isEmpty()) { @@ -40,6 +51,14 @@ public List searchIdsByKeyword(String keyword) { @Override public Member save(Member member) { - return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain(); + try { + // 동시 가입으로 중복 검사를 함께 통과한 경우 유니크 인덱스 위반을 이 자리에서 잡으려면 바로 반영해야 한다 + return memberJpaRepository.saveAndFlush(MemberJpaEntity.from(member)).toDomain(); + } catch (DataIntegrityViolationException e) { + if (ConstraintViolationUtil.isViolated(e, PHONE_NUMBER_UNIQUE_CONSTRAINT)) { + throw new BusinessException(MemberErrorCode.PHONE_NUMBER_ALREADY_EXISTS); + } + throw e; + } } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java index 42d8f3b0..d65d7280 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java @@ -18,4 +18,11 @@ public List findAllByMemberId(Long memberId) { .map(MemberTermAgreementJpaEntity::toDomain) .toList(); } + + @Override + public void saveAll(List agreements) { + memberTermAgreementJpaRepository.saveAll(agreements.stream() + .map(MemberTermAgreementJpaEntity::from) + .toList()); + } } diff --git a/infrastructure/db/src/main/resources/db/migration/V16__add_phone_number_to_members.sql b/infrastructure/db/src/main/resources/db/migration/V16__add_phone_number_to_members.sql new file mode 100644 index 00000000..78870871 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V16__add_phone_number_to_members.sql @@ -0,0 +1,10 @@ +-- 회원가입 때 받는 휴대전화 번호. 하이픈 없이 숫자만 저장한다(01012345678). +-- 가입 전 회원과 기존(이관) 회원은 값이 없으므로 NULL을 허용한다. +ALTER TABLE members + ADD COLUMN phone_number VARCHAR(20) NULL AFTER email; + +-- 활성 회원의 전화번호만 유니크 (탈퇴 후 같은 번호로 재가입 허용) +ALTER TABLE members + ADD COLUMN active_phone_number VARCHAR(20) + GENERATED ALWAYS AS (IF(is_deleted = 0, phone_number, NULL)) VIRTUAL; +CREATE UNIQUE INDEX uk_members_active_phone_number ON members (active_phone_number);