Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ dependencies {
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'

// Flyway: migration
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

// Caffeine
implementation 'com.github.ben-manes.caffeine:caffeine'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto;
import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto;
import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider;
import com.teamEWSN.gitdeun.user.entity.Role;
import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection;
import com.teamEWSN.gitdeun.user.entity.User;
import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository;
import com.teamEWSN.gitdeun.user.repository.UserRepository;
import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User;
import com.teamEWSN.gitdeun.user.service.UserService;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,10 +22,7 @@
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;


@Slf4j
Expand All @@ -35,6 +32,7 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService {

private final GitHubApiHelper gitHubApiHelper;
private final GoogleApiHelper googleApiHelper;
private final UserService userService;
private final SocialTokenRefreshService socialTokenRefreshService;
private final UserRepository userRepository;
private final SocialConnectionRepository socialConnectionRepository;
Expand Down Expand Up @@ -63,18 +61,30 @@ public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) {
String accessToken = userRequest.getAccessToken().getTokenValue();
String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token");

/* ② 이미 연결된 계정 → 토큰 갱신 로직 추상화 */
return socialConnectionRepository.findByProviderAndProviderId(provider, providerId)
.map(conn -> {
// provider 별 refresh 정책
socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken);

// 사용자 정보 갱신
User user = conn.getUser();
user.updateProfile(dto.getName(), dto.getProfileImageUrl());
return user;
})
.orElseGet(() -> createOrConnect(dto, provider, providerId, accessToken, refreshToken));
// 1) 이미 연결된 소셜 계정이면 토큰 갱신 + 프로필 갱신
var existingConnOpt = socialConnectionRepository.findByProviderAndProviderId(provider, providerId);
if (existingConnOpt.isPresent()) {
var conn = existingConnOpt.get();
socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken);

// 프로필 갱신 + (handle 보정 포함) — upsertAndGetId가 책임
Long userId = userService.upsertAndGetId(
dto.getEmail(), dto.getName(), dto.getProfileImageUrl(), dto.getNickname()
);
return userRepository.findById(userId)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID));
}

// 2) 아직 연결 안된 계정 → upsertAndGetId로 (신규/기존) 저장/보정 먼저
Long userId = userService.upsertAndGetId(
dto.getEmail(), dto.getName(), dto.getProfileImageUrl(), dto.getNickname()
);
User user = userRepository.findById(userId)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID));

// 3) 그 다음 소셜 계정 연결만 수행
connectSocialAccount(user, provider, providerId, accessToken, refreshToken);
return user;
}

// OAuth2 공급자로부터 받은 사용자 정보를 기반으로 OAuth2ResponseDto를 생성(인스턴스 메서드)
Expand Down Expand Up @@ -118,44 +128,6 @@ private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2User
throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER);
}

/**
* 사용자가 존재하면 계정을 연결하고, 존재하지 않으면 새로 생성합니다.
*/
private User createOrConnect(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) {
// 이메일로 기존 사용자를 찾습니다.
return userRepository.findByEmailAndDeletedAtIsNull(response.getEmail())
.map(user -> {
// 사용자가 존재하면, 새 소셜 계정을 연결
user.updateProfile(response.getName(), response.getProfileImageUrl());
connectSocialAccount(user, provider, providerId, accessToken, refreshToken);
return user;
})
.orElseGet(() -> {
// 사용자가 존재하지 않으면, 새 사용자를 생성
return createNewUser(response, provider, providerId, accessToken, refreshToken);
});
}

private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) {
// provider별 다른 Nickname 처리 로직
String nickname = response.getNickname();
if (provider == OauthProvider.GOOGLE) {
nickname = nickname + "_" + UUID.randomUUID().toString().substring(0, 6);
}

User newUser = User.builder()
.email(response.getEmail())
.name(response.getName()) // GitHub의 경우 full name, Google의 경우 name
.nickname(nickname)
.profileImage(response.getProfileImageUrl())
.role(Role.USER)
.build();
User savedUser = userRepository.save(newUser);

connectSocialAccount(savedUser, provider, providerId, accessToken, refreshToken);
return savedUser;
}

private void connectSocialAccount(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) {
socialConnectionRepository.findByProviderAndProviderId(provider, providerId)
.ifPresent(connection -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
Expand Down Expand Up @@ -108,7 +109,7 @@ public Map<String, Object> fetchLatestUserInfo(String accessToken) {
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(Map.class)
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
} catch (WebClientResponseException e) {
log.error("Google userinfo 조회 실패: {}", e.getResponseBodyAsString());
Expand Down
102 changes: 102 additions & 0 deletions src/main/java/com/teamEWSN/gitdeun/common/util/HandleGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.teamEWSN.gitdeun.common.util;

import com.teamEWSN.gitdeun.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;

import java.nio.ByteBuffer;
import java.util.Base64;
import java.util.concurrent.ThreadLocalRandom;

@Component
@RequiredArgsConstructor
public class HandleGenerator {
private final UserRepository userRepository;

private static final int MAX_LEN = 30;
private static final int MAX_SEQ = 999;
private static final int MAX_RETRY = 3;

/**
* base를 받아 전역 유니크 핸들을 생성합니다.
* @param base 후보 문자열 (null 가능)
* @param selfId 본인 사용자 ID (신규 생성 시 null)
*/
public String generateUniqueHandle(String base, Long selfId) {
String normalized = sanitize(base);
String candidate = normalized;
int seq = 0;

while (conflicts(candidate, selfId)) {
seq++;
if (seq <= MAX_SEQ) {
candidate = appendSeq(normalized, seq);
} else {
candidate = appendRandom(normalized);
// 랜덤도 충돌 가능하므로 계속 검사
}
}
return candidate;
}

/**
* UNIQUE 위반 발생 시 재시도하는 안전 래퍼
*/
public <T> T withUniqueRetry(HandleSetter<T> action, String base, Long selfId) {
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
String handle = generateUniqueHandle(base, selfId);
try {
return action.apply(handle);
} catch (DataIntegrityViolationException e) {
if (attempt == MAX_RETRY - 1) {
throw new IllegalStateException(
"Failed to generate unique handle after " + MAX_RETRY + " attempts", e
);
}
}
}
throw new IllegalStateException("Should not reach here");
}

private boolean conflicts(String handle, Long selfId) {
return (selfId == null)
? userRepository.existsByHandle(handle)
: userRepository.existsByHandleAndIdNot(handle, selfId);
}

private String sanitize(String raw) {
String s = (raw == null ? "" : raw).trim().toLowerCase();
s = s.replaceAll("\\s+", "_");
s = s.replaceAll("[^a-z0-9_.-]", "");
s = s.replaceAll("_+", "_");
if (s.isBlank()) s = "user";
return s.length() > MAX_LEN ? s.substring(0, MAX_LEN) : s;
}

private String appendSeq(String base, int seq) {
String suffix = "_" + String.format("%03d", seq);
int room = MAX_LEN - suffix.length();
String head = base.length() > room ? base.substring(0, room) : base;
return head + suffix;
}

private String appendRandom(String base) {
String rand = random6();
String suffix = "_" + rand;
int room = MAX_LEN - suffix.length();
String head = base.length() > room ? base.substring(0, room) : base;
return head + suffix;
}

private String random6() {
long random = ThreadLocalRandom.current().nextLong() & 0xFFFFFFFFL;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(ByteBuffer.allocate(4).putInt((int)random).array());
}

@FunctionalInterface
public interface HandleSetter<T> {
T apply(String uniqueHandle);
}
}
39 changes: 30 additions & 9 deletions src/main/java/com/teamEWSN/gitdeun/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ public class User extends AuditedEntity {
@Column(length = 100)
private String name;

@Column(nullable = false, length = 100, unique = true)
@Column(nullable = false, length = 100)
private String nickname;

@Column(nullable = false, length = 256)
@Column(nullable = false, length = 50, unique = true)
private String handle; // @menton / URL 용 전역 유니크 핸들

@Column(nullable = false, length = 256, unique = true)
private String email;

@Column(name="profile_image", length = 512)
Expand All @@ -49,29 +52,47 @@ public class User extends AuditedEntity {


@Builder
public User(String name, String nickname, String email, String profileImage, Role role) {
public User(String name, String nickname, String handle, String email, String profileImage, Role role) {
this.name = name;
this.nickname = nickname;
this.handle = handle;
this.email = email;
this.profileImage = profileImage;
this.role = role;
}

public User updateProfile(String name, String profileImage) {
public void updateProfile(String name, String nickname, String profileImage) {
this.name = name;
this.nickname = nickname;
this.profileImage = profileImage;
return this; // 메소드 체이닝을 위해 this 반환
}

// 회원 탈퇴 처리
public void markAsDeleted() {
this.deletedAt = LocalDateTime.now();
}

// 회원 닉네임 변경
public void updateNickname(String newNickname) {
this.nickname = newNickname;
}

/**
* handle이 아직 비어 있는(=최초 보정이 필요한) 계정에서만 1회 설정을 허용합니다.
* 이미 handle이 존재한다면 예외를 던져 서비스 내 불변성을 유지합니다.
*/
public void setHandle(String newHandle) {
if (this.handle != null && !this.handle.isBlank()) {
throw new IllegalStateException("Handle is already set and cannot be changed.");
}
if (newHandle == null || newHandle.isBlank()) {
throw new IllegalArgumentException("Handle cannot be null or blank.");
}
// 길이·문자 정책은 HandleGenerator에서 보장하지만, 방어적으로 한 번 더 체크해도 됩니다.
if (newHandle.length() > 50) {
throw new IllegalArgumentException("Handle exceeds maximum length of 50 characters.");
}
this.handle = newHandle;
}

/** 편의 메서드: handle 존재 여부 */
public boolean hasHandle() {
return this.handle != null && !this.handle.isBlank();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ public interface UserRepository extends JpaRepository<User, Long> {

// user email로 검색
Optional<User> findByEmailAndDeletedAtIsNull(String email);

// 신규 생성/백필 시 충돌 검사
boolean existsByHandle(String handle);
// 수정·보정 시 “본인 제외” 충돌 검사
boolean existsByHandleAndIdNot(String handle, Long id);
}
Loading