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;