Skip to content
Merged
125 changes: 125 additions & 0 deletions src/main/java/com/mr/domain/auth/entity/SocialAuth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.mr.domain.auth.entity;

import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.domain.auth.entity.enums.SocialType;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseCreatedEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
// TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가
@Table(
name = "social_auth",
uniqueConstraints = {
@UniqueConstraint(name = "uk_social_auth_type_id", columnNames = {"social_type", "social_id"}),
@UniqueConstraint(name = "uk_social_auth_user_type", columnNames = {"user_id", "social_type"})
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SocialAuth extends BaseCreatedEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "social_auth_id")
private Long id;

// TODO: User 엔티티 연관관계 연결 예정
Comment thread
kimyw1018 marked this conversation as resolved.
@Column(name = "user_id", nullable = false)
private Long userId;

@Enumerated(EnumType.STRING)
@Column(name = "social_type", nullable = false, length = 20)
private SocialType socialType;

@Column(name = "social_id", nullable = false, length = 100)
private String socialId;

@Column(name = "refresh_token", length = 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

길이 제한이 1000으로 되어 있는데, 혹시 TEXT 타입을 쓰는 방법도 가능한가요? 제가 토큰의 길이가 어느 정도 인지 잘 몰라서 여쭈어봅니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostgreSQL에서는 TEXT 타입을 사용해도 괜찮습니다! TEXTVARCHAR(n) 사이에 성능상 큰 차이는 없어서, refresh token의 최대 길이가 명확하지 않다면 TEXT로 두는 것도 안전한 선택일 것 같습니다. 반대로 현재 사용하는 토큰이 1,000자를 넘지 않는 것이 보장되고, DB 차원에서 길이 제한을 명시하고 싶다면 지금처럼 VARCHAR(1000)을 유지해도 괜찮을 것 같습니다!

사용 중인 OAuth 제공자의 안내는 다음과 같습니다.

  • 카카오는 access token과 refresh token의 길이가 상황에 따라 달라질 수 있으므로 length 100 이상을 권장하고 있습니다.
  • Google은 refresh token의 최대 크기를 512바이트로 안내하고 있으며, 해당 한도 내에서 토큰 크기가 달라질 수 있다고 명시하고 있습니다.

현재 카카오와 Google 토큰을 저장하는 용도라면 VARCHAR(1000)으로도 충분해 보이지만, 특별히 길이를 제한해야 하는 요구사항이 없다면 TEXT로 두어 향후 토큰 형식 변경에 유연하게 대응하는 것도 괜찮겠네용

[참고한 공식 답변/문서]

private String refreshToken;

@Column(name = "refresh_token_hash", length = 64, unique = true)
Comment thread
kimyw1018 marked this conversation as resolved.
private String refreshTokenHash;

@Column(name = "expired_at")
private LocalDateTime expiredAt;

@Column(name = "device_info", length = 255)
private String deviceInfo;

@Builder(access = AccessLevel.PRIVATE)
private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken,
String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) {
// 컴파일 에러 수정: 실제 정의된 validateUserAccount 메서드로 매핑
validateUserAccount(userId);
validateUserAccount(socialType);
validateUserAccount(socialId);

this.userId = userId;
this.socialType = socialType;
this.socialId = socialId;
this.refreshToken = refreshToken;
this.refreshTokenHash = refreshTokenHash;
this.expiredAt = expiredAt;
this.deviceInfo = deviceInfo;
}

public static SocialAuth create(Long userId, SocialType socialType, String socialId,
String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) {

validateTokenValue(encryptedToken);
validateTokenValue(tokenHash);
validateExpiryTime(expiredAt);

return SocialAuth.builder()
.userId(userId)
.socialType(socialType)
.socialId(socialId)
.refreshToken(encryptedToken)
.refreshTokenHash(tokenHash)
.expiredAt(expiredAt)
.deviceInfo(deviceInfo)
.build();
}

private static void validateUserAccount(Object value) {
if (value == null || (value instanceof String && ((String) value).trim().isEmpty())) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
}

private static void validateTokenValue(String token) {
if (token == null || token.trim().isEmpty()) {
throw new GeneralException(AuthErrorStatus.TOKEN_MISSING);
}
}

private static void validateExpiryTime(LocalDateTime expiredAt) {
if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul")))) {
throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY);
}
}

public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) {
validateTokenValue(encryptedToken);
validateTokenValue(tokenHash);
validateExpiryTime(newExpiredAt);

this.refreshToken = encryptedToken;
this.refreshTokenHash = tokenHash;
this.expiredAt = newExpiredAt;
this.deviceInfo = deviceInfo;
}

// 최신 토큰 만료 및 폐기 처리
public void expireToken() {
this.refreshToken = null;
this.refreshTokenHash = null;
this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // null 대신 현재 시각 기록
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/mr/domain/auth/entity/enums/SocialType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.mr.domain.auth.entity.enums;

public enum SocialType {
KAKAO,
GOOGLE
}
31 changes: 31 additions & 0 deletions src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.mr.domain.auth.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum AuthErrorStatus implements BaseCode {

// 입력 오류
INVALID_AUTH_REQUEST(HttpStatus.BAD_REQUEST, "AUTH_400_01", "인증 관련 필수 입력값이 올바르지 않거나 누락되었습니다."),
TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_02", "토큰 값이 필요합니다."),
INVALID_TOKEN_EXPIRY(HttpStatus.BAD_REQUEST, "AUTH_400_03", "만료 시간은 현재 시간 이후여야 합니다."),

// 인증 실패 및 토큰 오류
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_02", "만료된 토큰입니다."),
REVOKED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_03", "명시적으로 폐기 및 로그아웃 처리된 토큰입니다."),

// 리소스 부재
SOCIAL_AUTH_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH_404_01", "해당 사용자의 소셜 인증 기록을 찾을 수 없습니다."),

// 데이터 무결성ㅇ
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에 연동되어 있는 소셜 계정입니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
109 changes: 109 additions & 0 deletions src/main/java/com/mr/domain/user/entity/UsageLimit.java
Comment thread
kimyw1018 marked this conversation as resolved.
Comment thread
kimyw1018 marked this conversation as resolved.
Comment thread
kimyw1018 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.mr.domain.user.entity;

import com.mr.domain.user.exception.UserUsageErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseCreatedEntity;
import jakarta.persistence.*;
Comment thread
ownue marked this conversation as resolved.
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(
name = "usage_limit",
Comment thread
kimyw1018 marked this conversation as resolved.
uniqueConstraints = {
@UniqueConstraint(name = "uk_usage_limit_user_date", columnNames = {"user_id", "limit_date"})
})
Comment thread
kimyw1018 marked this conversation as resolved.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UsageLimit extends BaseCreatedEntity {

private static final int DEFAULT_MAX_FREE_COUNT = 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후에 횟수가 변경될 가능성도 고려하면 좋을 것 같습니다!


@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "usage_limit_id")
private Long id;

// TODO: User 엔티티 연관관계 연결 예정
@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "limit_date", nullable = false)
private LocalDate limitDate;

@Column(name = "remaining_count", nullable = false)
private Integer remainingCount;

@Column(name = "max_count", nullable = false)
private Integer maxCount;

//낙관적 락 버전
@Version
private Long version;

@Builder(access = AccessLevel.PRIVATE)
private UsageLimit(Long userId, LocalDate limitDate, Integer remainingCount, Integer maxCount) {
validateRequired(userId);
validateRequired(limitDate);
validatePositiveOrZero(remainingCount);
validatePositiveOrZero(maxCount);
validateCountRange(remainingCount, maxCount);

this.userId = userId;
this.limitDate = limitDate;
this.remainingCount = remainingCount;
this.maxCount = maxCount;
}

public static UsageLimit createDefault(Long userId, LocalDate limitDate) {
return UsageLimit.builder()
.userId(userId)
.limitDate(limitDate)
.maxCount(DEFAULT_MAX_FREE_COUNT)
.remainingCount(DEFAULT_MAX_FREE_COUNT)
.build();
}

private static void validateRequired(Object value) {
if (value == null) {
throw new GeneralException(UserUsageErrorStatus.REQUIRED_FIELD_MISSING);
}
}
private static void validatePositiveOrZero(Integer value) {
if (value == null || value < 0) {
throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT_RANGE);
}
}
private static void validateCountRange(Integer remainingCount, Integer maxCount) {
if (remainingCount != null && maxCount != null && remainingCount > maxCount) {
throw new GeneralException(UserUsageErrorStatus.EXCEEDED_MAX_COUNT);
}
}

// 분석 요청 시 카운트 로직
public void consume() {
Comment thread
kimyw1018 marked this conversation as resolved.
if (this.remainingCount <= 0) {
throw new GeneralException(UserUsageErrorStatus.USAGE_LIMIT_EXCEEDED);
}
this.remainingCount--;
}

// 관리자 기능 - 잔여 횟수 수동 조절
public void updateRemainingCount(Integer newCount) {
validatePositiveOrZero(newCount);
validateCountRange(newCount, this.maxCount);
this.remainingCount = newCount;
}
public void updateMaxCount(Integer newMaxCount) {
validatePositiveOrZero(newMaxCount);
this.maxCount = newMaxCount;

// 상한선이 깎여서 현재 잔여량이 상한보다 커진 경우 정정 보정 로직 포함
if (this.remainingCount > this.maxCount) {
this.remainingCount = this.maxCount;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.mr.domain.user.exception;

import com.mr.global.apipayload.code.BaseCode;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UserUsageErrorStatus implements BaseCode {

// 잔여 횟수 소진 에러
USAGE_LIMIT_EXCEEDED(HttpStatus.FORBIDDEN, "USAGE_403_01", "일일 잔여 분석 횟수를 모두 소진하였습니다."),

// 유효하지 않은 카운트 수정 요청
REQUIRED_FIELD_MISSING(HttpStatus.BAD_REQUEST, "USAGE_400_01", "이용 제한 설정에 필요한 필수 필드가 누락되었습니다."),
INVALID_USAGE_COUNT_RANGE(HttpStatus.BAD_REQUEST, "USAGE_400_02", "잔여 횟수 및 최대 제한 횟수는 음수일 수 없습니다."),
EXCEEDED_MAX_COUNT(HttpStatus.BAD_REQUEST, "USAGE_400_03", "잔여 횟수는 설정된 최대 제한 횟수를 초과할 수 없습니다."),

// 해당 날짜의 이용 제한 데이터가 존재하지 않을 때
USAGE_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "USAGE_404_01", "해당 날짜의 이용 제한 기록을 찾을 수 없습니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
20 changes: 0 additions & 20 deletions src/main/java/com/mr/global/apipayload/domain/AuthErrorStatus.java

This file was deleted.

42 changes: 42 additions & 0 deletions src/main/resources/application.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ================================================================= #
# 🔒 [NOTICE] 본 파일은 로컬 환경 세팅용 '공통 예시 파일'입니다.
# 각자 로컬 PC 사양에 맞게 정보를 입력한 후, 파일 이름을
# 'application.yml'로 변경하여 동일한 경로에 위치시켜 주세요!
# ================================================================= #

spring:
# 1. 로컬 데이터베이스 커넥션 설정 (PostgreSQL 18)
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/mr_db # [체크] 로컬에 mr_db 빈 데이터베이스를 먼저 생성
username: postgres # [입력] PostgreSQL 계정명
password: "" # [입력] PostgreSQL 비밀번호 (빈값 입력 가능)

# 2. JPA 및 하이버네이트 구동 설정
jpa:
hibernate:
ddl-auto: update # 엔티티 매핑 정보 변경 시 DB 테이블 자동 반영
show-sql: true # 콘솔에 실행 SQL 포맷 출력
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect

# 3. Spring Security 및 JWT 인증 설정
jwt:
# [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요.
# 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes"
secret: ""
access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분)
refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일)

# 4. 외부 소셜 로그인 API 연동 정보 (OAuth용)
oauth:
kakao:
client-id: "" # [입력] 카카오 디벨로퍼스 REST API 키
client-secret: "" # [입력] 카카오 보안 Client Secret 키
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
google:
client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID
client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호
redirect-uri: http://localhost:8080/login/oauth2/code/google
Loading