From cf2041501e47b25f510e5c04a83bf7e286b4da1d Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:54:57 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=EC=9A=94=EC=B2=AD=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20400=EC=9C=BC=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/common/GlobalExceptionHandler.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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={}] {}", From b1e0a1a7fea36a079af59ff4a8779703281bd3f7 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:55:45 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/domain/member/domain/Member.java | 16 ++++++++++++++-- .../member/repository/MemberRepository.java | 1 + .../stream/db/member/MemberJpaEntity.java | 11 ++++++++--- .../stream/db/member/MemberJpaRepository.java | 2 ++ .../stream/db/member/MemberRepositoryImpl.java | 5 +++++ .../V16__add_phone_number_to_members.sql | 10 ++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V16__add_phone_number_to_members.sql 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/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/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..588ac903 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 @@ -23,6 +23,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()) { 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); From 4d36d3182f136a43f870253cfa74dc1a1bc147af Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:56:28 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EC=A0=80=EC=9E=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/domain/MemberErrorCode.java | 3 +- .../member/domain/MemberTermAgreement.java | 12 ++++++ .../member/domain/member/domain/TermType.java | 7 ++- .../MemberTermAgreementRepository.java | 1 + .../member/service/MemberTermService.java | 4 ++ .../service/impl/MemberTermServiceImpl.java | 43 +++++++++++++++++++ .../MemberTermAgreementRepositoryImpl.java | 7 +++ 7 files changed, 74 insertions(+), 3 deletions(-) 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..f8e9c336 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,8 @@ 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, "필수 약관에 모두 동의해 주세요."); 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/MemberTermAgreement.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberTermAgreement.java index e330dc6e..53d1076e 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,16 @@ 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) { + 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/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/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/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java index 855ecb16..9f9d59cf 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,40 @@ public boolean hasAgreedRequiredTerms(Long memberId) { .filter(TermType::required) .allMatch(agreedTermTypes::contains); } + + // 요청에 없는 약관은 동의하지 않은 것으로 보고 기록을 남기지 않는다 + @Override + @Transactional + public void agree(Long memberId, 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); + } + + 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 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/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()); + } } From 9fbb4c6ab24d960dca13342494e9abe0b90a557c Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:57:06 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/domain/MemberErrorCode.java | 4 ++- .../member/domain/MemberSignUpCommand.java | 16 +++++++++ .../domain/member/service/MemberService.java | 3 ++ .../service/impl/MemberServiceImpl.java | 33 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberSignUpCommand.java 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 f8e9c336..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 @@ -13,7 +13,9 @@ public enum MemberErrorCode implements ErrorCode { MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다."), - REQUIRED_TERMS_NOT_AGREED(ErrorStatus.BAD_REQUEST, "필수 약관에 모두 동의해 주세요."); + 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/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/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index b646fbe4..0120a516 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,36 @@ public Member registerOrUpdateByStudentId(MemberProfileCommand command) { return applyProfile(found.get(), command); } + // 전화번호 저장과 약관 동의를 한 트랜잭션으로 묶는다. 필수 약관에 동의하지 않으면 전화번호 저장도 롤백된다 + @Override + @Transactional + public Member signUp(Long memberId, MemberSignUpCommand command) { + Member member = getById(memberId); + if (isSignedUp(member)) { + throw new BusinessException(MemberErrorCode.ALREADY_SIGNED_UP); + } + // 필수 약관이 늘어 다시 가입하는 회원은 자기 번호를 그대로 보낼 수 있으므로 같은 번호면 중복 검사를 건너뛴다 + if (!command.phoneNumber().equals(member.getPhoneNumber()) + && memberRepository.existsByPhoneNumber(command.phoneNumber())) { + throw new BusinessException(MemberErrorCode.PHONE_NUMBER_ALREADY_EXISTS); + } + + 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 Member register(MemberProfileCommand command) { Member member = Member.create( command.studentId(), From d66320f24b9efda9ad759254f69d2c026816f9bb Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:57:37 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/core/member/AppMemberApi.java | 31 ++++++++++++ .../app/core/member/AppMemberController.java | 30 ++++++++++++ .../member/request/MemberSignUpRequest.java | 47 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/MemberSignUpRequest.java 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..fd7b3a73 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/MemberSignUpRequest.java @@ -0,0 +1,47 @@ +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 java.util.stream.Collectors; +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 +) { + + // 같은 약관이 두 번 오면 IllegalArgumentException → GlobalExceptionHandler가 INVALID_INPUT(400)으로 응답한다 + public MemberSignUpCommand toCommand() { + Map agreements = termAgreements.stream() + .collect(Collectors.toMap( + TermAgreementRequest::termType, + TermAgreementRequest::agreed, + (first, second) -> { + throw new IllegalArgumentException("같은 약관의 동의 여부가 중복되었습니다."); + }, + () -> new EnumMap<>(TermType.class) + )); + return new MemberSignUpCommand(phoneNumber, agreements); + } + + public record TermAgreementRequest( + @NotNull(message = "약관 종류를 입력해 주세요.") + TermType termType, + + @NotNull(message = "약관 동의 여부를 입력해 주세요.") + Boolean agreed + ) {} +} From 2849edbb72d4e328dc5a1692a8de472b487bd1b3 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:58:04 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=84=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=95=84=EC=9A=94=20=EC=97=AC=EB=B6=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ac/kookmin/stream/api/app/core/auth/AppAuthApi.java | 2 +- .../api/app/core/auth/response/OAuthLoginResponse.java | 4 ++-- .../api/app/core/auth/usecase/OAuthLoginUseCase.java | 8 +++----- 3 files changed, 6 insertions(+), 8 deletions(-) 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..74021d39 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,9 @@ package kr.ac.kookmin.stream.api.app.core.auth.response; /** - * @param termsAgreementRequired 필수 약관에 아직 동의하지 않았으면 true. 앱은 이 값으로 약관 화면을 띄운다 + * @param signUpRequired 회원가입(전화번호·필수 약관 동의)을 마치지 않았으면 true. 앱은 이 값으로 회원가입 화면을 띄운다 */ public record OAuthLoginResponse( String accessToken, - boolean termsAgreementRequired + 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..36efbf43 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, signUpRequired); } private Member findOrRegister(OAuthUserInfo userInfo) { From 003e80a0227de25503e53de08b38d09e6e52e437 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:03:20 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?validate=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/request/MemberSignUpRequest.java | 26 +++++++++++-------- .../service/impl/MemberServiceImpl.java | 26 +++++++++++++------ .../service/impl/MemberTermServiceImpl.java | 16 +++++++----- 3 files changed, 43 insertions(+), 25 deletions(-) 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 index fd7b3a73..04122980 100644 --- 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 @@ -8,7 +8,6 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; import kr.ac.kookmin.stream.member.domain.member.domain.TermType; @@ -23,20 +22,25 @@ public record MemberSignUpRequest( List<@NotNull(message = "약관 동의 항목이 비어 있습니다.") TermAgreementRequest> termAgreements ) { - // 같은 약관이 두 번 오면 IllegalArgumentException → GlobalExceptionHandler가 INVALID_INPUT(400)으로 응답한다 public MemberSignUpCommand toCommand() { - Map agreements = termAgreements.stream() - .collect(Collectors.toMap( - TermAgreementRequest::termType, - TermAgreementRequest::agreed, - (first, second) -> { - throw new IllegalArgumentException("같은 약관의 동의 여부가 중복되었습니다."); - }, - () -> new EnumMap<>(TermType.class) - )); + 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, 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 0120a516..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 @@ -69,14 +69,8 @@ public Member registerOrUpdateByStudentId(MemberProfileCommand command) { @Transactional public Member signUp(Long memberId, MemberSignUpCommand command) { Member member = getById(memberId); - if (isSignedUp(member)) { - throw new BusinessException(MemberErrorCode.ALREADY_SIGNED_UP); - } - // 필수 약관이 늘어 다시 가입하는 회원은 자기 번호를 그대로 보낼 수 있으므로 같은 번호면 중복 검사를 건너뛴다 - if (!command.phoneNumber().equals(member.getPhoneNumber()) - && memberRepository.existsByPhoneNumber(command.phoneNumber())) { - throw new BusinessException(MemberErrorCode.PHONE_NUMBER_ALREADY_EXISTS); - } + validateNotSignedUp(member); + validatePhoneNumberNotDuplicated(member, command.phoneNumber()); member.registerPhoneNumber(command.phoneNumber()); Member saved = memberRepository.save(member); @@ -94,6 +88,22 @@ 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 9f9d59cf..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 @@ -38,12 +38,7 @@ public boolean hasAgreedRequiredTerms(Long memberId) { @Override @Transactional public void agree(Long memberId, 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); - } + validateRequiredTermsAgreed(termAgreements); Map existingAgreements = memberTermAgreementRepository.findAllByMemberId(memberId) .stream() @@ -55,6 +50,15 @@ public void agree(Long memberId, Map termAgreements) { 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, From 3ec7c3b7d4112a3ba7940dbb399ddd40c199b1ce Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:53:44 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=9A=8C=EC=9B=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/core/auth/response/OAuthLoginResponse.java | 1 + .../stream/api/app/core/auth/usecase/OAuthLoginUseCase.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 74021d39..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 @@ -5,5 +5,6 @@ */ public record OAuthLoginResponse( String accessToken, + 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 36efbf43..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 @@ -32,7 +32,7 @@ public OAuthLoginResponse login(OAuthLoginCommand command) { String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment()); // 회원을 이번에 만들었는지가 아니라 저장된 전화번호·동의 기록으로 판단한다. 회원가입 화면에서 이탈한 회원도 다시 가입 화면을 본다 boolean signUpRequired = !memberService.isSignedUp(member.getId()); - return new OAuthLoginResponse(accessToken, signUpRequired); + return new OAuthLoginResponse(accessToken, member.getName(), signUpRequired); } private Member findOrRegister(OAuthUserInfo userInfo) { From 98308ad5b20e1d3615269c69060690dd004dd8f7 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:26:49 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=20=ED=8C=90=EB=B3=84=EC=9D=84=20db=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/common/ConstraintViolationUtil.java | 20 +++++++++++++++++++ .../LockerApplicationRepositoryImpl.java | 11 ++-------- 2 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/ConstraintViolationUtil.java 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); - } } From 26831c6db50a8fd318018da060c939d7662d452c Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:26:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=EC=9D=84=20409=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/db/member/MemberRepositoryImpl.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 588ac903..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 @@ -45,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; + } } } From 70a8cf4388360c4b40eaca8ddf1df2973c760c18 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:26:50 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=EA=B0=99=EC=9D=80=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=EC=97=90=20=EB=8B=A4=EC=8B=9C=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=20=EC=B5=9C=EC=B4=88=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/domain/member/domain/MemberTermAgreement.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 53d1076e..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 @@ -34,8 +34,11 @@ public static MemberTermAgreement create(Long memberId, TermType termType, boole 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;