diff --git a/docs/erd.md b/docs/erd.md index f20443a..51cd474 100644 --- a/docs/erd.md +++ b/docs/erd.md @@ -3,7 +3,7 @@ | 항목 | 내용 | | --- | --- | | 버전 | v0.8 (2026-11-01, PRO 결제 기능 제거·정렬 고정 반영) | -| v0.7 대비 | `users.plan_override` 삭제(플랜 구분 없음). `users.template_sort` 삭제(내 템플릿 정렬을 생성 순으로 고정). 템플릿 한도 50개·AI 하루 5회로 설계 메모 수정, 결제·구독 관련 "나중에 추가할 것" 삭제. 컬럼 삭제는 새 마이그레이션으로 반영 (아래 "마이그레이션" 참고) | +| v0.7 대비 | `users.plan_override` 삭제(플랜 구분 없음). `users.template_sort` 삭제(내 템플릿 정렬을 생성 순으로 고정). 템플릿 한도 50개·AI 하루 5회로 설계 메모 수정, 결제·구독 관련 "나중에 추가할 것" 삭제. `social_accounts.provider_user_id`를 `VARCHAR(255)`·`ascii_bin`으로 변경(구글 `sub` 규격). 모두 새 마이그레이션으로 반영 (아래 "마이그레이션" 참고) | | v0.6 → v0.7 | 설명만 보강: `users.profile_image_url`(500자 초과 시 저장 안 함), `users.last_login_at`(가입도 로그인으로 기록), `refresh_tokens.revoked_at`(재사용 감지 시 계보 폐기), 설계 메모 "가입"(인가 코드 방식). 테이블·컬럼·인덱스 변경 없음 | | v0.5 → v0.6 | 같은 갤러리 템플릿 여러 번 저장 허용 → `source_gallery_template_id`는 출처 기록·집계용으로만 사용, 인덱스를 `(source_gallery_template_id)` 하나로 변경. 테이블·컬럼 변경 없음 | | v0.4 → v0.5 | `user_terms_agreements` 추가(약관 버전별 동의 기록), `users.terms_agreed_at` 삭제 | @@ -128,7 +128,7 @@ erDiagram | `id` | BIGINT | N | | PK | ID | | `user_id` | BIGINT | N | | FK | 회원 ID | | `provider` | VARCHAR(20) | N | | | GOOGLE | -| `provider_user_id` | VARCHAR(100) | N | | | 구글 계정 고유값(ID 토큰의 sub) | +| `provider_user_id` | VARCHAR(255) | N | | | 구글 계정 고유값(ID 토큰의 sub). 대소문자를 구분하는 ASCII라 `ascii_bin` | | `created_at` | DATETIME(6) | N | | | 연결 시각 | - 유니크 `uk_social_provider_user` (provider, provider_user_id) @@ -288,6 +288,15 @@ ALTER TABLE users - 개발용 시드(`R__dev_seed_users.sql`)에서 두 컬럼을 넣는 부분이 있으면 그것도 같이 지워야 합니다. 플라이웨이는 버전 마이그레이션(컬럼 삭제)을 모두 끝낸 뒤 반복 실행 시드를 돌리므로, 시드를 그대로 두면 새로 만드는 DB(테스트컨테이너 포함)에서 실패합니다. 같은 PR에서 함께 고칩니다. +구글 `sub`는 대소문자를 구분하는 ASCII 최대 255자입니다. 초기 마이그레이션의 `VARCHAR(100)`과 기본 정렬 규칙(대소문자 구분 안 함)으로는 맞지 않아 새 파일로 바꿉니다. + +```sql +-- V202610311700__widen_social_provider_user_id.sql +ALTER TABLE social_accounts + MODIFY provider_user_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL + COMMENT '구글 계정 고유값(ID 토큰의 sub, 대소문자 구분)'; +``` + ## 부록. DDL (MySQL 8.4) ```sql @@ -313,7 +322,7 @@ CREATE TABLE social_accounts ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID', user_id BIGINT NOT NULL COMMENT '회원 ID', provider VARCHAR(20) NOT NULL COMMENT 'GOOGLE', - provider_user_id VARCHAR(100) NOT NULL COMMENT '구글 계정 고유값(ID 토큰의 sub)', + provider_user_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '구글 계정 고유값(ID 토큰의 sub, 대소문자 구분)', created_at DATETIME(6) NOT NULL COMMENT '연결 시각', PRIMARY KEY (id), UNIQUE KEY uk_social_provider_user (provider, provider_user_id), diff --git a/src/main/java/com/sniptext/domain/auth/entity/SocialAccount.java b/src/main/java/com/sniptext/domain/auth/entity/SocialAccount.java new file mode 100644 index 0000000..f6620f1 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/entity/SocialAccount.java @@ -0,0 +1,73 @@ +package com.sniptext.domain.auth.entity; + +import com.sniptext.global.entity.BaseCreatedTimeEntity; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원과 소셜 계정의 연결. 로그인할 때 (제공자, 구글 계정 고유값)으로 가입된 회원을 찾는다. + * 같은 소셜 계정은 한 번만 연결된다 (uk_social_provider_user). 회원은 user 도메인이라 ID로만 들고 있다. + */ +@Getter +@Entity +@Table(name = "social_accounts") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SocialAccount extends BaseCreatedTimeEntity { + + /** 구글 sub는 대소문자를 구분하는 ASCII 최대 255자다. DB 컬럼도 ascii_bin이라 글자 그대로 비교한다 */ + public static final int PROVIDER_USER_ID_MAX_LENGTH = 255; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 20) + private SocialProvider provider; + + /** 구글 ID 토큰의 sub. 이메일은 바뀔 수 있어 이 값으로 계정을 구분한다 */ + @Column(name = "provider_user_id", nullable = false, length = PROVIDER_USER_ID_MAX_LENGTH) + private String providerUserId; + + @Builder(access = AccessLevel.PRIVATE) + private SocialAccount(Long userId, SocialProvider provider, String providerUserId) { + this.userId = userId; + this.provider = provider; + this.providerUserId = providerUserId; + } + + /** 가입 완료 때 만든다. 여기서 실패하면 가입 처리의 버그라 IllegalArgumentException(500)이다. */ + public static SocialAccount create(Long userId, SocialProvider provider, String providerUserId) { + require(userId != null, "회원 ID가 없다"); + require(provider != null, "제공자가 없다"); + require(providerUserId != null && !providerUserId.isBlank(), "소셜 계정 고유값이 없다"); + require(providerUserId.length() <= PROVIDER_USER_ID_MAX_LENGTH, "소셜 계정 고유값이 255자를 넘는다"); + require(providerUserId.chars().allMatch(c -> c < 128), "소셜 계정 고유값이 ASCII가 아니다"); + return SocialAccount.builder() + .userId(userId) + .provider(provider) + .providerUserId(providerUserId) + .build(); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException("소셜 계정 생성 규칙 위반: " + message); + } + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/entity/SocialProvider.java b/src/main/java/com/sniptext/domain/auth/entity/SocialProvider.java new file mode 100644 index 0000000..cebe8fa --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/entity/SocialProvider.java @@ -0,0 +1,8 @@ +package com.sniptext.domain.auth.entity; + +/** 소셜 로그인 제공자. 상수 이름이 social_accounts.provider와 로그인 요청의 provider 값이 된다 (S1은 구글만). */ +public enum SocialProvider { + + GOOGLE + +} diff --git a/src/main/java/com/sniptext/domain/auth/repository/SocialAccountRepository.java b/src/main/java/com/sniptext/domain/auth/repository/SocialAccountRepository.java new file mode 100644 index 0000000..692c97f --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/repository/SocialAccountRepository.java @@ -0,0 +1,14 @@ +package com.sniptext.domain.auth.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sniptext.domain.auth.entity.SocialAccount; +import com.sniptext.domain.auth.entity.SocialProvider; + +public interface SocialAccountRepository extends JpaRepository { + + Optional findByProviderAndProviderUserId(SocialProvider provider, String providerUserId); + +} diff --git a/src/main/resources/db/migration/V202610311700__widen_social_provider_user_id.sql b/src/main/resources/db/migration/V202610311700__widen_social_provider_user_id.sql new file mode 100644 index 0000000..414315e --- /dev/null +++ b/src/main/resources/db/migration/V202610311700__widen_social_provider_user_id.sql @@ -0,0 +1,6 @@ +-- 구글 sub는 대소문자를 구분하는 ASCII 최대 255자다 (OpenID Connect 문서). +-- 100자로는 모자라고, 기본 정렬 규칙(utf8mb4_0900_ai_ci)은 대소문자를 구분하지 않아 +-- 대소문자만 다른 두 계정을 같은 계정으로 본다. ascii_bin으로 바꿔 글자 그대로 비교한다. +ALTER TABLE social_accounts + MODIFY provider_user_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL + COMMENT '구글 계정 고유값(ID 토큰의 sub, 대소문자 구분)'; diff --git a/src/test/java/com/sniptext/domain/auth/entity/SocialAccountTest.java b/src/test/java/com/sniptext/domain/auth/entity/SocialAccountTest.java new file mode 100644 index 0000000..fcec43d --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/entity/SocialAccountTest.java @@ -0,0 +1,59 @@ +package com.sniptext.domain.auth.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class SocialAccountTest { + + @Test + @DisplayName("소셜 계정 연결을 만들면 받은 값이 그대로 들어간다") + void create() { + SocialAccount account = SocialAccount.create(1L, SocialProvider.GOOGLE, "109876543210987654321"); + + assertThat(account.getUserId()).isEqualTo(1L); + assertThat(account.getProvider()).isEqualTo(SocialProvider.GOOGLE); + assertThat(account.getProviderUserId()).isEqualTo("109876543210987654321"); + } + + @Test + @DisplayName("소셜 계정 고유값 255자는 만들 수 있고 256자는 IllegalArgumentException") + void providerUserIdLength() { + String max = "1".repeat(SocialAccount.PROVIDER_USER_ID_MAX_LENGTH); + + assertThat(SocialAccount.create(1L, SocialProvider.GOOGLE, max).getProviderUserId()).hasSize(255); + assertThatThrownBy(() -> SocialAccount.create(1L, SocialProvider.GOOGLE, max + "1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("소셜 계정 고유값에 ASCII가 아닌 글자가 있으면 IllegalArgumentException") + void nonAsciiProviderUserId() { + assertThatThrownBy(() -> SocialAccount.create(1L, SocialProvider.GOOGLE, "10987가")) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + @DisplayName("소셜 계정 고유값이 없거나 공백이면 IllegalArgumentException") + void blankProviderUserId(String providerUserId) { + assertThatThrownBy(() -> SocialAccount.create(1L, SocialProvider.GOOGLE, providerUserId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("회원 ID·제공자가 없으면 IllegalArgumentException") + void missingValues() { + assertThatThrownBy(() -> SocialAccount.create(null, SocialProvider.GOOGLE, "1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SocialAccount.create(1L, null, "1")) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/src/test/java/com/sniptext/domain/auth/repository/SocialAccountRepositoryTest.java b/src/test/java/com/sniptext/domain/auth/repository/SocialAccountRepositoryTest.java new file mode 100644 index 0000000..9ae351f --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/repository/SocialAccountRepositoryTest.java @@ -0,0 +1,83 @@ +package com.sniptext.domain.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.auth.entity.SocialAccount; +import com.sniptext.domain.auth.entity.SocialProvider; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.SocialAccountFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; + +@Transactional +class SocialAccountRepositoryTest extends IntegrationTestSupport { + + @Autowired + private SocialAccountRepository socialAccountRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("제공자와 구글 계정 고유값으로 연결된 회원을 찾는다") + void findByProviderAndProviderUserId() { + Long userId = userRepository.save(UserFixture.user("social@gmail.com")).getId(); + socialAccountRepository.save(SocialAccountFixture.google(userId)); + + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, + SocialAccountFixture.GOOGLE_SUB)) + .map(SocialAccount::getUserId) + .contains(userId); + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, "unknown")) + .isEmpty(); + } + + @Test + @DisplayName("255자 고유값을 저장하고 찾을 수 있다") + void maxLengthProviderUserId() { + Long userId = userRepository.save(UserFixture.user("long@gmail.com")).getId(); + String longSub = "a".repeat(SocialAccount.PROVIDER_USER_ID_MAX_LENGTH); + socialAccountRepository.saveAndFlush(SocialAccountFixture.google(userId, longSub)); + + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, longSub)) + .map(SocialAccount::getUserId) + .contains(userId); + } + + @Test + @DisplayName("고유값은 대소문자를 구분한다. 대소문자만 다른 두 계정은 따로 연결되고 각각 찾아진다") + void caseSensitiveProviderUserId() { + Long lower = userRepository.save(UserFixture.user("lower@gmail.com")).getId(); + Long upper = userRepository.save(UserFixture.user("upper@gmail.com")).getId(); + socialAccountRepository.saveAndFlush(SocialAccountFixture.google(lower, "abcDEF123")); + socialAccountRepository.saveAndFlush(SocialAccountFixture.google(upper, "ABCdef123")); + + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, "abcDEF123")) + .map(SocialAccount::getUserId) + .contains(lower); + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, "ABCdef123")) + .map(SocialAccount::getUserId) + .contains(upper); + assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.GOOGLE, "abcdef123")) + .isEmpty(); + } + + @Test + @DisplayName("같은 소셜 계정을 두 번 연결하면 DB 유니크 위반이다 (동시 가입 방지)") + void uniqueProviderUser() { + Long first = userRepository.save(UserFixture.user("first@gmail.com")).getId(); + Long second = userRepository.save(UserFixture.user("second@gmail.com")).getId(); + socialAccountRepository.save(SocialAccountFixture.google(first)); + + assertThatThrownBy(() -> socialAccountRepository.saveAndFlush(SocialAccountFixture.google(second))) + .isInstanceOf(DataIntegrityViolationException.class); + } + +} diff --git a/src/test/java/com/sniptext/fixture/SocialAccountFixture.java b/src/test/java/com/sniptext/fixture/SocialAccountFixture.java new file mode 100644 index 0000000..ab5d0f3 --- /dev/null +++ b/src/test/java/com/sniptext/fixture/SocialAccountFixture.java @@ -0,0 +1,22 @@ +package com.sniptext.fixture; + +import com.sniptext.domain.auth.entity.SocialAccount; +import com.sniptext.domain.auth.entity.SocialProvider; + +/** 테스트용 소셜 계정 연결. 생성 인자가 바뀌면 여기만 고친다. */ +public final class SocialAccountFixture { + + public static final String GOOGLE_SUB = "109876543210987654321"; + + private SocialAccountFixture() { + } + + public static SocialAccount google(Long userId) { + return google(userId, GOOGLE_SUB); + } + + public static SocialAccount google(Long userId, String googleSub) { + return SocialAccount.create(userId, SocialProvider.GOOGLE, googleSub); + } + +}