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
15 changes: 12 additions & 3 deletions docs/erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 삭제 |
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
73 changes: 73 additions & 0 deletions src/main/java/com/sniptext/domain/auth/entity/SocialAccount.java
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sniptext.domain.auth.entity;

/** 소셜 로그인 제공자. 상수 이름이 social_accounts.provider와 로그인 요청의 provider 값이 된다 (S1은 구글만). */
public enum SocialProvider {

GOOGLE

}
Original file line number Diff line number Diff line change
@@ -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<SocialAccount, Long> {

Optional<SocialAccount> findByProviderAndProviderUserId(SocialProvider provider, String providerUserId);

}
Original file line number Diff line number Diff line change
@@ -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, 대소문자 구분)';
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
22 changes: 22 additions & 0 deletions src/test/java/com/sniptext/fixture/SocialAccountFixture.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading