From a3a691e1d260f5a29301c195f26f59af16688eb4 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 19 Oct 2025 01:54:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=ED=95=B8=EB=93=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../service/CustomOAuth2UserService.java | 82 +++++--------- .../common/oauth/service/GoogleApiHelper.java | 3 +- .../gitdeun/common/util/HandleGenerator.java | 102 ++++++++++++++++++ .../teamEWSN/gitdeun/user/entity/User.java | 39 +++++-- .../user/repository/UserRepository.java | 5 + .../gitdeun/user/service/UserService.java | 69 ++++++++---- src/main/resources/application.yml | 4 +- 8 files changed, 224 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/util/HandleGenerator.java diff --git a/build.gradle b/build.gradle index e7e9fc7..1caa6a6 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index 3ebbd91..775edf1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -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; @@ -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 @@ -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; @@ -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를 생성(인스턴스 메서드) @@ -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 -> { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index d7dcfd4..72028b3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -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; @@ -108,7 +109,7 @@ public Map fetchLatestUserInfo(String accessToken) { .uri("https://www.googleapis.com/oauth2/v2/userinfo") .header("Authorization", "Bearer " + accessToken) .retrieve() - .bodyToMono(Map.class) + .bodyToMono(new ParameterizedTypeReference>() {}) .block(); } catch (WebClientResponseException e) { log.error("Google userinfo 조회 실패: {}", e.getResponseBodyAsString()); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/HandleGenerator.java b/src/main/java/com/teamEWSN/gitdeun/common/util/HandleGenerator.java new file mode 100644 index 0000000..58b389b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/HandleGenerator.java @@ -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 withUniqueRetry(HandleSetter 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 apply(String uniqueHandle); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 5811d2d..eb61035 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -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) @@ -49,18 +52,19 @@ 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 반환 } // 회원 탈퇴 처리 @@ -68,10 +72,27 @@ 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(); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index 6e5b186..b4ed0ab 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -18,4 +18,9 @@ public interface UserRepository extends JpaRepository { // user email로 검색 Optional findByEmailAndDeletedAtIsNull(String email); + + // 신규 생성/백필 시 충돌 검사 + boolean existsByHandle(String handle); + // 수정·보정 시 “본인 제외” 충돌 검사 + boolean existsByHandleAndIdNot(String handle, Long id); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 133620d..50ad43b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -7,6 +7,7 @@ import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.util.HandleGenerator; import com.teamEWSN.gitdeun.user.dto.UserResponseDto; import com.teamEWSN.gitdeun.user.entity.Role; import com.teamEWSN.gitdeun.user.entity.User; @@ -27,6 +28,7 @@ public class UserService { private final RefreshTokenService refreshTokenService; private final BlacklistService blacklistService; private final UserRepository userRepository; + private final HandleGenerator handleGenerator; private final GoogleApiHelper googleApiHelper; private final GitHubApiHelper gitHubApiHelper; @@ -77,16 +79,6 @@ public void deleteUser(Long userId, String accessToken, String refreshToken) { // 깃든 서비스 DB에서 soft-delete 처리 user.markAsDeleted(); - userRepository.save(user); - log.info("User {} has been marked as deleted.", userId); - } - - // 이메일로 회원 검색 - @Transactional(readOnly = true) - public User findUserByEmail(String email) { - return userRepository.findByEmailAndDeletedAtIsNull(email) - .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); - } // 아이디로 회원 검색 @@ -99,14 +91,55 @@ public User findById(Long id) { @Transactional public Long upsertAndGetId(String email, String name, String picture, String nickname) { + // base 우선순위: nickname → name → email local → "user" + final String base = resolveHandleBase(nickname, name, email); + + // 이메일로 기존 사용자 조회 return userRepository.findByEmailAndDeletedAtIsNull(email) - .map(u -> u.updateProfile(name, picture)) // 이미 있으면 갱신 - .orElseGet(() -> userRepository.save( - User.builder() - .email(email).name(name).profileImage(picture) - .nickname(nickname) - .role(Role.USER) - .build())) - .getId(); + .map(u -> { + // 기존 사용자: 프로필만 갱신 + u.updateProfile(name, nickname, picture); + + // 3) 핸들이 없으면 지금 로그인에서 1회 보정 생성 + if (!u.hasHandle()) { + return handleGenerator.withUniqueRetry(unique -> { + u.setHandle(unique); + return userRepository.save(u); + }, base, u.getId()).getId(); + } + + // 핸들이 이미 있으면 일반 저장(프로필 갱신 반영) + return userRepository.save(u).getId(); + }) + .orElseGet(() -> { + // 신규 사용자: withUniqueRetry로 유니크 핸들 확보 후 저장 + User saved = handleGenerator.withUniqueRetry(unique -> { + User newUser = User.builder() + .email(email) + .name(name) + .profileImage(picture) + // 화면용 표시 닉네임은 비어있으면 name→base 순으로 채움 + .nickname(!isBlank(nickname) ? nickname : !isBlank(name) ? name : base) + .handle(unique) + .role(Role.USER) + .build(); + return userRepository.save(newUser); + }, base, null); // 신규이므로 selfId 없음 + return saved.getId(); + }); + } + + // ===== 내부 유틸 ===== + private String resolveHandleBase(String nickname, String name, String email) { + if (!isBlank(nickname)) return nickname; + if (!isBlank(name)) return name; + if (!isBlank(email) && email.contains("@")) { + return email.substring(0, email.indexOf('@')); + } + return "user"; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a81212..9ab9a96 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: jpa: show-sql: true # SQL 로그 출력 hibernate: - ddl-auto: update # create # update + ddl-auto: validate # create # update properties: hibernate: format_sql: true # SQL 로그를 보기 좋게 포맷 @@ -26,6 +26,8 @@ spring: jdbc: batch_size: 1000 time_zone: UTC + flyway: + baseline-on-migrate: true # 기존 스키마가 있을 때 베이스라인 설정 security: oauth2: client: