diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java index 74ca85dc..3ebad06b 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java @@ -12,4 +12,5 @@ public final class ErrorStatus { public static final int BAD_REQUEST = 400; public static final int CONFLICT = 409; public static final int FORBIDDEN = 403; + public static final int BAD_GATEWAY = 502; } diff --git a/core/domain/auth/build.gradle.kts b/core/domain/auth/build.gradle.kts index 0854131d..122cec81 100644 --- a/core/domain/auth/build.gradle.kts +++ b/core/domain/auth/build.gradle.kts @@ -4,6 +4,14 @@ plugins { description = "auth 도메인 — gateway:auth(Security/JWT)와는 별개의 도메인 계층 로직" +// gateway:auth와 프로젝트 이름(auth)이 같아 기본 좌표(kr.ac.kookmin:auth)가 겹치면 Gradle이 두 모듈을 같은 모듈로 보고 +// 한쪽을 다른 쪽으로 치환한다. group을 달리해 좌표를 분리하고, bootJar 안에서 jar 파일명도 겹치지 않게 바꾼다 +group = "kr.ac.kookmin.domain" + +base { + archivesName.set("domain-auth") +} + dependencies { implementation(project(":core:common")) diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java new file mode 100644 index 00000000..7100c894 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.client; + +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; + +/** + * provider별 OAuth 로그인 전략. 구현체는 provider마다 하나씩 모두 빈으로 등록하고, + * 요청의 provider에 맞는 구현체를 OAuthClientRegistry가 고른다. + */ +public interface OAuthClient { + + OAuthProvider provider(); + + /** provider에 등록한 redirect URI와 정확히 일치하는지. 외부 호출 전에 거르는 용도다. */ + boolean isAllowedRedirectUri(String redirectUri); + + /** + * code를 provider 토큰으로 교환하고 사용자 정보를 조회한다. + * provider 토큰은 이 메서드 안에서만 쓰고 밖으로 내보내지 않는다. + */ + OAuthUserInfo fetchUserInfo(OAuthLoginCommand command); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java new file mode 100644 index 00000000..eb75571e --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * provider 계정과 회원의 연결. 한 회원은 provider마다 계정을 하나씩 연결할 수 있다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthAccount { + + private Long id; + private Long memberId; + private OAuthProvider provider; + private String providerUserId; + + public static OAuthAccount create(Long memberId, OAuthProvider provider, String providerUserId) { + return new OAuthAccount(null, memberId, provider, providerUserId); + } + + public static OAuthAccount of(Long id, Long memberId, OAuthProvider provider, String providerUserId) { + return new OAuthAccount(id, memberId, provider, providerUserId); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java new file mode 100644 index 00000000..66884559 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum OAuthErrorCode implements ErrorCode { + + UNSUPPORTED_OAUTH_PROVIDER(ErrorStatus.BAD_REQUEST, "지원하지 않는 로그인 방식입니다."), + REDIRECT_URI_NOT_ALLOWED(ErrorStatus.BAD_REQUEST, "허용되지 않은 redirect URI입니다."), + INVALID_AUTHORIZATION_CODE(ErrorStatus.UNAUTHORIZED, "로그인이 만료되었습니다. 다시 로그인해 주세요."), + OAUTH_PROVIDER_UNAVAILABLE(ErrorStatus.BAD_GATEWAY, "로그인 서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요."); + + private final int status; + private final String message; +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java new file mode 100644 index 00000000..71dcb3eb --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +public record OAuthLoginCommand( + OAuthProvider provider, + String code, + String codeVerifier, + String redirectUri +) {} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java new file mode 100644 index 00000000..62826c66 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import java.util.Arrays; +import kr.ac.kookmin.stream.common.BusinessException; + +public enum OAuthProvider { + KCONNECT; + + /** 로그인 경로의 provider 값(예: "kconnect")을 대소문자 구분 없이 변환한다. */ + public static OAuthProvider from(String value) { + return Arrays.stream(values()) + .filter(provider -> provider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER)); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java new file mode 100644 index 00000000..697009b8 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +/** + * provider에서 확인한 사용자 정보. + *

+ * providerUserId는 provider 안에서 바뀌지 않는 고유 ID로, 회원을 찾는 키가 된다. + * studentId·major·academicStatus는 학번·학과·학적을 주지 않는 provider(구글·카카오)에서 null일 수 있다. + * academicStatus는 provider가 주는 학적 상태 값을 그대로 담는다. + */ +public record OAuthUserInfo( + OAuthProvider provider, + String providerUserId, + String studentId, + String name, + String major, + String academicStatus +) {} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java new file mode 100644 index 00000000..1544c562 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; + +public interface OAuthAccountRepository { + Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId); + OAuthAccount save(OAuthAccount oauthAccount); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java new file mode 100644 index 00000000..14fd2c3f --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service; + +import java.util.Optional; +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; + +public interface OAuthService { + OAuthUserInfo authenticate(OAuthLoginCommand command); + Optional findMemberId(OAuthProvider provider, String providerUserId); + void link(OAuthProvider provider, String providerUserId, Long memberId); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java new file mode 100644 index 00000000..46417aa3 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service.impl; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +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.OAuthProvider; +import kr.ac.kookmin.stream.common.BusinessException; +import org.springframework.stereotype.Component; + +/** + * 빈으로 등록된 OAuthClient 구현체를 provider별로 모아 두고, 요청의 provider에 맞는 구현체를 돌려준다. + * 같은 provider의 구현체가 둘이면 기동 시 실패한다. + */ +@Component +class OAuthClientRegistry { + + private final Map clients; + + OAuthClientRegistry(List clients) { + this.clients = clients.stream() + .collect(Collectors.toMap( + OAuthClient::provider, + Function.identity(), + (first, second) -> { + throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider()); + }, + () -> new EnumMap<>(OAuthProvider.class) + )); + } + + OAuthClient get(OAuthProvider provider) { + OAuthClient client = clients.get(provider); + if (client == null) { + throw new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + return client; + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java new file mode 100644 index 00000000..ac0f3413 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service.impl; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +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.auth.domain.oauth.repository.OAuthAccountRepository; +import kr.ac.kookmin.stream.auth.domain.oauth.service.OAuthService; +import kr.ac.kookmin.stream.common.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class OAuthServiceImpl implements OAuthService { + + private final OAuthClientRegistry oauthClientRegistry; + private final OAuthAccountRepository oauthAccountRepository; + + // 외부 호출만 하므로 트랜잭션을 걸지 않는다 + @Override + public OAuthUserInfo authenticate(OAuthLoginCommand command) { + OAuthClient client = oauthClientRegistry.get(command.provider()); + if (!client.isAllowedRedirectUri(command.redirectUri())) { + throw new BusinessException(OAuthErrorCode.REDIRECT_URI_NOT_ALLOWED); + } + + return client.fetchUserInfo(command); + } + + @Override + public Optional findMemberId(OAuthProvider provider, String providerUserId) { + return oauthAccountRepository.findByProviderAndProviderUserId(provider, providerUserId) + .map(OAuthAccount::getMemberId); + } + + @Override + @Transactional + public void link(OAuthProvider provider, String providerUserId, Long memberId) { + oauthAccountRepository.save(OAuthAccount.create(memberId, provider, providerUserId)); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java new file mode 100644 index 00000000..ae0c6da6 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java @@ -0,0 +1,8 @@ +/** + * 계층 패키지(domain/{도메인}/{domain|repository|service})를 그대로 공개하기 위해 OPEN으로 둔다. + * service.impl 접근 차단은 bootstrap의 DomainImplAccessTests(ArchUnit)가 담당한다. + */ +@ApplicationModule(type = ApplicationModule.Type.OPEN) +package kr.ac.kookmin.stream.auth; + +import org.springframework.modulith.ApplicationModule; 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..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 @@ -1,9 +1,28 @@ 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) { + if (major == null) { + throw new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED); + } + return Arrays.stream(values()) + .filter(department -> 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..261e9301 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.member.domain.member.domain; +import java.util.Objects; import kr.ac.kookmin.stream.common.CouncilDepartment; import kr.ac.kookmin.stream.common.Role; import lombok.AccessLevel; @@ -16,21 +17,42 @@ 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의 최신 이름·학부·학적 상태로 갱신한다. 바뀐 값이 있으면 true를 돌려준다 + public boolean updateProfile(String name, Department department, String academicStatus) { + if (Objects.equals(this.name, name) + && this.department == department + && Objects.equals(this.academicStatus, academicStatus)) { + return false; + } + this.name = name; + this.department = department; + this.academicStatus = academicStatus; + return true; } } 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/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/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/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/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/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/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index b41d13e9..b646fbe4 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 @@ -2,15 +2,19 @@ import java.util.List; import java.util.Map; +import java.util.Optional; 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 +44,38 @@ public Map getMapByIds(List ids) { public List searchIdsByKeyword(String keyword) { return memberRepository.searchIdsByKeyword(keyword); } + + @Override + @Transactional + public Member updateProfile(Long id, MemberProfileCommand command) { + return applyProfile(getById(id), command); + } + + @Override + @Transactional + public Member registerOrUpdateByStudentId(MemberProfileCommand command) { + Optional found = memberRepository.findByStudentId(command.studentId()); + if (found.isEmpty()) { + return register(command); + } + return applyProfile(found.get(), command); + } + + private Member register(MemberProfileCommand command) { + Member member = Member.create( + command.studentId(), + command.name(), + Department.fromMajor(command.major()), + command.academicStatus() + ); + return memberRepository.save(member); + } + + private Member applyProfile(Member member, MemberProfileCommand command) { + boolean changed = member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus()); + if (!changed) { + return member; + } + return memberRepository.save(member); + } } 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..855ecb16 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.member.domain.member.service.impl; + +import java.util.Arrays; +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 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 Arrays.stream(TermType.values()) + .filter(TermType::required) + .allMatch(agreedTermTypes::contains); + } +} diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 893436cd..35e7486d 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -517,6 +517,32 @@ public class S3FileStorageClient implements FileStorageClient { ... } public class LocalFileStorageClient implements FileStorageClient { ... } ``` +- **단, 요청마다 구현체를 골라 써야 하는 포트는 구현체를 모두 Bean으로 띄우고 레지스트리로 고른다.** 설정값으로 하나를 고르는 위 경우와 달리, 여러 구현체가 동시에 쓰이는 경우다(예: 로그인 provider별 `OAuthClient` — 요청 경로의 `{provider}`로 고른다). 포트에 자신의 키를 돌려주는 메서드를 두고, 도메인의 `service.impl`에 둔 package-private 레지스트리가 `List<포트>`를 주입받아 `EnumMap`으로 모은다. 같은 키의 구현체가 둘이면 기동 시 실패시키고, 없는 키를 요청하면 도메인 `ErrorCode`로 400을 던진다. 새 구현체를 추가할 때 서비스·UseCase·컨트롤러는 고치지 않는다. + +```java +// core:domain:auth — domain/oauth/client (공개 포트) +public interface OAuthClient { + OAuthProvider provider(); + OAuthUserInfo fetchUserInfo(OAuthLoginCommand command); +} + +// core:domain:auth — domain/oauth/service/impl (비공개) +@Component +class OAuthClientRegistry { + + private final Map clients; + + OAuthClientRegistry(List clients) { + this.clients = clients.stream().collect(Collectors.toMap( + OAuthClient::provider, Function.identity(), + (first, second) -> { throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider()); }, + () -> new EnumMap<>(OAuthProvider.class))); + } + + OAuthClient get(OAuthProvider provider) { ... } // 없으면 BusinessException(UNSUPPORTED_OAUTH_PROVIDER) +} +``` + - **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** 설정 클래스에도 구현체와 같은 `@ConditionalOnProperty`를 붙인다. SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다. ```java diff --git a/infrastructure/db/build.gradle.kts b/infrastructure/db/build.gradle.kts index 903dc34b..2b98ca65 100644 --- a/infrastructure/db/build.gradle.kts +++ b/infrastructure/db/build.gradle.kts @@ -6,6 +6,7 @@ description = "JPA Entity, RepositoryImpl, Flyway 마이그레이션 (MySQL)" dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:auth")) implementation(project(":core:domain:member")) implementation(project(":core:domain:event")) implementation(project(":core:domain:welfare")) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java new file mode 100644 index 00000000..2810a3d7 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java @@ -0,0 +1,64 @@ +package kr.ac.kookmin.stream.db.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "oauth_accounts", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_oauth_accounts_provider_provider_user_id", + columnNames = {"provider", "provider_user_id"} + ), + @UniqueConstraint( + name = "uk_oauth_accounts_member_id_provider", + columnNames = {"member_id", "provider"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthAccountJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "oauth_account_id") + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OAuthProvider provider; + + @Column(name = "provider_user_id", nullable = false) + private String providerUserId; + + private OAuthAccountJpaEntity(OAuthAccount oauthAccount) { + this.id = oauthAccount.getId(); + this.memberId = oauthAccount.getMemberId(); + this.provider = oauthAccount.getProvider(); + this.providerUserId = oauthAccount.getProviderUserId(); + } + + public static OAuthAccountJpaEntity from(OAuthAccount oauthAccount) { + return new OAuthAccountJpaEntity(oauthAccount); + } + + public OAuthAccount toDomain() { + return OAuthAccount.of(id, memberId, provider, providerUserId); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java new file mode 100644 index 00000000..801f2aab --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.db.auth; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OAuthAccountJpaRepository extends JpaRepository { + Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java new file mode 100644 index 00000000..f2f74583 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.db.auth; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import kr.ac.kookmin.stream.auth.domain.oauth.repository.OAuthAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OAuthAccountRepositoryImpl implements OAuthAccountRepository { + + private final OAuthAccountJpaRepository oauthAccountJpaRepository; + + @Override + public Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId) { + return oauthAccountJpaRepository.findByProviderAndProviderUserId(provider, providerUserId) + .map(OAuthAccountJpaEntity::toDomain); + } + + @Override + public OAuthAccount save(OAuthAccount oauthAccount) { + return oauthAccountJpaRepository.save(OAuthAccountJpaEntity.from(oauthAccount)).toDomain(); + } +} 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/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(); + } +} diff --git a/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql new file mode 100644 index 00000000..99237dab --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE oauth_accounts ( + oauth_account_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + provider VARCHAR(20) NOT NULL, -- OAuthProvider.name() + provider_user_id VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 로그인할 때 provider 계정으로 회원을 찾는다 +CREATE UNIQUE INDEX uk_oauth_accounts_provider_provider_user_id + ON oauth_accounts (provider, provider_user_id); + +-- 한 회원에게 같은 provider 계정이 둘 연결되지 않게 막는다 +CREATE UNIQUE INDEX uk_oauth_accounts_member_id_provider + ON oauth_accounts (member_id, provider); 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;