From 28551d2b6890405532d660d5a4a21b5287f6d02c Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:00:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9B=90=20ID=EB=A1=9C?= =?UTF-8?q?=20OAuth=20=EC=97=B0=EB=8F=99=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../member/port/outbound/MemberOAuthPersistencePort.kt | 2 ++ .../member/repository/oauth/MemberOAuthJpaRepository.kt | 2 ++ .../member/repository/oauth/MemberOAuthRepository.kt | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt index 41d92de50..711a7646b 100644 --- a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt +++ b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt @@ -24,5 +24,7 @@ interface MemberOAuthPersistencePort { fun findMemberIdsByProvider(provider: OAuthProvider): List + fun findAllByMemberId(memberId: MemberId): List + fun deleteAllByMemberId(memberId: MemberId) } diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt index fab606017..67f4baff6 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt @@ -11,5 +11,7 @@ interface MemberOAuthJpaRepository : JpaRepository { externalId: String, ): MemberOAuthEntity? + fun findAllByMemberId(memberId: Long): List + fun deleteAllByMemberId(memberId: Long) } diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt index 41d6123f4..23fb88ba7 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt @@ -24,6 +24,11 @@ class MemberOAuthRepository( .findAllByProvider(provider.name) .map { MemberId(it.member.id) } + override fun findAllByMemberId(memberId: MemberId): List = + memberOAuthJpaRepository + .findAllByMemberId(memberId.value) + .map { it.toDomain() } + override fun relinkToMember( provider: OAuthProvider, externalId: String, From 821bd61bb2fbc9fd5ab08fa766428bd28fbfbd8a Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:00:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9B=90=EC=9D=98=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=EB=90=9C=20OAuth=20=EC=A0=9C=EA=B3=B5?= =?UTF-8?q?=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../member/application/service/oauth/MemberOAuthService.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt b/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt index 2c24c5f78..cd1c85b88 100644 --- a/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt @@ -20,6 +20,13 @@ class MemberOAuthService( fun findMemberIdsByProvider(provider: OAuthProvider): List = memberOAuthPersistencePort.findMemberIdsByProvider(provider) + fun findProvidersByMemberId(memberId: MemberId): List = + memberOAuthPersistencePort + .findAllByMemberId(memberId) + .map { it.provider } + .distinct() + .sorted() + fun relinkMemberOAuthProvider( member: Member, authAttribute: OAuthAttributes, From a969861dba2bcfd28245ba2f7fed93c52eb5207c Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:00:23 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat=20:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=88=98=EB=8B=A8=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../application/service/MemberQueryService.kt | 13 +++++++++++++ .../response/MemberDetailsResponse.kt | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt b/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt index 06045046c..513cc1d8a 100644 --- a/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt @@ -20,6 +20,7 @@ import core.domain.member.port.outbound.MemberPersistencePort import core.domain.member.port.outbound.query.MemberNameRoleQueryModel import core.domain.member.port.outbound.query.MemberOverviewQueryModel import core.domain.member.vo.MemberId +import core.domain.membercredential.port.outbound.MemberCredentialPersistencePort import core.domain.team.vo.TeamId import core.domain.team.vo.TeamNumber import org.springframework.beans.factory.annotation.Value @@ -31,6 +32,7 @@ class MemberQueryService( private val memberPersistencePort: MemberPersistencePort, private val memberAccessService: MemberAccessService, private val memberOAuthService: MemberOAuthService, + private val memberCredentialPersistencePort: MemberCredentialPersistencePort, private val cohortQueryUseCase: CohortQueryUseCase, @Value("\${member.default-team-id:0}") private val defaultTeamId: Int, @@ -49,8 +51,18 @@ class MemberQueryService( getMemberById(memberId), memberAccessService.isAdmin(memberId), getMemberTeamNumber(memberId), + getLoginMethods(memberId), ) + /** + * 멤버가 로그인할 수 있는 수단(소셜 제공자 + 이메일/비밀번호)을 조회함. + */ + private fun getLoginMethods(memberId: MemberId): List { + val oAuthProviders = memberOAuthService.findProvidersByMemberId(memberId).map { it.name } + val hasCredential = memberCredentialPersistencePort.findByMemberId(memberId) != null + return if (hasCredential) oAuthProviders + EMAIL_LOGIN_METHOD else oAuthProviders + } + /** * 멤버의 식별자를 기반으로 멤버 객체를 조회함. * @@ -177,6 +189,7 @@ class MemberQueryService( } private companion object { + private const val EMAIL_LOGIN_METHOD = "EMAIL" private val UUID_REGEX = Regex( pattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", diff --git a/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt b/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt index 40b35ac62..b4151c3b0 100644 --- a/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt +++ b/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt @@ -2,6 +2,7 @@ package core.application.member.presentation.response import core.domain.member.aggregate.Member import core.domain.team.vo.TeamNumber +import io.swagger.v3.oas.annotations.media.ArraySchema import io.swagger.v3.oas.annotations.media.Schema data class MemberDetailsResponse( @@ -49,12 +50,27 @@ data class MemberDetailsResponse( requiredMode = Schema.RequiredMode.REQUIRED, ) val status: String, + @field:ArraySchema( + schema = + Schema( + description = "로그인 수단 (소셜 제공자 및 이메일/비밀번호)", + allowableValues = ["KAKAO", "APPLE", "EMAIL"], + ), + arraySchema = + Schema( + description = "연동된 로그인 수단 목록", + example = "[\"KAKAO\", \"EMAIL\"]", + requiredMode = Schema.RequiredMode.REQUIRED, + ), + ) + val loginMethods: List, ) { companion object { fun of( member: Member, isAdmin: Boolean, teamNumber: TeamNumber, + loginMethods: List, ): MemberDetailsResponse = MemberDetailsResponse( email = member.signupEmail, @@ -64,6 +80,7 @@ data class MemberDetailsResponse( teamNumber = teamNumber, isAdmin = isAdmin, status = member.status.name, + loginMethods = loginMethods, ) } } From aabef72e688af50b415dad88f162e8659ed0da4f Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:29:07 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat=20:=20member=5Foauth=20email=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- db/pending/2609241927_member_oauth_email.sql | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 db/pending/2609241927_member_oauth_email.sql diff --git a/db/pending/2609241927_member_oauth_email.sql b/db/pending/2609241927_member_oauth_email.sql new file mode 100644 index 000000000..bf11d79d6 --- /dev/null +++ b/db/pending/2609241927_member_oauth_email.sql @@ -0,0 +1,81 @@ +-- ============================================================================= +-- 2026-09-24 · member_oauth.email 컬럼 추가 +-- ============================================================================= +-- 목적: 로그인 수단(OAuth 제공자)별 이메일을 저장한다. (#563) +-- 1) member_oauth.email 컬럼 추가 (NULL 허용) +-- 2) 기존 연동 행을 members.signup_email 로 채우기 +-- 선행: 없음 +-- 후행: 없음 +-- 검증: 파일 하단 VERIFY 섹션 (읽기 전용) +-- 주의: 애플리케이션이 ddl-auto: validate 이므로 이 스크립트를 먼저 적용한 뒤 배포해야 한다. +-- 컬럼이 없으면 MemberOAuthEntity 스키마 검증에 실패해 서버가 기동되지 않는다. + +-- ----------------------------------------------------------------------------- +-- [1] member_oauth.email 컬럼 추가 +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +SET @has_email := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='member_oauth' AND column_name='email'); +SET @sql1 := IF(@has_email=0, + 'ALTER TABLE member_oauth ADD COLUMN email VARCHAR(255) NULL COMMENT ''OAuth 제공자가 내려준 이메일 (로그인 시 최신화)''', + 'SELECT 1'); +PREPARE s1 FROM @sql1; EXECUTE s1; DEALLOCATE PREPARE s1; + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [2] 기존 연동 행 이메일 채우기 +-- 소셜 계정은 signup_email 과 제공자 이메일이 같은 회원에게만 연동되므로 +-- (Kakao/Apple 로그인 시 findAllBySignupEmail 로 매칭) 연동 시점의 이메일은 +-- signup_email 과 같다. 이후 제공자 쪽에서 이메일이 바뀐 경우는 다음 로그인 때 +-- 애플리케이션이 최신값으로 갱신한다. +-- 탈퇴로 익명화된 회원(withdrawn+...@withdrawn.local)은 채우지 않는다. +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +UPDATE member_oauth mo +INNER JOIN members m ON mo.member_id = m.member_id +SET mo.email = m.signup_email +WHERE mo.email IS NULL + AND m.signup_email NOT LIKE '%@withdrawn.local'; + +COMMIT; + +-- ============================================================================= +-- VERIFY (읽기 전용) +-- ============================================================================= +-- 컬럼 존재 (기대: email / varchar(255) / YES) +SHOW COLUMNS FROM member_oauth LIKE 'email'; + +-- 채움 현황 (기대: null_email 은 탈퇴 회원 또는 members 에 없는 고아 행 수와 일치) +SELECT provider, + COUNT(*) AS total, + COUNT(email) AS with_email, + COUNT(*) - COUNT(email) AS null_email +FROM member_oauth +GROUP BY provider; + +-- 채우지 못한 행의 사유 (기대: withdrawn 또는 orphan 만) +SELECT mo.member_oauth_id, + mo.provider, + CASE + WHEN m.member_id IS NULL THEN 'orphan' + WHEN m.signup_email LIKE '%@withdrawn.local' THEN 'withdrawn' + ELSE 'unexpected' + END AS reason +FROM member_oauth mo +LEFT JOIN members m ON mo.member_id = m.member_id +WHERE mo.email IS NULL; + +-- ============================================================================= +-- ROLLBACK +-- ============================================================================= +-- 컬럼 추가 + 값 채우기만 수행하므로 컬럼 DROP 으로 충분. +-- 단, 롤백 전에 이 컬럼을 사용하는 애플리케이션 버전을 먼저 내려야 한다. +-- (ddl-auto: validate 로 인해 컬럼이 없으면 기동 실패) +-- +-- START TRANSACTION; +-- ALTER TABLE member_oauth DROP COLUMN email; +-- COMMIT; +-- ============================================================================= From bb06df1a0a615ee0d1b03974c5c98a89051c0ed2 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:29:07 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat=20:=20OAuth=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EC=97=90=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B0=8F=20=EA=B0=B1=EC=8B=A0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/domain/member/aggregate/MemberOAuth.kt | 9 +++++++-- .../port/outbound/MemberOAuthPersistencePort.kt | 6 ++++++ .../core/entity/member/MemberOAuthEntity.kt | 4 ++++ .../oauth/MemberOAuthJpaRepository.kt | 17 +++++++++++++++++ .../repository/oauth/MemberOAuthRepository.kt | 10 ++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/domain/src/main/kotlin/core/domain/member/aggregate/MemberOAuth.kt b/domain/src/main/kotlin/core/domain/member/aggregate/MemberOAuth.kt index c2d4a928b..220c126fc 100644 --- a/domain/src/main/kotlin/core/domain/member/aggregate/MemberOAuth.kt +++ b/domain/src/main/kotlin/core/domain/member/aggregate/MemberOAuth.kt @@ -9,6 +9,7 @@ class MemberOAuth( val externalId: String, val provider: OAuthProvider, val memberId: MemberId, + val email: String? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -17,7 +18,8 @@ class MemberOAuth( return id == other.id && externalId == other.externalId && provider == other.provider && - memberId == other.memberId + memberId == other.memberId && + email == other.email } override fun hashCode(): Int { @@ -25,22 +27,25 @@ class MemberOAuth( result = 31 * result + externalId.hashCode() result = 31 * result + provider.hashCode() result = 31 * result + memberId.hashCode() + result = 31 * result + (email?.hashCode() ?: 0) return result } override fun toString(): String = - "MemberOAuth(id=$id, externalId='$externalId', provider=$provider, memberId=$memberId)" + "MemberOAuth(id=$id, externalId='$externalId', provider=$provider, memberId=$memberId, email=$email)" companion object { fun of( externalId: String, provider: OAuthProvider, memberId: MemberId, + email: String? = null, ): MemberOAuth = MemberOAuth( externalId = externalId, provider = provider, memberId = memberId, + email = email?.takeIf { it.isNotBlank() }, ) } } diff --git a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt index 711a7646b..cc7f3be7a 100644 --- a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt +++ b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberOAuthPersistencePort.kt @@ -26,5 +26,11 @@ interface MemberOAuthPersistencePort { fun findAllByMemberId(memberId: MemberId): List + fun updateEmail( + provider: OAuthProvider, + externalId: String, + email: String, + ) + fun deleteAllByMemberId(memberId: MemberId) } diff --git a/entity/src/main/kotlin/core/entity/member/MemberOAuthEntity.kt b/entity/src/main/kotlin/core/entity/member/MemberOAuthEntity.kt index d567247e7..73b5ccbb2 100644 --- a/entity/src/main/kotlin/core/entity/member/MemberOAuthEntity.kt +++ b/entity/src/main/kotlin/core/entity/member/MemberOAuthEntity.kt @@ -31,6 +31,8 @@ class MemberOAuthEntity( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) val member: MemberEntity, + @Column(name = "email") + val email: String? = null, ) { fun toDomain(): MemberOAuth = MemberOAuth( @@ -38,6 +40,7 @@ class MemberOAuthEntity( externalId = externalId, provider = OAuthProvider.valueOf(provider), memberId = MemberId(member.id), + email = email, ) companion object { @@ -50,6 +53,7 @@ class MemberOAuthEntity( externalId = memberOAuth.externalId, provider = memberOAuth.provider.name, member = MemberEntity.from(member), + email = memberOAuth.email, ) } } diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt index 67f4baff6..31ef11d50 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthJpaRepository.kt @@ -2,6 +2,10 @@ package core.persistence.member.repository.oauth import core.entity.member.MemberOAuthEntity import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.transaction.annotation.Transactional interface MemberOAuthJpaRepository : JpaRepository { fun findAllByProvider(provider: String): List @@ -14,4 +18,17 @@ interface MemberOAuthJpaRepository : JpaRepository { fun findAllByMemberId(memberId: Long): List fun deleteAllByMemberId(memberId: Long) + + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + "update MemberOAuthEntity m set m.email = :email " + + "where m.provider = :provider and m.externalId = :externalId " + + "and (m.email is null or m.email <> :email)", + ) + fun updateEmail( + @Param("provider") provider: String, + @Param("externalId") externalId: String, + @Param("email") email: String, + ): Int } diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt index 23fb88ba7..ea159f584 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/oauth/MemberOAuthRepository.kt @@ -44,6 +44,7 @@ class MemberOAuthRepository( externalId = existing.externalId, provider = existing.provider, member = core.entity.member.MemberEntity.from(member), + email = existing.email, ), ) } @@ -55,6 +56,15 @@ class MemberOAuthRepository( return memberOAuthJpaRepository.findByProviderAndExternalId(provider.name, externalId)?.toDomain() } + override fun updateEmail( + provider: OAuthProvider, + externalId: String, + email: String, + ) { + if (email.isBlank()) return + memberOAuthJpaRepository.updateEmail(provider.name, externalId, email) + } + override fun deleteAllByMemberId(memberId: MemberId) { memberOAuthJpaRepository.deleteAllByMemberId(memberId.value) } From 3d91dab5c0e0f5984011f6acddfb5a308e504895 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:29:07 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat=20:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../application/service/MemberLoginService.kt | 1 + .../service/auth/AppleAuthService.kt | 29 +++++++++++-------- .../service/auth/KakaoAuthService.kt | 13 +++++++-- .../service/oauth/MemberOAuthService.kt | 17 ++++++++--- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/application/src/main/kotlin/core/application/member/application/service/MemberLoginService.kt b/application/src/main/kotlin/core/application/member/application/service/MemberLoginService.kt index 55c3840e5..499085a7e 100644 --- a/application/src/main/kotlin/core/application/member/application/service/MemberLoginService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/MemberLoginService.kt @@ -42,6 +42,7 @@ class MemberLoginService( ?: recoverOrCreateMemberForOrphanedOAuth(authAttributes).also { memberOAuthService.relinkMemberOAuthProvider(it, authAttributes) } + memberOAuthService.syncEmail(authAttributes) return handleExistingMemberLogin(member, deviceId) } diff --git a/application/src/main/kotlin/core/application/member/application/service/auth/AppleAuthService.kt b/application/src/main/kotlin/core/application/member/application/service/auth/AppleAuthService.kt index b6ffec203..ebc259247 100644 --- a/application/src/main/kotlin/core/application/member/application/service/auth/AppleAuthService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/auth/AppleAuthService.kt @@ -79,24 +79,29 @@ class AppleAuthService( externalId = externalId, provider = OAuthProvider.APPLE, memberId = targetMember.id!!, + email = email, ), targetMember, ) targetMember } else { - memberPersistencePort.findById(memberOAuth.memberId) - ?: recoverOrCreateMemberForOrphanedOAuth( - externalId = externalId, - email = email, - name = - resolveMemberName( - fullName = fullName, - familyName = familyName, - givenName = givenName, - email = email, - ), - ) + ( + memberPersistencePort.findById(memberOAuth.memberId) + ?: recoverOrCreateMemberForOrphanedOAuth( + externalId = externalId, + email = email, + name = + resolveMemberName( + fullName = fullName, + familyName = familyName, + givenName = givenName, + email = email, + ), + ) + ).also { + memberOAuthPersistencePort.updateEmail(OAuthProvider.APPLE, externalId, email) + } } validateMemberForLogin(member) diff --git a/application/src/main/kotlin/core/application/member/application/service/auth/KakaoAuthService.kt b/application/src/main/kotlin/core/application/member/application/service/auth/KakaoAuthService.kt index 730900a2d..3ac5a22c2 100644 --- a/application/src/main/kotlin/core/application/member/application/service/auth/KakaoAuthService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/auth/KakaoAuthService.kt @@ -63,14 +63,23 @@ class KakaoAuthService( externalId = attributes.getExternalId(), provider = attributes.getProvider(), memberId = targetMember.id!!, + email = attributes.getEmail(), ), targetMember, ) targetMember } else { - memberPersistencePort.findById(memberOAuth.memberId) - ?: recoverOrCreateMemberForOrphanedOAuth(attributes) + ( + memberPersistencePort.findById(memberOAuth.memberId) + ?: recoverOrCreateMemberForOrphanedOAuth(attributes) + ).also { + memberOAuthPersistencePort.updateEmail( + provider = attributes.getProvider(), + externalId = attributes.getExternalId(), + email = attributes.getEmail(), + ) + } } validateMemberForLogin(member) diff --git a/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt b/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt index cd1c85b88..d65003a3d 100644 --- a/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/oauth/MemberOAuthService.kt @@ -20,12 +20,20 @@ class MemberOAuthService( fun findMemberIdsByProvider(provider: OAuthProvider): List = memberOAuthPersistencePort.findMemberIdsByProvider(provider) - fun findProvidersByMemberId(memberId: MemberId): List = + fun findAllByMemberId(memberId: MemberId): List = memberOAuthPersistencePort .findAllByMemberId(memberId) - .map { it.provider } - .distinct() - .sorted() + .sortedBy { it.provider } + + /** + * 로그인 시 OAuth 제공자가 내려준 이메일로 연동 정보의 이메일을 최신화함. + */ + fun syncEmail(authAttribute: OAuthAttributes) = + memberOAuthPersistencePort.updateEmail( + provider = authAttribute.getProvider(), + externalId = authAttribute.getExternalId(), + email = authAttribute.getEmail(), + ) fun relinkMemberOAuthProvider( member: Member, @@ -53,6 +61,7 @@ class MemberOAuthService( authAttribute.getExternalId(), authAttribute.getProvider(), member.id!!, + authAttribute.getEmail(), ), member, ) From c2be090b78c2df7de4061680d1a255d807dc6bd6 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 24 Sep 2026 19:29:07 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat=20:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=20=EC=88=98=EB=8B=A8=EB=B3=84=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../application/service/MemberQueryService.kt | 17 ++++++++---- .../response/MemberDetailsResponse.kt | 27 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt b/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt index 513cc1d8a..6b1de9974 100644 --- a/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/MemberQueryService.kt @@ -55,12 +55,19 @@ class MemberQueryService( ) /** - * 멤버가 로그인할 수 있는 수단(소셜 제공자 + 이메일/비밀번호)을 조회함. + * 멤버가 로그인할 수 있는 수단(소셜 제공자 + 이메일/비밀번호)과 수단별 이메일을 조회함. */ - private fun getLoginMethods(memberId: MemberId): List { - val oAuthProviders = memberOAuthService.findProvidersByMemberId(memberId).map { it.name } - val hasCredential = memberCredentialPersistencePort.findByMemberId(memberId) != null - return if (hasCredential) oAuthProviders + EMAIL_LOGIN_METHOD else oAuthProviders + private fun getLoginMethods(memberId: MemberId): List { + val oAuthMethods = + memberOAuthService + .findAllByMemberId(memberId) + .map { MemberDetailsResponse.LoginMethod(type = it.provider.name, email = it.email) } + .distinct() + val credentialMethod = + memberCredentialPersistencePort + .findByMemberId(memberId) + ?.let { MemberDetailsResponse.LoginMethod(type = EMAIL_LOGIN_METHOD, email = it.email) } + return oAuthMethods + listOfNotNull(credentialMethod) } /** diff --git a/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt b/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt index b4151c3b0..dbfbfe6ec 100644 --- a/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt +++ b/application/src/main/kotlin/core/application/member/presentation/response/MemberDetailsResponse.kt @@ -51,26 +51,37 @@ data class MemberDetailsResponse( ) val status: String, @field:ArraySchema( - schema = - Schema( - description = "로그인 수단 (소셜 제공자 및 이메일/비밀번호)", - allowableValues = ["KAKAO", "APPLE", "EMAIL"], - ), arraySchema = Schema( description = "연동된 로그인 수단 목록", - example = "[\"KAKAO\", \"EMAIL\"]", requiredMode = Schema.RequiredMode.REQUIRED, ), ) - val loginMethods: List, + val loginMethods: List, ) { + data class LoginMethod( + @field:Schema( + description = "로그인 수단 (소셜 제공자 및 이메일/비밀번호)", + example = "KAKAO", + allowableValues = ["KAKAO", "APPLE", "EMAIL"], + requiredMode = Schema.RequiredMode.REQUIRED, + ) + val type: String, + @field:Schema( + description = "해당 로그인 수단의 이메일. 이메일 저장 이전에 연동되어 아직 재로그인하지 않은 경우 null", + example = "depromeetcore@gmail.com", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + nullable = true, + ) + val email: String?, + ) + companion object { fun of( member: Member, isAdmin: Boolean, teamNumber: TeamNumber, - loginMethods: List, + loginMethods: List, ): MemberDetailsResponse = MemberDetailsResponse( email = member.signupEmail,