From 6e40de7d9767a5d211851e53c4dd0d4273c9c0fa Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Sat, 11 Jul 2026 22:20:33 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20social=5Fauth,=20usage=5Flimits=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=84=A0=EC=96=B8=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 103 ++++++++++++++++++ .../auth/entity/enums}/AuthErrorStatus.java | 7 +- .../domain/auth/entity/enums/SocialType.java | 6 + .../com/mr/domain/user/entity/UsageLimit.java | 93 ++++++++++++++++ .../entity/enums/UserUsageErrorStatus.java | 24 ++++ 5 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/mr/domain/auth/entity/SocialAuth.java rename src/main/java/com/mr/{global/apipayload/domain => domain/auth/entity/enums}/AuthErrorStatus.java (59%) create mode 100644 src/main/java/com/mr/domain/auth/entity/enums/SocialType.java create mode 100644 src/main/java/com/mr/domain/user/entity/UsageLimit.java create mode 100644 src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java new file mode 100644 index 00000000..a1bab9fa --- /dev/null +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -0,0 +1,103 @@ +package com.mr.domain.auth.entity; + +import com.mr.domain.auth.entity.enums.AuthErrorStatus; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.entity.BaseCreatedEntity; +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.mr.domain.user.entity.UsageLimit.validateRequired; + +@Getter +@Entity +// TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가 +@Table( + name = "social_auth", + indexes = { + @Index(name = "idx_social_auth_refresh_token", columnList = "refresh_token") + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SocialAuth extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "social_auth_id") + private Long id; + + // TODO: User 엔티티 연관관계 연결 예정 + @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 = 500) + private String refreshToken; + + @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, + LocalDateTime expiredAt, String deviceInfo) { + validateRequired(userId, "userId"); + validateRequired(socialType, "socialType"); + validateRequired(socialId, "socialId"); + + this.userId = userId; + this.socialType = socialType; + this.socialId = socialId; + this.refreshToken = refreshToken; + this.expiredAt = expiredAt; + this.deviceInfo = deviceInfo; + } + + public static SocialAuth create(Long userId, SocialType socialType, String socialId, + String refreshToken, LocalDateTime expiredAt, String deviceInfo) { + return SocialAuth.builder() + .userId(userId) + .socialType(socialType) + .socialId(socialId) + .refreshToken(refreshToken) + .expiredAt(expiredAt) + .deviceInfo(deviceInfo) + .build(); + } + private static void validateRequiredField(Object value) { + if (value == null || (value instanceof String && ((String) value).trim().isEmpty())) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + } + + public void updateRefreshToken(String newRefreshToken, LocalDateTime newExpiredAt, String deviceInfo) { + if (newRefreshToken == null || newRefreshToken.trim().isEmpty()) { + throw new GeneralException(AuthErrorStatus.TOKEN_MISSING); + } + + if (newExpiredAt == null || newExpiredAt.isBefore(LocalDateTime.now())) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); + } + + this.refreshToken = newRefreshToken; + this.expiredAt = newExpiredAt; + this.deviceInfo = deviceInfo; + } + + public void expireToken() { + this.refreshToken = null; + this.expiredAt = null; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/apipayload/domain/AuthErrorStatus.java b/src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java similarity index 59% rename from src/main/java/com/mr/global/apipayload/domain/AuthErrorStatus.java rename to src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java index f78da427..555536d7 100644 --- a/src/main/java/com/mr/global/apipayload/domain/AuthErrorStatus.java +++ b/src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java @@ -1,4 +1,4 @@ -package com.mr.global.apipayload.domain; +package com.mr.domain.auth.entity.enums; import com.mr.global.apipayload.code.BaseCode; import org.springframework.http.HttpStatus; @@ -9,8 +9,9 @@ @AllArgsConstructor public enum AuthErrorStatus implements BaseCode { - // 예원님 도메인 에러코드 컨벤션 적용 - TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_07", "토큰 값이 필요합니다."), + 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", "만료된 토큰입니다."); diff --git a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java new file mode 100644 index 00000000..bb236510 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java @@ -0,0 +1,6 @@ +package com.mr.domain.auth.entity.enums; + +public enum SocialType { + KAKAO, + GOOGLE +} diff --git a/src/main/java/com/mr/domain/user/entity/UsageLimit.java b/src/main/java/com/mr/domain/user/entity/UsageLimit.java new file mode 100644 index 00000000..0f79d1eb --- /dev/null +++ b/src/main/java/com/mr/domain/user/entity/UsageLimit.java @@ -0,0 +1,93 @@ +package com.mr.domain.user.entity; + +import com.mr.domain.user.entity.enums.UserUsageErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.entity.BaseCreatedEntity; +import jakarta.persistence.*; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "usage_limit", + indexes = { + @Index(name = "idx_usage_limit_user_date", columnList = "user_id, limit_date") + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageLimit extends BaseCreatedEntity { + + private static final int DEFAULT_MAX_FREE_COUNT = 3; // Free 3회 제한 + + @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; + + @Builder(access = AccessLevel.PRIVATE) + private UsageLimit(Long userId, LocalDate limitDate, Integer remainingCount, Integer maxCount) { + validateRequired(userId, "userId"); + validateRequired(limitDate, "limitDate"); + validatePositiveOrZero(remainingCount, "remainingCount"); + validatePositiveOrZero(maxCount, "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(); + } + + public static void validateRequired(Object value, String fieldName) { + if (value == null) { + throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); + } + } + + private static void validatePositiveOrZero(Integer value, String fieldName) { + if (value == null || value < 0) { + throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); + } + } + + // 분석 요청 시 카운트 로직 + public void consume() { + if (this.remainingCount <= 0) { + throw new GeneralException(UserUsageErrorStatus.USAGE_LIMIT_EXCEEDED); + } + this.remainingCount--; + } + + // 관리자 기능으로 잔여 횟수 수동 조절할 때 사용 + public void updateRemainingCount(Integer newCount) { + validatePositiveOrZero(newCount, "newCount"); + if (newCount > this.maxCount) { + throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); + } + this.remainingCount = newCount; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java b/src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java new file mode 100644 index 00000000..c310dbc3 --- /dev/null +++ b/src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java @@ -0,0 +1,24 @@ +package com.mr.domain.user.entity.enums; + +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", "일일 잔여 분석 횟수를 모두 소진하였습니다."), + + // 유효하지 않은 카운트 수정 요청 + INVALID_USAGE_COUNT(HttpStatus.BAD_REQUEST, "USAGE_400_01", "잔여 횟수는 최대 제한 횟수를 초과할 수 없습니다."), + + // 해당 날짜의 이용 제한 데이터가 존재하지 않을 때 + USAGE_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "USAGE_404_01", "해당 날짜의 이용 제한 기록을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file From e392993510990eb2a0816bc8a24927a93fd88cd6 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Sat, 11 Jul 2026 23:04:34 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20refresh=20token=20hash=ED=82=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=ED=98=B8=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index a1bab9fa..6fb270e8 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -12,15 +12,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import static com.mr.domain.user.entity.UsageLimit.validateRequired; - @Getter @Entity // TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가 @Table( name = "social_auth", indexes = { - @Index(name = "idx_social_auth_refresh_token", columnList = "refresh_token") + @Index(name = "idx_social_auth_token_hash", columnList = "refresh_token_hash") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class SocialAuth extends BaseCreatedEntity { @@ -41,9 +39,12 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_id", nullable = false, length = 100) private String socialId; - @Column(name = "refresh_token", length = 500) + @Column(name = "refresh_token", length = 1000) private String refreshToken; + @Column(name = "refresh_token_hash", length = 64, unique = true) + private String refreshTokenHash; + @Column(name = "expired_at") private LocalDateTime expiredAt; @@ -53,9 +54,10 @@ public class SocialAuth extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, LocalDateTime expiredAt, String deviceInfo) { - validateRequired(userId, "userId"); - validateRequired(socialType, "socialType"); - validateRequired(socialId, "socialId"); + + validateRequiredField(userId); + validateRequiredField(socialType); + validateRequiredField(socialId); this.userId = userId; this.socialType = socialType; @@ -82,16 +84,15 @@ private static void validateRequiredField(Object value) { } } - public void updateRefreshToken(String newRefreshToken, LocalDateTime newExpiredAt, String deviceInfo) { - if (newRefreshToken == null || newRefreshToken.trim().isEmpty()) { - throw new GeneralException(AuthErrorStatus.TOKEN_MISSING); - } + public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) { + validateRequiredField(encryptedToken); + validateRequiredField(tokenHash); if (newExpiredAt == null || newExpiredAt.isBefore(LocalDateTime.now())) { throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); } - - this.refreshToken = newRefreshToken; + this.refreshToken = encryptedToken; + this.refreshTokenHash = tokenHash; this.expiredAt = newExpiredAt; this.deviceInfo = deviceInfo; } From 73dc4a0ee67d4be747221d854cfc7f74d8fdfa55 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Sat, 11 Jul 2026 23:05:59 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20user-limit=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/user/entity/UsageLimit.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/user/entity/UsageLimit.java b/src/main/java/com/mr/domain/user/entity/UsageLimit.java index 0f79d1eb..24de7fd5 100644 --- a/src/main/java/com/mr/domain/user/entity/UsageLimit.java +++ b/src/main/java/com/mr/domain/user/entity/UsageLimit.java @@ -15,7 +15,7 @@ @Table( name = "usage_limit", indexes = { - @Index(name = "idx_usage_limit_user_date", columnList = "user_id, limit_date") + @Index(name = "idx_usage_limit_user_date", columnList = "user_id, limit_date", unique = true) }) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UsageLimit extends BaseCreatedEntity { From f83d8138be260b683c67bd75d425ebfcc45402e9 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Sat, 11 Jul 2026 23:09:06 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/entity/SocialAuth.java | 3 +-- .../auth/{entity/enums => exception}/AuthErrorStatus.java | 2 +- src/main/java/com/mr/domain/user/entity/UsageLimit.java | 2 +- .../user/{entity/enums => exception}/UserUsageErrorStatus.java | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) rename src/main/java/com/mr/domain/auth/{entity/enums => exception}/AuthErrorStatus.java (95%) rename src/main/java/com/mr/domain/user/{entity/enums => exception}/UserUsageErrorStatus.java (95%) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 6fb270e8..1ef8ad71 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -1,8 +1,7 @@ package com.mr.domain.auth.entity; -import com.mr.domain.auth.entity.enums.AuthErrorStatus; +import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.entity.enums.SocialType; -import com.mr.global.apipayload.code.CommonStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.*; diff --git a/src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java similarity index 95% rename from src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java rename to src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java index 555536d7..30fc5868 100644 --- a/src/main/java/com/mr/domain/auth/entity/enums/AuthErrorStatus.java +++ b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java @@ -1,4 +1,4 @@ -package com.mr.domain.auth.entity.enums; +package com.mr.domain.auth.exception; import com.mr.global.apipayload.code.BaseCode; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/mr/domain/user/entity/UsageLimit.java b/src/main/java/com/mr/domain/user/entity/UsageLimit.java index 24de7fd5..93eda76e 100644 --- a/src/main/java/com/mr/domain/user/entity/UsageLimit.java +++ b/src/main/java/com/mr/domain/user/entity/UsageLimit.java @@ -1,6 +1,6 @@ package com.mr.domain.user.entity; -import com.mr.domain.user.entity.enums.UserUsageErrorStatus; +import com.mr.domain.user.exception.UserUsageErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.*; diff --git a/src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java b/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java similarity index 95% rename from src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java rename to src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java index c310dbc3..fa3561c0 100644 --- a/src/main/java/com/mr/domain/user/entity/enums/UserUsageErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java @@ -1,4 +1,4 @@ -package com.mr.domain.user.entity.enums; +package com.mr.domain.user.exception; import com.mr.global.apipayload.code.BaseCode; import org.springframework.http.HttpStatus; From b9dcb2186f0161140a42112e742919b38c035347 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Sat, 11 Jul 2026 23:21:30 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=95=B4=EC=8B=9C=EA=B0=92=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C,=EB=A6=AC=ED=94=84=EB=9E=98=EC=8B=9C,=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 1ef8ad71..3590b2f7 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -52,8 +52,7 @@ public class SocialAuth extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, - LocalDateTime expiredAt, String deviceInfo) { - + String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { validateRequiredField(userId); validateRequiredField(socialType); validateRequiredField(socialId); @@ -62,17 +61,23 @@ private SocialAuth(Long userId, SocialType socialType, String socialId, String r 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 refreshToken, LocalDateTime expiredAt, String deviceInfo) { + String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) { + validateRequiredField(encryptedToken); + validateRequiredField(tokenHash); + validateExpiryTime(expiredAt); + return SocialAuth.builder() .userId(userId) .socialType(socialType) .socialId(socialId) - .refreshToken(refreshToken) + .refreshToken(encryptedToken) + .refreshTokenHash(tokenHash) .expiredAt(expiredAt) .deviceInfo(deviceInfo) .build(); @@ -82,14 +87,17 @@ private static void validateRequiredField(Object value) { throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } } + private static void validateExpiryTime(LocalDateTime expiredAt) { + if (expiredAt == null || expiredAt.isBefore(LocalDateTime.now())) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); + } + } public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) { validateRequiredField(encryptedToken); validateRequiredField(tokenHash); + validateExpiryTime(newExpiredAt); - if (newExpiredAt == null || newExpiredAt.isBefore(LocalDateTime.now())) { - throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); - } this.refreshToken = encryptedToken; this.refreshTokenHash = tokenHash; this.expiredAt = newExpiredAt; @@ -98,6 +106,7 @@ public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDat public void expireToken() { this.refreshToken = null; + this.refreshTokenHash = null; this.expiredAt = null; } } \ No newline at end of file From de6a46c80375796523250668334052865cabf78a Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 20 Jul 2026 22:32:31 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/user/entity/UsageLimit.java | 50 ++++++++++++------- .../user/exception/UserUsageErrorStatus.java | 4 +- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/mr/domain/user/entity/UsageLimit.java b/src/main/java/com/mr/domain/user/entity/UsageLimit.java index 93eda76e..5c80a087 100644 --- a/src/main/java/com/mr/domain/user/entity/UsageLimit.java +++ b/src/main/java/com/mr/domain/user/entity/UsageLimit.java @@ -14,13 +14,13 @@ @Entity @Table( name = "usage_limit", - indexes = { - @Index(name = "idx_usage_limit_user_date", columnList = "user_id, limit_date", unique = true) + uniqueConstraints = { + @UniqueConstraint(name = "uk_usage_limit_user_date", columnNames = {"user_id", "limit_date"}) }) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UsageLimit extends BaseCreatedEntity { - private static final int DEFAULT_MAX_FREE_COUNT = 3; // Free 3회 제한 + private static final int DEFAULT_MAX_FREE_COUNT = 3; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -40,12 +40,17 @@ public class UsageLimit extends BaseCreatedEntity { @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, "userId"); - validateRequired(limitDate, "limitDate"); - validatePositiveOrZero(remainingCount, "remainingCount"); - validatePositiveOrZero(maxCount, "maxCount"); + validateRequired(userId); + validateRequired(limitDate); + validatePositiveOrZero(remainingCount); + validatePositiveOrZero(maxCount); + validateCountRange(remainingCount, maxCount); this.userId = userId; this.limitDate = limitDate; @@ -62,15 +67,19 @@ public static UsageLimit createDefault(Long userId, LocalDate limitDate) { .build(); } - public static void validateRequired(Object value, String fieldName) { + private static void validateRequired(Object value) { if (value == null) { - throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); + throw new GeneralException(UserUsageErrorStatus.REQUIRED_FIELD_MISSING); } } - - private static void validatePositiveOrZero(Integer value, String fieldName) { + private static void validatePositiveOrZero(Integer value) { if (value == null || value < 0) { - throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); + 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); } } @@ -82,12 +91,19 @@ public void consume() { this.remainingCount--; } - // 관리자 기능으로 잔여 횟수 수동 조절할 때 사용 + // 관리자 기능 - 잔여 횟수 수동 조절 public void updateRemainingCount(Integer newCount) { - validatePositiveOrZero(newCount, "newCount"); - if (newCount > this.maxCount) { - throw new GeneralException(UserUsageErrorStatus.INVALID_USAGE_COUNT); - } + 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; + } + } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java b/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java index fa3561c0..4b32a485 100644 --- a/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserUsageErrorStatus.java @@ -13,7 +13,9 @@ public enum UserUsageErrorStatus implements BaseCode { USAGE_LIMIT_EXCEEDED(HttpStatus.FORBIDDEN, "USAGE_403_01", "일일 잔여 분석 횟수를 모두 소진하였습니다."), // 유효하지 않은 카운트 수정 요청 - INVALID_USAGE_COUNT(HttpStatus.BAD_REQUEST, "USAGE_400_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", "해당 날짜의 이용 제한 기록을 찾을 수 없습니다."); From f79766953db13e6aab7908ca5c3854e1eca3aea2 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 20 Jul 2026 22:53:55 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20auth=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 37 +++++++++++++------ .../auth/exception/AuthErrorStatus.java | 12 +++++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 3590b2f7..ba8b38a6 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -6,6 +6,7 @@ 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; @@ -16,8 +17,9 @@ // TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가 @Table( name = "social_auth", - indexes = { - @Index(name = "idx_social_auth_token_hash", columnList = "refresh_token_hash") + 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 { @@ -53,9 +55,10 @@ public class SocialAuth extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { - validateRequiredField(userId); - validateRequiredField(socialType); - validateRequiredField(socialId); + // 컴파일 에러 수정: 실제 정의된 validateUserAccount 메서드로 매핑 + validateUserAccount(userId); + validateUserAccount(socialType); + validateUserAccount(socialId); this.userId = userId; this.socialType = socialType; @@ -68,8 +71,9 @@ private SocialAuth(Long userId, SocialType socialType, String socialId, String r public static SocialAuth create(Long userId, SocialType socialType, String socialId, String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) { - validateRequiredField(encryptedToken); - validateRequiredField(tokenHash); + + validateTokenValue(encryptedToken); + validateTokenValue(tokenHash); validateExpiryTime(expiredAt); return SocialAuth.builder() @@ -82,20 +86,28 @@ public static SocialAuth create(Long userId, SocialType socialType, String socia .deviceInfo(deviceInfo) .build(); } - private static void validateRequiredField(Object value) { + + 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.isBefore(LocalDateTime.now())) { + 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) { - validateRequiredField(encryptedToken); - validateRequiredField(tokenHash); + validateTokenValue(encryptedToken); + validateTokenValue(tokenHash); validateExpiryTime(newExpiredAt); this.refreshToken = encryptedToken; @@ -104,9 +116,10 @@ public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDat this.deviceInfo = deviceInfo; } + // 최신 토큰 만료 및 폐기 처리 public void expireToken() { this.refreshToken = null; this.refreshTokenHash = null; - this.expiredAt = null; + this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // null 대신 현재 시각 기록 } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java index 30fc5868..5555c50b 100644 --- a/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java +++ b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java @@ -9,11 +9,21 @@ @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", "만료된 토큰입니다."); + 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;