Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class MemberLoginService(
?: recoverOrCreateMemberForOrphanedOAuth(authAttributes).also {
memberOAuthService.relinkMemberOAuthProvider(it, authAttributes)
}
memberOAuthService.syncEmail(authAttributes)
return handleExistingMemberLogin(member, deviceId)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -49,8 +51,25 @@ class MemberQueryService(
getMemberById(memberId),
memberAccessService.isAdmin(memberId),
getMemberTeamNumber(memberId),
getLoginMethods(memberId),
)

/**
* 멤버가 로그인할 수 있는 수단(소셜 제공자 + 이메일/비밀번호)과 수단별 이메일을 조회함.
*/
private fun getLoginMethods(memberId: MemberId): List<MemberDetailsResponse.LoginMethod> {
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)
}

/**
* 멤버의 식별자를 기반으로 멤버 객체를 조회함.
*
Expand Down Expand Up @@ -177,6 +196,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}$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ class MemberOAuthService(
fun findMemberIdsByProvider(provider: OAuthProvider): List<MemberId> =
memberOAuthPersistencePort.findMemberIdsByProvider(provider)

fun findAllByMemberId(memberId: MemberId): List<MemberOAuth> =
memberOAuthPersistencePort
.findAllByMemberId(memberId)
.sortedBy { it.provider }

/**
* 로그인 시 OAuth 제공자가 내려준 이메일로 연동 정보의 이메일을 최신화함.
*/
fun syncEmail(authAttribute: OAuthAttributes) =
memberOAuthPersistencePort.updateEmail(
provider = authAttribute.getProvider(),
externalId = authAttribute.getExternalId(),
email = authAttribute.getEmail(),
)

fun relinkMemberOAuthProvider(
member: Member,
authAttribute: OAuthAttributes,
Expand All @@ -46,6 +61,7 @@ class MemberOAuthService(
authAttribute.getExternalId(),
authAttribute.getProvider(),
member.id!!,
authAttribute.getEmail(),
),
member,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -49,12 +50,38 @@ data class MemberDetailsResponse(
requiredMode = Schema.RequiredMode.REQUIRED,
)
val status: String,
@field:ArraySchema(
arraySchema =
Schema(
description = "연동된 로그인 수단 목록",
requiredMode = Schema.RequiredMode.REQUIRED,
),
)
val loginMethods: List<LoginMethod>,
) {
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<LoginMethod>,
): MemberDetailsResponse =
MemberDetailsResponse(
email = member.signupEmail,
Expand All @@ -64,6 +91,7 @@ data class MemberDetailsResponse(
teamNumber = teamNumber,
isAdmin = isAdmin,
status = member.status.name,
loginMethods = loginMethods,
)
}
}
81 changes: 81 additions & 0 deletions db/pending/2609241927_member_oauth_email.sql
Original file line number Diff line number Diff line change
@@ -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;
-- =============================================================================
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,30 +18,34 @@ 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 {
var result = id?.hashCode() ?: 0
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() },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,13 @@ interface MemberOAuthPersistencePort {

fun findMemberIdsByProvider(provider: OAuthProvider): List<MemberId>

fun findAllByMemberId(memberId: MemberId): List<MemberOAuth>

fun updateEmail(
provider: OAuthProvider,
externalId: String,
email: String,
)

fun deleteAllByMemberId(memberId: MemberId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ 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(
id = MemberOAuthId(id),
externalId = externalId,
provider = OAuthProvider.valueOf(provider),
memberId = MemberId(member.id),
email = email,
)

companion object {
Expand All @@ -50,6 +53,7 @@ class MemberOAuthEntity(
externalId = memberOAuth.externalId,
provider = memberOAuth.provider.name,
member = MemberEntity.from(member),
email = memberOAuth.email,
)
}
}
Loading
Loading