From cc5637b59347052a617a2d9e9243044f669ff91f Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:08 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9A=A9=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85=C2=B7?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EA=B3=BC=20=ED=95=99=EC=A0=81=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=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/Department.java | 20 ++++++++++++++-- .../member/domain/member/domain/Member.java | 17 ++++++++++++- .../domain/member/domain/MemberErrorCode.java | 3 ++- .../member/domain/MemberProfileCommand.java | 11 +++++++++ .../member/repository/MemberRepository.java | 2 ++ .../domain/member/service/MemberService.java | 3 +++ .../service/impl/MemberServiceImpl.java | 24 +++++++++++++++++++ .../stream/db/member/MemberJpaEntity.java | 6 ++++- .../stream/db/member/MemberJpaRepository.java | 2 ++ .../db/member/MemberRepositoryImpl.java | 10 ++++++++ .../V14__add_academic_status_to_members.sql | 4 ++++ 11 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java index c5b5c6a3..80d977a2 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java @@ -1,9 +1,25 @@ package kr.ac.kookmin.stream.member.domain.member.domain; +import java.util.Arrays; +import kr.ac.kookmin.stream.common.BusinessException; +import lombok.AllArgsConstructor; + /** * 학부. 학생회 부서(CouncilDepartment)와는 다른 개념이다. */ +@AllArgsConstructor public enum Department { - AI, - SW + AI("인공지능전공"), + SW("소프트웨어전공"); + + // 로그인 provider가 주는 소속 문자열에서 찾는 전공명 + private final String majorName; + + /** 소속 문자열을 학부로 바꾼다. 소프트웨어융합대학 전공이 아니면 가입할 수 없다. */ + public static Department fromMajor(String major) { + return Arrays.stream(values()) + .filter(department -> major != null && major.contains(department.majorName)) + .findFirst() + .orElseThrow(() -> new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED)); + } } 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 ef5deb80..75f43d22 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 @@ -16,21 +16,36 @@ public class Member { private String studentId; private String name; private Department department; + // 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인 전 이관 회원은 null이다 + private String academicStatus; private String email; 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); + } + public static Member of( Long id, String studentId, String name, Department department, + String academicStatus, String email, String fcmToken, Role role, CouncilDepartment councilDepartment ) { - return new Member(id, studentId, name, department, email, fcmToken, role, councilDepartment); + return new Member(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment); + } + + // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다 + public void updateProfile(String name, Department department, String academicStatus) { + this.name = name; + this.department = department; + this.academicStatus = academicStatus; } } 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 5df8fbb3..1bfc9911 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 @@ -11,7 +11,8 @@ @AllArgsConstructor public enum MemberErrorCode implements ErrorCode { - MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), + DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다."); 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/MemberProfileCommand.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java new file mode 100644 index 00000000..a1552128 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.member.domain.member.domain; + +/** + * 로그인 provider가 준 회원 프로필. major·academicStatus는 원문이며, 학부 변환은 {@link Department#fromMajor}가 한다. + */ +public record MemberProfileCommand( + String studentId, + String name, + String major, + String academicStatus +) {} 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 49c027fc..c7e98d8e 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 @@ -6,6 +6,8 @@ public interface MemberRepository { Optional findById(Long id); + Optional findByStudentId(String studentId); 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/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index 46ef4bc6..7cd62f93 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 @@ -3,10 +3,13 @@ import java.util.List; import java.util.Map; import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; public interface MemberService { Member getById(Long id); List findAllByIds(List ids); Map getMapByIds(List ids); List searchIdsByKeyword(String keyword); + Member updateProfile(Long id, MemberProfileCommand command); + Member registerOrUpdateByStudentId(MemberProfileCommand command); } 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 b41d13e9..19d90ac7 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 @@ -5,12 +5,15 @@ 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.Department; 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.repository.MemberRepository; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -40,4 +43,25 @@ public Map getMapByIds(List ids) { public List searchIdsByKeyword(String keyword) { return memberRepository.searchIdsByKeyword(keyword); } + + @Override + @Transactional + public Member updateProfile(Long id, MemberProfileCommand command) { + Member member = getById(id); + member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus()); + return memberRepository.save(member); + } + + @Override + @Transactional + public Member registerOrUpdateByStudentId(MemberProfileCommand command) { + Department department = Department.fromMajor(command.major()); + Member member = memberRepository.findByStudentId(command.studentId()) + .map(existing -> { + existing.updateProfile(command.name(), department, command.academicStatus()); + return existing; + }) + .orElseGet(() -> Member.create(command.studentId(), command.name(), department, command.academicStatus())); + return memberRepository.save(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 0f68a633..a3069ec3 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 @@ -38,6 +38,9 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { @Column(nullable = false, length = 30) private Department department; + @Column(name = "academic_status", length = 50) + private String academicStatus; + private String email; @Column(name = "fcm_token") @@ -56,6 +59,7 @@ private MemberJpaEntity(Member member) { this.studentId = member.getStudentId(); this.name = member.getName(); this.department = member.getDepartment(); + this.academicStatus = member.getAcademicStatus(); this.email = member.getEmail(); this.fcmToken = member.getFcmToken(); this.role = member.getRole(); @@ -67,6 +71,6 @@ public static MemberJpaEntity from(Member member) { } public Member toDomain() { - return Member.of(id, studentId, name, department, email, fcmToken, role, councilDepartment); + return Member.of(id, studentId, name, department, academicStatus, email, 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 24fbf7ab..2877d272 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 @@ -10,6 +10,8 @@ public interface MemberJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); + Optional findByStudentIdAndIsDeletedFalse(String studentId); + 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 88e7e1a2..89998df2 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 @@ -18,6 +18,11 @@ public Optional findById(Long id) { return memberJpaRepository.findByIdAndIsDeletedFalse(id).map(MemberJpaEntity::toDomain); } + @Override + public Optional findByStudentId(String studentId) { + return memberJpaRepository.findByStudentIdAndIsDeletedFalse(studentId).map(MemberJpaEntity::toDomain); + } + @Override public List findAllByIds(List ids) { if (ids.isEmpty()) { @@ -32,4 +37,9 @@ public List findAllByIds(List ids) { public List searchIdsByKeyword(String keyword) { return memberJpaRepository.searchIdsByKeyword(keyword); } + + @Override + public Member save(Member member) { + return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain(); + } } diff --git a/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql b/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql new file mode 100644 index 00000000..151c6acc --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql @@ -0,0 +1,4 @@ +-- 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인할 때마다 갱신한다. +-- 로그인 전인 기존(이관) 회원은 값이 없으므로 NULL을 허용한다. +ALTER TABLE members + ADD COLUMN academic_status VARCHAR(50) NULL AFTER department; From aaf22a3da86bb4fc8025dac41864fe06de0be124 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EC=97=AC=EB=B6=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberTermAgreementRepository.java | 8 +++++ .../member/service/MemberTermService.java | 5 ++++ .../service/impl/MemberTermServiceImpl.java | 30 +++++++++++++++++++ .../MemberTermAgreementJpaRepository.java | 8 +++++ .../MemberTermAgreementRepositoryImpl.java | 21 +++++++++++++ 5 files changed, 72 insertions(+) create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java 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 new file mode 100644 index 00000000..2ad7a2ec --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.member.domain.member.repository; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement; + +public interface MemberTermAgreementRepository { + List findAllByMemberId(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 new file mode 100644 index 00000000..0abd77db --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java @@ -0,0 +1,5 @@ +package kr.ac.kookmin.stream.member.domain.member.service; + +public interface MemberTermService { + boolean hasAgreedRequiredTerms(Long memberId); +} 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 new file mode 100644 index 00000000..2fdff165 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java @@ -0,0 +1,30 @@ +package kr.ac.kookmin.stream.member.domain.member.service.impl; + +import java.util.EnumSet; +import java.util.Set; +import java.util.stream.Collectors; +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; + +@Service +@RequiredArgsConstructor +class MemberTermServiceImpl implements MemberTermService { + + private static final Set REQUIRED_TERM_TYPES = + EnumSet.of(TermType.PRIVACY_POLICY, TermType.TERMS_OF_SERVICE); + + private final MemberTermAgreementRepository memberTermAgreementRepository; + + @Override + public boolean hasAgreedRequiredTerms(Long memberId) { + Set agreedTermTypes = memberTermAgreementRepository.findAllByMemberId(memberId).stream() + .filter(MemberTermAgreement::isAgreed) + .map(MemberTermAgreement::getTermType) + .collect(Collectors.toSet()); + return agreedTermTypes.containsAll(REQUIRED_TERM_TYPES); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java new file mode 100644 index 00000000..35637f1f --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberTermAgreementJpaRepository extends JpaRepository { + List findAllByMemberId(Long memberId); +} 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 new file mode 100644 index 00000000..42d8f3b0 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement; +import kr.ac.kookmin.stream.member.domain.member.repository.MemberTermAgreementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberTermAgreementRepositoryImpl implements MemberTermAgreementRepository { + + private final MemberTermAgreementJpaRepository memberTermAgreementJpaRepository; + + @Override + public List findAllByMemberId(Long memberId) { + return memberTermAgreementJpaRepository.findAllByMemberId(memberId).stream() + .map(MemberTermAgreementJpaEntity::toDomain) + .toList(); + } +} From 5dcf2fbfdea5e03e2f4fb4f5aede53921f66a4bf Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:28 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20KConnect=20OAuth=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 ++ infrastructure/client/build.gradle.kts | 1 + .../oauth/kconnect/KConnectClientConfig.java | 36 +++++ .../oauth/kconnect/KConnectOAuthClient.java | 148 ++++++++++++++++++ .../oauth/kconnect/KConnectProperties.java | 58 +++++++ .../oauth/kconnect/KConnectTokenResponse.java | 10 ++ .../application-infrastructure-client.yml | 17 ++ 7 files changed, 287 insertions(+) create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java diff --git a/.env.example b/.env.example index e83f4b92..a255ec55 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,20 @@ R2_SECRET_KEY=replace-with-r2-secret-key # R2는 region을 auto로 고정한다 (변경 불필요) R2_REGION=auto S3_UPLOAD_URL_EXPIRY_SECONDS=600 + +### infrastructure:client — KConnect OAuth ### +# KConnect 측 요청으로 API 명세(주소·경로·응답 필드)는 저장소에 남기지 않는다. 실제 값은 팀 내부 문서를 참고한다 +KCONNECT_BASE_URL=replace-with-kconnect-base-url +KCONNECT_TOKEN_PATH=replace-with-token-path +# KConnect 관리자에게 받은 값. Client Secret은 코드·로그에 남기지 않는다 +KCONNECT_CLIENT_ID=replace-with-kconnect-client-id +KCONNECT_CLIENT_SECRET=replace-with-kconnect-client-secret +# KConnect에 등록한 redirect URI (쉼표 구분). 운영 환경에는 localhost를 넣지 않는다 +KCONNECT_ALLOWED_REDIRECT_URIS=kmustream://oauth/kconnect,http://localhost:5173/oauth/kconnect +# 사용자 정보 API 경로와 응답에서 읽을 필드 이름 +KCONNECT_USER_INFO_PATH=replace-with-user-info-path +KCONNECT_USER_INFO_ID_FIELD=replace-with-field-name +KCONNECT_USER_INFO_STUDENT_ID_FIELD=replace-with-field-name +KCONNECT_USER_INFO_NAME_FIELD=replace-with-field-name +KCONNECT_USER_INFO_MAJOR_FIELD=replace-with-field-name +KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD=replace-with-field-name diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index f7205cb8..ed5bd55e 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -6,6 +6,7 @@ description = "외부 API 클라이언트 구현체" dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:auth")) implementation(project(":core:domain:internal")) implementation(platform(libs.springBootDependenciesBom)) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java new file mode 100644 index 00000000..dfba9b0f --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.client.oauth.kconnect; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * {@link KConnectOAuthClient}가 쓰는 RestClient 빈 설정. 로그인 요청이 KConnect 응답을 기다리며 오래 묶이지 않도록 타임아웃을 건다. + */ +@Configuration +public class KConnectClientConfig { + + private static final Logger log = LoggerFactory.getLogger(KConnectClientConfig.class); + + @Bean + public RestClient kconnectRestClient(KConnectProperties properties) { + // 로컬처럼 KConnect 설정 없이도 기동은 되게 두고, 빠진 설정을 알려만 준다. 로그인하면 500이 난다 + List missingSettings = properties.missingSettings(); + if (!missingSettings.isEmpty()) { + log.warn("KConnect 설정이 비어 있어 KConnect 로그인을 쓸 수 없습니다: {}", missingSettings); + } + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.readTimeout()); + + return RestClient.builder() + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java new file mode 100644 index 00000000..b93cd181 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java @@ -0,0 +1,148 @@ +package kr.ac.kookmin.stream.client.oauth.kconnect; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthErrorCode; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo; +import kr.ac.kookmin.stream.common.BusinessException; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +/** + * KConnect(국민대 학생용 OAuth) 구현체. 앱·웹이 PKCE 로그인으로 받은 code를 access token으로 교환하고, + * 그 토큰으로 사용자 정보 API를 불러 사용자를 확인한다. access token은 사용자 조회에만 쓰고 저장하지 않는다. + *

+ * KConnect 측 요청으로 API 명세는 코드에 두지 않는다. 경로와 응답 필드 이름은 {@link KConnectProperties}로 받는다. + * code·code verifier·access token·client secret은 로그와 예외 메시지에 남기지 않는다. + */ +@Component +@RequiredArgsConstructor +public class KConnectOAuthClient implements OAuthClient { + + private static final Logger log = LoggerFactory.getLogger(KConnectOAuthClient.class); + + // RFC 6749 표준 에러 코드: code 만료·재사용·verifier 불일치 + private static final String INVALID_GRANT = "invalid_grant"; + private static final ParameterizedTypeReference> JSON_OBJECT = new ParameterizedTypeReference<>() {}; + + private final KConnectProperties properties; + + @Qualifier("kconnectRestClient") + private final RestClient kconnectRestClient; + + @Override + public OAuthProvider provider() { + return OAuthProvider.KCONNECT; + } + + @Override + public boolean isAllowedRedirectUri(String redirectUri) { + return properties.allowedRedirectUris().contains(redirectUri); + } + + @Override + public OAuthUserInfo fetchUserInfo(OAuthLoginCommand command) { + List missingSettings = properties.missingSettings(); + if (!missingSettings.isEmpty()) { + throw new IllegalStateException("KConnect 설정이 비어 있습니다: " + missingSettings); + } + + String accessToken = exchangeCode(command); + return toOAuthUserInfo(requestUserInfo(accessToken)); + } + + // code는 한 번만 쓸 수 있어 두 번째 요청은 항상 invalid_grant가 된다. 그래서 재시도하지 않는다 + private String exchangeCode(OAuthLoginCommand command) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("code", command.code()); + form.add("redirect_uri", command.redirectUri()); + form.add("code_verifier", command.codeVerifier()); + form.add("client_id", properties.clientId()); + form.add("client_secret", properties.clientSecret()); + + try { + KConnectTokenResponse response = kconnectRestClient.post() + .uri(properties.tokenPath()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(KConnectTokenResponse.class); + if (response == null || response.accessToken() == null) { + throw new IllegalStateException("KConnect 토큰 응답에 access_token이 없습니다"); + } + return response.accessToken(); + } catch (HttpClientErrorException e) { + // 사용자가 다시 로그인하면 되는 경우 + if (e.getResponseBodyAsString().contains(INVALID_GRANT)) { + throw new BusinessException(OAuthErrorCode.INVALID_AUTHORIZATION_CODE); + } + // invalid_client 등: Client ID·Secret 설정 오류라 서버에서 알아야 하므로 500으로 남긴다 + throw new IllegalStateException( + "KConnect 토큰 교환 실패: " + e.getStatusCode() + " " + e.getResponseBodyAsString(), e); + } catch (HttpServerErrorException | ResourceAccessException e) { + throw providerUnavailable(e); + } + } + + private Map requestUserInfo(String accessToken) { + try { + Map response = kconnectRestClient.get() + .uri(properties.userInfo().path()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(JSON_OBJECT); + if (response == null) { + throw new IllegalStateException("KConnect 사용자 정보 응답이 비어 있습니다"); + } + return response; + } catch (HttpClientErrorException e) { + // 방금 발급받은 토큰이 거절되면 scope·경로 등 설정 문제다 + throw new IllegalStateException("KConnect 사용자 정보 조회 실패: " + e.getStatusCode(), e); + } catch (HttpServerErrorException | ResourceAccessException e) { + throw providerUnavailable(e); + } + } + + private OAuthUserInfo toOAuthUserInfo(Map response) { + KConnectProperties.UserInfo fields = properties.userInfo(); + String providerUserId = field(response, fields.idField()); + if (providerUserId == null) { + throw new IllegalStateException("KConnect 사용자 정보 응답에 고유 ID가 없습니다"); + } + + return new OAuthUserInfo( + OAuthProvider.KCONNECT, + providerUserId, + field(response, fields.studentIdField()), + field(response, fields.nameField()), + field(response, fields.majorField()), + field(response, fields.academicStatusField()) + ); + } + + private static String field(Map response, String name) { + return Objects.toString(response.get(name), null); + } + + private BusinessException providerUnavailable(Exception e) { + log.warn("KConnect 호출 실패: {}", e.getMessage()); + return new BusinessException(OAuthErrorCode.OAUTH_PROVIDER_UNAVAILABLE); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java new file mode 100644 index 00000000..361d6b92 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java @@ -0,0 +1,58 @@ +package kr.ac.kookmin.stream.client.oauth.kconnect; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * KConnect 연동 설정. KConnect 측 요청으로 API 명세(주소·경로·응답 필드)를 코드에 두지 않고 환경 변수로만 받는다. + *

+ * allowedRedirectUris는 KConnect에 등록한 redirect URI와 글자 하나까지 같아야 한다. 운영 환경에는 localhost를 넣지 않는다. + */ +@ConfigurationProperties(prefix = "oauth.kconnect") +public record KConnectProperties( + String baseUrl, + String tokenPath, + String clientId, + String clientSecret, + Set allowedRedirectUris, + UserInfo userInfo, + Duration connectTimeout, + Duration readTimeout +) { + + /** 사용자 정보 API의 경로와, 응답에서 읽을 필드 이름. */ + public record UserInfo( + String path, + String idField, + String studentIdField, + String nameField, + String majorField, + String academicStatusField + ) {} + + /** 비어 있는 필수 설정의 키 이름. 로그에 남기므로 값은 담지 않는다. */ + public List missingSettings() { + UserInfo info = userInfo != null ? userInfo : new UserInfo(null, null, null, null, null, null); + + Map required = new LinkedHashMap<>(); + required.put("base-url", baseUrl); + required.put("token-path", tokenPath); + required.put("client-id", clientId); + required.put("client-secret", clientSecret); + required.put("user-info.path", info.path()); + required.put("user-info.id-field", info.idField()); + required.put("user-info.student-id-field", info.studentIdField()); + required.put("user-info.name-field", info.nameField()); + required.put("user-info.major-field", info.majorField()); + required.put("user-info.academic-status-field", info.academicStatusField()); + + return required.entrySet().stream() + .filter(entry -> entry.getValue() == null || entry.getValue().isBlank()) + .map(Map.Entry::getKey) + .toList(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java new file mode 100644 index 00000000..285a49fd --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.client.oauth.kconnect; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +// 토큰 교환 응답. access token만 쓰고 refresh token 등 나머지는 받지 않는다 +@JsonIgnoreProperties(ignoreUnknown = true) +record KConnectTokenResponse( + @JsonProperty("access_token") String accessToken +) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 95f32b32..6b8b40ba 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -12,3 +12,20 @@ file: access-key: ${R2_ACCESS_KEY:} secret-key: ${R2_SECRET_KEY:} upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600} +# KConnect 측 요청으로 API 명세(주소·경로·응답 필드)는 환경 변수로만 주입하고 저장소에 남기지 않는다 +oauth: + kconnect: + base-url: ${KCONNECT_BASE_URL:} + token-path: ${KCONNECT_TOKEN_PATH:} + client-id: ${KCONNECT_CLIENT_ID:} + client-secret: ${KCONNECT_CLIENT_SECRET:} + allowed-redirect-uris: ${KCONNECT_ALLOWED_REDIRECT_URIS:kmustream://oauth/kconnect} + user-info: + path: ${KCONNECT_USER_INFO_PATH:} + id-field: ${KCONNECT_USER_INFO_ID_FIELD:} + student-id-field: ${KCONNECT_USER_INFO_STUDENT_ID_FIELD:} + name-field: ${KCONNECT_USER_INFO_NAME_FIELD:} + major-field: ${KCONNECT_USER_INFO_MAJOR_FIELD:} + academic-status-field: ${KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD:} + connect-timeout: 3s + read-timeout: 5s From 702c0f43ac69c72e966df6c3ae9646259cd7d740 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:37:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20Department.fromMajor=EC=9D=98?= =?UTF-8?q?=20null=20=EA=B2=80=EC=82=AC=EB=A5=BC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A7=84=EC=9E=85=EB=B6=80=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/member/domain/member/domain/Department.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/Department.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java index 80d977a2..3811d097 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java @@ -17,8 +17,11 @@ public enum Department { /** 소속 문자열을 학부로 바꾼다. 소프트웨어융합대학 전공이 아니면 가입할 수 없다. */ public static Department fromMajor(String major) { + if (major == null) { + throw new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED); + } return Arrays.stream(values()) - .filter(department -> major != null && major.contains(department.majorName)) + .filter(department -> major.contains(department.majorName)) .findFirst() .orElseThrow(() -> new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED)); } From 1899e982d9ba911a29d7bbe16328ccc659eaae03 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:37:14 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=ED=95=84=EC=88=98=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EC=97=AC=EB=B6=80=EB=A5=BC=20TermType=EC=9D=98=20r?= =?UTF-8?q?equired=20=ED=95=84=EB=93=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/domain/member/domain/TermType.java | 16 ++++++++++++++-- .../service/impl/MemberTermServiceImpl.java | 9 ++++----- 2 files changed, 18 insertions(+), 7 deletions(-) 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 68758db3..a70eb1ce 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 @@ -1,6 +1,18 @@ package kr.ac.kookmin.stream.member.domain.member.domain; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public enum TermType { - PRIVACY_POLICY, // 개인정보 이용 동의 - TERMS_OF_SERVICE // 서비스 이용 약관 동의 + + PRIVACY_POLICY(true), // 개인정보 이용 동의 + TERMS_OF_SERVICE(true); // 서비스 이용 약관 동의 + + /** 가입 시 반드시 동의해야 하는 약관인지 여부 */ + private final boolean required; } 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 2fdff165..855ecb16 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,6 +1,6 @@ package kr.ac.kookmin.stream.member.domain.member.service.impl; -import java.util.EnumSet; +import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement; @@ -14,9 +14,6 @@ @RequiredArgsConstructor class MemberTermServiceImpl implements MemberTermService { - private static final Set REQUIRED_TERM_TYPES = - EnumSet.of(TermType.PRIVACY_POLICY, TermType.TERMS_OF_SERVICE); - private final MemberTermAgreementRepository memberTermAgreementRepository; @Override @@ -25,6 +22,8 @@ public boolean hasAgreedRequiredTerms(Long memberId) { .filter(MemberTermAgreement::isAgreed) .map(MemberTermAgreement::getTermType) .collect(Collectors.toSet()); - return agreedTermTypes.containsAll(REQUIRED_TERM_TYPES); + return Arrays.stream(TermType.values()) + .filter(TermType::required) + .allMatch(agreedTermTypes::contains); } }