diff --git a/module-app/src/main/resources/application.yml b/module-app/src/main/resources/application.yml
index 06a0ff3e..ef00f191 100644
--- a/module-app/src/main/resources/application.yml
+++ b/module-app/src/main/resources/application.yml
@@ -120,6 +120,14 @@ auth:
PathOption: ${JWT_COOKIE_PATH}
secureOption: ${JWT_SECURE_OPTION}
sameSiteOption: ${JWT_SAME_SITE}
+ # Exchange code 설정
+ exchange:
+ code:
+ ttl-seconds: 60
+ # OAuth2 임시 사용자 정보 TTL
+ oauth2:
+ temp-user:
+ ttl-minutes: 5
# 카카오 좌표 설정
kakao:
diff --git a/module-app/src/main/resources/logback-spring.xml b/module-app/src/main/resources/logback-spring.xml
index 7bbce494..7e7e3f75 100644
--- a/module-app/src/main/resources/logback-spring.xml
+++ b/module-app/src/main/resources/logback-spring.xml
@@ -6,7 +6,8 @@
- %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n
+ %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} -
+ %msg%n
diff --git a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java
index 64aa3c27..b10c525a 100644
--- a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java
+++ b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java
@@ -37,12 +37,21 @@ public enum SecurityErrorCode implements ErrorCode {
REFRESH_TOKEN_NOT_FOUND(401_112, HttpStatus.UNAUTHORIZED, "리프레쉬 토큰이 없기에 재로그인이 필요합니다."),
REFRESH_TOKEN_LOGOUT(401_113, HttpStatus.UNAUTHORIZED, "리프레쉬 토큰을 사용한 로그아웃에 유저가 다릅니다."),
+ EXCHANGE_CODE_INVALID(401_114, HttpStatus.UNAUTHORIZED, "유효하지 않은 Exchange Code입니다."),
+ EXCHANGE_CODE_EXPIRED(401_115, HttpStatus.UNAUTHORIZED, "만료된 Exchange Code입니다."),
+
+ // ========================
+ // 429 Too Many Requests
+ // ========================
+ EXCHANGE_CODE_RATE_LIMITED(429_100, HttpStatus.TOO_MANY_REQUESTS, "Exchange Code 교환 요청이 너무 많습니다. 잠시 후 다시 시도해주세요."),
+
// ========================
// 403 Forbidden
// ========================
FORBIDDEN(403_100, HttpStatus.FORBIDDEN, "접속 권한이 없습니다."),
ACCESS_DENY(403_101, HttpStatus.FORBIDDEN, "접근이 거부되었습니다."),
UNAUTHORIZED_POST_ACCESS(403_102, HttpStatus.FORBIDDEN, "해당 게시글에 접근할 권한이 없습니다."),
+ INTERNAL_API_FORBIDDEN(403_103, HttpStatus.FORBIDDEN, "내부 API 접근이 거부되었습니다."),
// ========================
// 404 Not Found
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java
index 6d5c4780..ba8cbdba 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java
@@ -64,6 +64,7 @@ public static DiagnosisDetailResponse from(EvaluationContext context) {
? List.of("해당 없음")
: context.getCurrentCandidates().stream()
.map((c -> c.noticeType().getValue() + " : " + c.supplyType().getValue()))
+ .distinct()
.toList();
// 지원 가능한 공급 유형 (중복 제거)
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java
index b37099b7..d4c360c0 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java
@@ -34,6 +34,7 @@ public static DiagnosisResponse from(EvaluationContext context) {
? List.of("해당 없음")
: context.getCurrentCandidates().stream()
.map((c -> c.noticeType().getValue() + " : " + c.supplyType().getValue()))
+ .distinct()
.toList();
return DiagnosisResponse.builder()
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java
index c138fb72..281380f2 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java
@@ -154,21 +154,21 @@ private double incomeByFamily(int familyCount, Map table, doubl
/**
* 최대 총자산 제한 (단위: 원)
- * 통합공공임대/국민임대/행복주택: 총자산 33,700만원 이하
- * 영구임대: 총자산 23,700만원 이하
+ * 통합공공임대/국민임대: 총자산 34,500만원 이하
+ * 영구임대: 총자산 24,500만원 이하
* 장기전세/공공임대: 부동산 21,550만원 이하
*/
@Override
public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount) {
return switch (rental) {
- /// 통합공공임대 - 총자산 337,000,000원 (우선공급 중 일부 적용 제외)
- case PUBLIC_INTEGRATED -> 337_000_000L;
+ /// 통합공공임대 - 총자산 345,000,000원
+ case PUBLIC_INTEGRATED -> 345_000_000L;
- /// 영구임대 - 총자산 237,000,000원 (수급자 등 일부 대상 제외)
- case PERMANENT_RENTAL -> 237_000_000L;
+ /// 영구임대 - 총자산 245,000,000원
+ case PERMANENT_RENTAL -> 245_000_000L;
- /// 국민임대 - 총자산 337,000,000원 (우선공급 중 일부 적용 제외)
- case NATIONAL_RENTAL -> 337_000_000L;
+ /// 국민임대 - 총자산 345,000,000원
+ case NATIONAL_RENTAL -> 345_000_000L;
/// 장기전세 - 부동산 215,500,000원 (우선공급 중 일부 적용 제외)
case LONG_TERM_JEONSE -> 215_500_000L;
@@ -178,11 +178,11 @@ public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount)
/// 행복주택 - 계층별 총자산 기준 상이
case HAPPY_HOUSING -> switch (supply) {
- /// 대학생 특별공급 - 총자산 104,000,000원 (1억 400만원)
- case STUDENT_SPECIAL -> 104_000_000L;
+ /// 대학생 특별공급 - 총자산 108,000,000원
+ case STUDENT_SPECIAL -> 108_000_000L;
- /// 청년 특별공급 - 총자산 254,000,000원 (2억 5천 4백만원) - 맞벌이 등 고려
- case YOUTH_SPECIAL -> 254_000_000L;
+ /// 청년 특별공급 - 총자산 251,000,000원
+ case YOUTH_SPECIAL -> 251_000_000L;
/// 기타 - 총자산 337,000,000원 (3억 3천 7백만원)
default -> 337_000_000L;
@@ -194,12 +194,12 @@ public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount)
}
/**
- * 자동차 자산 제한 - 45,630,000원 (4,563만원)
+ * 자동차 자산 제한 - 45,420,000원 (4,542만원)
* 모든 임대주택 유형 공통 적용
*/
@Override
public long checkMaxCarValue() {
- return 45_630_000L;
+ return 45_420_000L;
}
@Override
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java
new file mode 100644
index 00000000..e5b21691
--- /dev/null
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java
@@ -0,0 +1,67 @@
+package co.kr.pinhouse.domain.diagnostic.rule.domain.entity;
+
+import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType;
+
+/**
+ * 내부 정책 variant.
+ * 응답 DTO에는 노출하지 않고, 후보별 세부 판정과 exact-support 여부만 관리한다.
+ */
+public enum CandidatePolicyKey {
+
+ DEFAULT(true, null),
+ INTEGRATED_YOUTH_18_TO_39(true, null),
+ YOUTH_19_TO_39(true, null),
+ SINGLE_PARENT_WITH_INFANT_OR_FETUS(true, null),
+ NEWLY_MARRIED_GENERIC(true, null),
+ UNSUPPORTED_PUBLIC_RENTAL_PRODUCT(
+ false, "공공임대는 5년·6년·10년 유형과 모집공고별 면적 기준 정보가 필요합니다."),
+ UNSUPPORTED_LONG_TERM_JEONSE_REGION(
+ false, "장기전세는 지역별 자산 기준과 모집공고 정보가 필요합니다."),
+ UNSUPPORTED_STUDENT_PARENT_INCOME(
+ false, "행복주택 대학생·취업준비생은 부모 소득 및 자산 정보가 필요합니다."),
+ UNSUPPORTED_MINOR_CHILD_AGE(
+ false, "신생아 특별공급은 2세 미만 자녀 여부를 확인할 입력이 필요합니다."),
+ UNSUPPORTED_FIRST_HOME_HISTORY(
+ false, "생애최초 특별공급은 과거 주택 소유 이력과 소득세 납부 이력이 필요합니다."),
+ UNSUPPORTED_ELDER_SUPPORT_DURATION(
+ false, "노부모부양 특별공급은 1년 또는 3년 이상 부양기간 정보가 필요합니다.");
+
+ private final boolean exactSupported;
+ private final String unsupportedReason;
+
+ CandidatePolicyKey(boolean exactSupported, String unsupportedReason) {
+ this.exactSupported = exactSupported;
+ this.unsupportedReason = unsupportedReason;
+ }
+
+ public static CandidatePolicyKey resolve(NoticeType noticeType, SupplyType supplyType) {
+ if (noticeType == NoticeType.PUBLIC_RENTAL) {
+ return UNSUPPORTED_PUBLIC_RENTAL_PRODUCT;
+ }
+
+ if (noticeType == NoticeType.LONG_TERM_JEONSE) {
+ return UNSUPPORTED_LONG_TERM_JEONSE_REGION;
+ }
+
+ return switch (supplyType) {
+ case STUDENT_SPECIAL -> UNSUPPORTED_STUDENT_PARENT_INCOME;
+ case MINOR_SPECIAL -> UNSUPPORTED_MINOR_CHILD_AGE;
+ case FIRST_SPECIAL -> UNSUPPORTED_FIRST_HOME_HISTORY;
+ case ELDER_SUPPORT_SPECIAL -> UNSUPPORTED_ELDER_SUPPORT_DURATION;
+ case YOUTH_SPECIAL -> noticeType == NoticeType.PUBLIC_INTEGRATED
+ ? INTEGRATED_YOUTH_18_TO_39
+ : YOUTH_19_TO_39;
+ case SINGLE_PARENT_SPECIAL -> SINGLE_PARENT_WITH_INFANT_OR_FETUS;
+ case NEWCOUPLE_SPECIAL -> NEWLY_MARRIED_GENERIC;
+ default -> DEFAULT;
+ };
+ }
+
+ public boolean exactSupported() {
+ return exactSupported;
+ }
+
+ public String unsupportedReason() {
+ return unsupportedReason;
+ }
+}
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java
index b5a08c23..76d8fe47 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java
@@ -9,26 +9,35 @@ public class RentalSupplyMapping {
public static final Map> RENTAL_SUPPLY_MAP = Map.of(
NoticeType.PUBLIC_INTEGRATED, List.of(
- SupplyType.SPECIAL, // 미성년자
SupplyType.YOUTH_SPECIAL,
SupplyType.ELDER_SPECIAL,
SupplyType.NEWCOUPLE_SPECIAL,
SupplyType.SINGLE_PARENT_SPECIAL,
SupplyType.GENERAL,
SupplyType.MULTICHILD_SPECIAL,
+ SupplyType.ELDER_SUPPORT_SPECIAL,
SupplyType.DEMOLITION,
SupplyType.NATIONAL_MERIT,
SupplyType.LONG_SERVICE_VETERAN,
SupplyType.NORTH_DEFECTOR,
+ SupplyType.COMFORT_WOMAN_VICTIM,
SupplyType.DISABLED,
- SupplyType.NON_HOUSING_RESIDENT
+ SupplyType.NON_HOUSING_RESIDENT,
+ SupplyType.PERMANENT_LEASE_EVICTEE
),
NoticeType.NATIONAL_RENTAL, List.of(
- SupplyType.SPECIAL, // 미성년자
SupplyType.NEWCOUPLE_SPECIAL,
SupplyType.SINGLE_PARENT_SPECIAL,
+ SupplyType.MULTICHILD_SPECIAL,
+ SupplyType.ELDER_SUPPORT_SPECIAL,
SupplyType.GENERAL,
SupplyType.DEMOLITION,
+ SupplyType.NATIONAL_MERIT,
+ SupplyType.LONG_SERVICE_VETERAN,
+ SupplyType.NORTH_DEFECTOR,
+ SupplyType.DISABLED,
+ SupplyType.PERMANENT_LEASE_EVICTEE,
+ SupplyType.TEMPORARY_STRUCTURE_RESIDENT,
SupplyType.UNAUTHORIZED_BUILDING_TENANT
),
NoticeType.HAPPY_HOUSING, List.of(
@@ -36,11 +45,7 @@ public class RentalSupplyMapping {
SupplyType.YOUTH_SPECIAL,
SupplyType.NEWCOUPLE_SPECIAL,
SupplyType.SINGLE_PARENT_SPECIAL,
- SupplyType.ELDER_SPECIAL,
- SupplyType.DISABLED,
- SupplyType.NATIONAL_MERIT,
- SupplyType.PERMANENT_LEASE_EVICTEE,
- SupplyType.TEMPORARY_STRUCTURE_RESIDENT
+ SupplyType.ELDER_SPECIAL
),
NoticeType.PUBLIC_RENTAL, List.of(
SupplyType.GENERAL,
@@ -50,10 +55,14 @@ public class RentalSupplyMapping {
SupplyType.FIRST_SPECIAL, // 생애최초
SupplyType.MINOR_SPECIAL, // 신생아
SupplyType.YOUTH_SPECIAL, // 청년
- SupplyType.NATIONAL_MERIT
+ SupplyType.NATIONAL_MERIT,
+ SupplyType.DEMOLITION,
+ SupplyType.LONG_SERVICE_VETERAN,
+ SupplyType.NORTH_DEFECTOR,
+ SupplyType.DISABLED
),
NoticeType.PERMANENT_RENTAL, List.of(
- SupplyType.NEWCOUPLE_SPECIAL,
+ SupplyType.GENERAL,
SupplyType.SINGLE_PARENT_SPECIAL,
SupplyType.ELDER_SUPPORT_SPECIAL,
SupplyType.ELDER_SPECIAL,
@@ -63,9 +72,15 @@ public class RentalSupplyMapping {
SupplyType.DISABLED
),
NoticeType.LONG_TERM_JEONSE, List.of(
+ SupplyType.GENERAL,
SupplyType.NEWCOUPLE_SPECIAL,
- SupplyType.SINGLE_PARENT_SPECIAL,
- SupplyType.MULTICHILD_SPECIAL
+ SupplyType.MULTICHILD_SPECIAL,
+ SupplyType.DEMOLITION,
+ SupplyType.NATIONAL_MERIT,
+ SupplyType.DISABLED,
+ SupplyType.PERMANENT_LEASE_EVICTEE,
+ SupplyType.TEMPORARY_STRUCTURE_RESIDENT,
+ SupplyType.UNAUTHORIZED_BUILDING_TENANT
)
);
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java
index bb632bf7..267e3c16 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java
@@ -13,18 +13,13 @@
@Builder
public record SupplyRentalCandidate(
SupplyType supplyType,
- NoticeType noticeType
+ NoticeType noticeType,
+ CandidatePolicyKey policyKey
) {
/// 정적 팩토리 메서드 (기본 값)
public static List basic() {
- /// 모든 값 가져오기
- List supplyTypes = SupplyType.getAllTypes();
-
- /// 모든 값 가져오기
- List noticeTypes = NoticeType.getAllTypes();
-
List candidates = new ArrayList<>();
/// for문 돌리면서 값 합치기
@@ -52,6 +47,7 @@ public static SupplyRentalCandidate basic(SupplyType supplyType, NoticeType noti
return SupplyRentalCandidate.builder()
.supplyType(supplyType)
.noticeType(noticeType)
+ .policyKey(CandidatePolicyKey.resolve(noticeType, supplyType))
.build();
}
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java
index c5bf151f..ba622377 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java
@@ -2,7 +2,6 @@
import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.LONG_TERM_JEONSE;
import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.NATIONAL_RENTAL;
-import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.PUBLIC_INTEGRATED;
import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.PUBLIC_RENTAL;
import java.util.ArrayList;
@@ -31,7 +30,6 @@ public class AccountRule implements Rule {
/// 청약통장이 없으면 불가능한 목록들
private static final List NO_ACCOUNT_ALLOWED = List.of(
NATIONAL_RENTAL,
- PUBLIC_INTEGRATED,
LONG_TERM_JEONSE,
PUBLIC_RENTAL
);
@@ -83,6 +81,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 청약통장 없는 경우, NO_ACCOUNT_ALLOWED에 해당하는 RentalType 제거
candidates.removeIf(c -> NO_ACCOUNT_ALLOWED.contains(c.noticeType()));
+ ctx.setCurrentCandidates(candidates);
return RuleResult.pass(code(),
"청약통장 미보유로 인한 유형 제거 완료",
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java
index 8538ab51..f08cec4e 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java
@@ -37,8 +37,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 나이별 후보군 필터링
if (age < 19) {
candidates.removeIf(c ->
- c.supplyType() == SupplyType.NEWCOUPLE_SPECIAL
- || c.supplyType() == SupplyType.ELDER_SPECIAL
+ c.supplyType() == SupplyType.ELDER_SPECIAL
|| c.supplyType() == SupplyType.GENERAL
);
} else if (age <= 39) {
@@ -49,10 +48,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
|| c.supplyType() == SupplyType.ELDER_SPECIAL
);
} else if (age >= 65) {
- candidates.removeIf(c ->
- c.supplyType() == SupplyType.YOUTH_SPECIAL
- || c.supplyType() == SupplyType.NEWCOUPLE_SPECIAL
- );
+ candidates.removeIf(c -> c.supplyType() == SupplyType.YOUTH_SPECIAL);
}
/// 2차, 주택 소유 여부
@@ -67,6 +63,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 본인이 주택을 소유한 경우 모든 임대주택 신청 불가
if (ownsHouse) {
+ ctx.setCurrentCandidates(List.of());
return RuleResult.fail(
code(),
"주택 소유자는 임대주택 지원이 불가능",
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java
index c98b8c6d..473d4884 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java
@@ -38,8 +38,7 @@ public class GeneralSupplyRule implements Rule {
/// 청약통장이 필요한 임대주택 유형
private static final Set SUBSCRIPTION_REQUIRED_TYPES = Set.of(
- NoticeType.PUBLIC_RENTAL, // 공공임대 (분양전환 가능)
- NoticeType.PUBLIC_INTEGRATED // 통합공공임대 (분양전환 가능)
+ NoticeType.PUBLIC_RENTAL // 공공임대 (분양전환 가능)
);
private final PolicyUseCase policyUseCase;
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java
index d8042ed4..1926390a 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java
@@ -32,7 +32,6 @@ public RuleResult evaluate(EvaluationContext ctx) {
var candidates = new ArrayList<>(ctx.getCurrentCandidates());
/// 무주택 세대주 또는 예비 세대주 여부
- boolean isHouseholdHead = diagnosis.isHouseholdHead();
boolean isNoHouse = diagnosis.getHousingStatus().equals(
co.kr.pinhouse.domain.diagnostic.diagnosis.domain.entity.HousingOwnershipStatus.NO_ONE_OWNS_HOUSE)
|| diagnosis.getHousingStatus().equals(
@@ -49,11 +48,11 @@ public RuleResult evaluate(EvaluationContext ctx) {
boolean withYouthAge = diagnosis.getUnbornChildrenCount() > 0 || diagnosis.getUnder6ChildrenCount() > 0;
/// 신혼부부 특별공급 요건:
- /// - 무주택 세대주 또는 예비 세대주
+ /// - 무주택 세대구성원 또는 무주택자
/// - 결혼했고 7년 이하, 또는
/// - 결혼 7년 초과지만 자녀가 6세 이하인 경우
- if (!(isHouseholdHead && isNoHouse && isMarried && (withinYears || withYouthAge))) {
+ if (!(isNoHouse && isMarried && (withinYears || withYouthAge))) {
/// 있다면 삭제
candidates.removeIf(c ->
@@ -64,9 +63,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 실패 이유를 담아줌
String failReason;
- if (!isHouseholdHead) {
- failReason = "세대주가 아님";
- } else if (!isNoHouse) {
+ if (!isNoHouse) {
failReason = "무주택 요건 미충족";
} else if (!isMarried) {
failReason = "결혼하지 않음";
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java
index ee8dd4a7..d122af96 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java
@@ -44,8 +44,9 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 한부모인지
boolean hasSingleParent = specialCategories.stream()
.anyMatch(c -> c == SINGLE_PARENT || c == PROTECTED_SINGLE_PARENT);
+ boolean hasInfantOrFetus = diagnosis.getUnbornChildrenCount() > 0 || diagnosis.getUnder6ChildrenCount() > 0;
- if (!hasSingleParent) {
+ if (!hasSingleParent || !hasInfantOrFetus) {
/// 한부모 계층이 아니라면 삭제
candidates.removeIf(c ->
@@ -56,7 +57,10 @@ public RuleResult evaluate(EvaluationContext ctx) {
return RuleResult.fail(code(),
"한부모 특별공급 해당 없음",
- Map.of("candidate", candidates));
+ Map.of(
+ "candidate", candidates,
+ "failReason", hasSingleParent ? "태아 또는 6세 이하 자녀 요건 미충족" : "한부모가족 요건 미충족"
+ ));
}
return RuleResult.pass(code(),
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java
index 9b763b67..b3225b29 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java
@@ -43,6 +43,7 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 사용자가 가진 특수계층을 SupplyType으로 매핑
List ownedTypes = specialCategory.stream()
.map(SpecialCategory::toSupplyType)
+ .filter(java.util.Objects::nonNull)
.toList();
/// 존재하는 계층은 냅두고, 없는 것은 삭제하기
@@ -76,6 +77,6 @@ public RuleResult evaluate(EvaluationContext ctx) {
@Override
public String code() {
- return "";
+ return "SPECIAL_CATEGORY";
}
}
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java
new file mode 100644
index 00000000..14634312
--- /dev/null
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java
@@ -0,0 +1,60 @@
+package co.kr.pinhouse.domain.diagnostic.rule.domain.rule;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import co.kr.pinhouse.domain.diagnostic.rule.application.dto.RuleResult;
+import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.EvaluationContext;
+import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.SupplyRentalCandidate;
+
+/**
+ * 현재 DTO만으로 최신 공식 기준을 정확히 재현할 수 없는 후보를 보수적으로 제거한다.
+ */
+@Order(16)
+@Component
+public class UnsupportedCandidateRule implements Rule {
+
+ @Override
+ public RuleResult evaluate(EvaluationContext ctx) {
+ var candidates = new ArrayList<>(ctx.getCurrentCandidates());
+ List removed = new ArrayList<>();
+
+ candidates.removeIf(candidate -> {
+ boolean unsupported = !candidate.policyKey().exactSupported();
+
+ if (unsupported) {
+ removed.add(candidate);
+ }
+ return unsupported;
+ });
+
+ ctx.setCurrentCandidates(candidates);
+
+ if (removed.isEmpty()) {
+ return RuleResult.pass(code(),
+ "DTO로 정확 판정 가능한 후보만 유지",
+ Map.of("candidate", candidates));
+ }
+
+ return RuleResult.pass(code(),
+ "현재 DTO로 정확 판정이 어려운 후보 제거",
+ Map.of(
+ "candidate", candidates,
+ "removed", removed.stream()
+ .map(candidate -> String.format("%s : %s (%s)",
+ candidate.noticeType().getValue(),
+ candidate.supplyType().getValue(),
+ candidate.policyKey().unsupportedReason()))
+ .toList()
+ ));
+ }
+
+ @Override
+ public String code() {
+ return "UNSUPPORTED_CANDIDATE_FILTER";
+ }
+}
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java
index 7a01f551..cc61056c 100644
--- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java
@@ -10,6 +10,7 @@
import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.entity.HousingOwnershipStatus;
import co.kr.pinhouse.domain.diagnostic.rule.application.dto.RuleResult;
import co.kr.pinhouse.domain.diagnostic.rule.application.usecase.PolicyUseCase;
+import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.CandidatePolicyKey;
import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.EvaluationContext;
import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.SupplyType;
import lombok.RequiredArgsConstructor;
@@ -34,35 +35,38 @@ public RuleResult evaluate(EvaluationContext ctx) {
/// 가능한 리스트 추출하기
var candidates = new ArrayList<>(ctx.getCurrentCandidates());
- /// 나이 체크 (19~39세)
int age = diagnosis.getAge();
- int youthAgeMin = policyUseCase.youthAgeMin();
- boolean ageOk = age >= youthAgeMin && age <= 39;
-
- /// 무주택 세대주 또는 예비 세대주 여부
- boolean isHouseholdHead = diagnosis.isHouseholdHead();
+ boolean unmarried = !diagnosis.isMaritalStatus();
boolean isNoHouse = diagnosis.getHousingStatus().equals(HousingOwnershipStatus.NO_ONE_OWNS_HOUSE)
|| diagnosis.getHousingStatus().equals(HousingOwnershipStatus.HOUSEHOLD_MEMBER_OWNS_HOUSE);
- /// 1인 가구 또는 세대주인 경우 청년 특별공급 가능
- boolean qualifies = ageOk && (isHouseholdHead || diagnosis.isSingle()) && isNoHouse;
+ candidates.removeIf(candidate -> {
+ if (candidate.supplyType() != SupplyType.YOUTH_SPECIAL) {
+ return false;
+ }
- if (!qualifies) {
+ int minAge = candidate.policyKey() == CandidatePolicyKey.INTEGRATED_YOUTH_18_TO_39
+ ? 18
+ : policyUseCase.youthAgeMin();
+ boolean ageOk = age >= minAge && age <= 39;
+ return !(ageOk && unmarried && isNoHouse);
+ });
- /// 청년 특별공급 제거
- candidates.removeIf(c -> c.supplyType() == SupplyType.YOUTH_SPECIAL);
+ ctx.setCurrentCandidates(candidates);
- /// 결과 저장하기
- ctx.setCurrentCandidates(candidates);
+ boolean hasYouthCandidate = candidates.stream()
+ .anyMatch(candidate -> candidate.supplyType() == SupplyType.YOUTH_SPECIAL);
- // 실패 이유 분류
+ if (!hasYouthCandidate) {
String failReason;
- if (!ageOk) {
- failReason = "나이 기준 미충족 (19~39세)";
+ if (diagnosis.isMaritalStatus()) {
+ failReason = "청년 계층 혼인 요건 미충족";
} else if (!isNoHouse) {
failReason = "무주택 요건 미충족";
+ } else if (age < 18) {
+ failReason = "나이 기준 미충족 (통합공공임대 청년 18세 이상)";
} else {
- failReason = "세대주 또는 1인 가구 요건 미충족";
+ failReason = "나이 기준 미충족";
}
return RuleResult.fail(code(),
@@ -73,7 +77,6 @@ public RuleResult evaluate(EvaluationContext ctx) {
));
}
- /// 청년 특별공급 후보
return RuleResult.pass(code(),
"청년 특별공급 후보",
Map.of("candidate", candidates));
diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java
new file mode 100644
index 00000000..4859c28f
--- /dev/null
+++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java
@@ -0,0 +1,18 @@
+package co.kr.pinhouse.domain.user.application.dto.response;
+
+import lombok.Builder;
+
+@Builder
+public record SignupResponse(
+ String accessToken,
+ String refreshToken
+) {
+
+ /// 정적 팩토리 메서드
+ public static SignupResponse of(String accessToken, String refreshToken) {
+ return SignupResponse.builder()
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+}
diff --git a/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java
new file mode 100644
index 00000000..b6d19c7f
--- /dev/null
+++ b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java
@@ -0,0 +1,162 @@
+package co.kr.pinhouse.domain.housing.complex.application.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyDouble;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.UnsupportedEncodingException;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import co.kr.pinhouse.domain.Location;
+import co.kr.pinhouse.domain.housing.complex.application.dto.response.ChipType;
+import co.kr.pinhouse.domain.housing.complex.application.dto.response.DistanceResponse;
+import co.kr.pinhouse.domain.housing.complex.application.dto.response.TransitInfoResponse;
+import co.kr.pinhouse.domain.housing.complex.application.util.DistanceUtil;
+import co.kr.pinhouse.domain.housing.complex.application.util.TransitResponseMapper;
+import co.kr.pinhouse.domain.housing.complex.domain.entity.ComplexDocument;
+import co.kr.pinhouse.domain.housing.complex.domain.repository.ComplexDocumentRepository;
+import co.kr.pinhouse.domain.housing.complex.domain.transit.PathResult;
+import co.kr.pinhouse.domain.housing.complex.domain.transit.RootResult;
+import co.kr.pinhouse.domain.housing.facility.application.usecase.FacilityUseCase;
+import co.kr.pinhouse.domain.like.application.usecase.LikeQueryUseCase;
+import co.kr.pinhouse.domain.pinpoint.application.usecase.PinPointUseCase;
+import co.kr.pinhouse.domain.pinpoint.domain.entity.PinPoint;
+
+@ExtendWith(MockitoExtension.class)
+class ComplexServiceTest {
+
+ @Mock
+ private ComplexDocumentRepository repository;
+
+ @Mock
+ private PinPointUseCase pinPointService;
+
+ @Mock
+ private DistanceUtil distanceUtil;
+
+ @Mock
+ private TransitResponseMapper mapper;
+
+ @Mock
+ private LikeQueryUseCase likeService;
+
+ @Mock
+ private FacilityUseCase facilityService;
+
+ @Mock
+ private DistanceCacheService distanceCacheService;
+
+ @InjectMocks
+ private ComplexService complexService;
+
+ @Test
+ void getTransitInfo_usesTransitInfoCacheBeforeRecalculation() throws UnsupportedEncodingException {
+ TransitInfoResponse cachedTransitInfo = TransitInfoResponse.builder()
+ .totalTime("1시간 10분")
+ .totalTimeMinutes(70)
+ .totalDistance(21.4)
+ .segments(List.of())
+ .build();
+
+ when(distanceCacheService.getTransitInfo("complex-1", "pin-1")).thenReturn(cachedTransitInfo);
+
+ TransitInfoResponse result = complexService.getTransitInfo("complex-1", "pin-1");
+
+ assertThat(result).isSameAs(cachedTransitInfo);
+ verify(distanceUtil, never()).findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble());
+ verify(repository, never()).findById(any());
+ verify(distanceCacheService, never()).getRootResult(any(), any());
+ }
+
+ @Test
+ void getTransitInfo_recalculatesWhenTransitInfoCacheIsMissing() throws UnsupportedEncodingException {
+ ComplexDocument complex = mock(ComplexDocument.class);
+ when(complex.getLocation()).thenReturn(Location.of(127.0, 37.5));
+ when(repository.findById("complex-1")).thenReturn(Optional.of(complex));
+
+ PinPoint pinPoint = PinPoint.of("user-1", "서울", "집", 37.6, 127.1, true);
+ when(pinPointService.loadPinPoint("pin-1")).thenReturn(pinPoint);
+
+ PathResult pathResult = mock(PathResult.class);
+ RootResult rootResult = RootResult.builder()
+ .totalTime(25)
+ .totalPayment(1450)
+ .totalDistance(8200)
+ .steps(List.of())
+ .build();
+ TransitInfoResponse transitInfo = TransitInfoResponse.builder()
+ .totalTime("25분")
+ .totalTimeMinutes(25)
+ .totalDistance(8.2)
+ .segments(List.of())
+ .build();
+
+ when(distanceCacheService.getTransitInfo("complex-1", "pin-1")).thenReturn(null);
+ when(distanceUtil.findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(pathResult);
+ when(pathResult.routes()).thenReturn(List.of(rootResult));
+ when(mapper.selectBest(pathResult)).thenReturn(rootResult);
+ when(mapper.toTransitInfoResponse(rootResult)).thenReturn(transitInfo);
+
+ TransitInfoResponse result = complexService.getTransitInfo("complex-1", "pin-1");
+
+ assertThat(result).isSameAs(transitInfo);
+ verify(distanceUtil).findPathResult(eq(37.6), eq(127.1), eq(37.5), eq(127.0));
+ verify(distanceCacheService).cacheRootResult("complex-1", "pin-1", rootResult);
+ verify(distanceCacheService).cacheTransitInfo("complex-1", "pin-1", transitInfo);
+ verify(distanceCacheService, never()).getRootResult(any(), any());
+ }
+
+ @Test
+ void getEasyDistance_warmsTransitInfoCacheOnFreshCalculation() throws UnsupportedEncodingException {
+ ComplexDocument complex = mock(ComplexDocument.class);
+ when(complex.getLocation()).thenReturn(Location.of(127.0, 37.5));
+ when(repository.findById("complex-1")).thenReturn(Optional.of(complex));
+
+ PinPoint pinPoint = PinPoint.of("user-1", "서울", "집", 37.6, 127.1, true);
+ when(pinPointService.loadPinPoint("pin-1")).thenReturn(pinPoint);
+
+ PathResult pathResult = mock(PathResult.class);
+ RootResult rootResult = RootResult.builder()
+ .totalTime(25)
+ .totalPayment(1450)
+ .totalDistance(8200)
+ .steps(List.of())
+ .build();
+ TransitInfoResponse transitInfo = TransitInfoResponse.builder()
+ .totalTime("25분")
+ .totalTimeMinutes(25)
+ .totalDistance(8.2)
+ .segments(List.of())
+ .build();
+ List routes = List.of(
+ DistanceResponse.TransitResponse.builder()
+ .type(ChipType.WALK)
+ .build()
+ );
+
+ when(distanceCacheService.getRootResult("complex-1", "pin-1")).thenReturn(null);
+ when(distanceUtil.findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(pathResult);
+ when(pathResult.routes()).thenReturn(List.of(rootResult));
+ when(mapper.selectBest(pathResult)).thenReturn(rootResult);
+ when(mapper.toTransitInfoResponse(rootResult)).thenReturn(transitInfo);
+ when(mapper.from(rootResult)).thenReturn(routes);
+
+ DistanceResponse result = complexService.getEasyDistance("complex-1", "pin-1");
+
+ assertThat(result.totalTimeMinutes()).isEqualTo(25);
+ verify(distanceCacheService).cacheRootResult("complex-1", "pin-1", rootResult);
+ verify(distanceCacheService).cacheTransitInfo("complex-1", "pin-1", transitInfo);
+ }
+}
diff --git a/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java
new file mode 100644
index 00000000..786df37e
--- /dev/null
+++ b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java
@@ -0,0 +1,126 @@
+package co.kr.pinhouse.domain.housing.notice.application.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import co.kr.pinhouse.domain.housing.complex.application.service.DistanceCacheService;
+import co.kr.pinhouse.domain.housing.complex.application.usecase.ComplexUseCase;
+import co.kr.pinhouse.domain.housing.complex.domain.entity.Address;
+import co.kr.pinhouse.domain.housing.complex.domain.entity.ComplexDocument;
+import co.kr.pinhouse.domain.housing.complex.domain.entity.Deposit;
+import co.kr.pinhouse.domain.housing.complex.domain.entity.UnitType;
+import co.kr.pinhouse.domain.housing.facility.application.usecase.FacilityUseCase;
+import co.kr.pinhouse.domain.housing.notice.application.dto.UnitTypeSortType;
+import co.kr.pinhouse.domain.housing.notice.application.dto.response.UnitTypeCompareResponse;
+import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument;
+import co.kr.pinhouse.domain.housing.notice.domain.repository.NoticeDocumentRepository;
+import co.kr.pinhouse.domain.like.application.usecase.LikeQueryUseCase;
+import co.kr.pinhouse.domain.pinpoint.application.usecase.PinPointUseCase;
+
+@ExtendWith(MockitoExtension.class)
+class NoticeServiceTest {
+
+ @Mock
+ private NoticeDocumentRepository repository;
+
+ @Mock
+ private ComplexUseCase complexService;
+
+ @Mock
+ private ComplexFilterService complexFilterService;
+
+ @Mock
+ private LikeQueryUseCase likeService;
+
+ @Mock
+ private FacilityUseCase facilityService;
+
+ @Mock
+ private PinPointUseCase pinPointService;
+
+ @Mock
+ private DistanceCacheService distanceCacheService;
+
+ @InjectMocks
+ private NoticeService noticeService;
+
+ @Test
+ void compareUnitTypes_sortsDepositsGloballyAcrossComplexes() {
+ String noticeId = "notice-1";
+ ComplexDocument complexA = complex("complex-a", "A단지", "서울시 강남구", List.of(
+ unitType("type-1", "30A", 30.0, 1_000_000L),
+ unitType("type-2", "40A", 40.0, 3_000_000L)
+ ));
+ ComplexDocument complexB = complex("complex-b", "B단지", "서울시 관악구", List.of(
+ unitType("type-3", "20A", 20.0, 2_000_000L),
+ unitType("type-4", "25A", 25.0, 2_500_000L)
+ ));
+
+ when(repository.findById(noticeId)).thenReturn(Optional.of(mock(NoticeDocument.class)));
+ when(complexService.loadSortedComplexes(noticeId, UnitTypeSortType.DEPOSIT_ASC))
+ .thenReturn(List.of(complexA, complexB));
+ when(facilityService.getNearFacilities(anyString())).thenReturn(null);
+
+ UnitTypeCompareResponse response =
+ noticeService.compareUnitTypes(noticeId, null, UnitTypeSortType.DEPOSIT_ASC, null, null);
+
+ assertThat(response.unitTypes())
+ .extracting(UnitTypeCompareResponse.UnitTypeComparisonItem::typeId)
+ .containsExactly("type-1", "type-3", "type-4", "type-2");
+ }
+
+ @Test
+ void compareUnitTypes_sortsAreasGloballyAcrossComplexes() {
+ String noticeId = "notice-2";
+ ComplexDocument complexA = complex("complex-a", "A단지", "서울시 강남구", List.of(
+ unitType("type-1", "40A", 40.0, 4_000_000L),
+ unitType("type-2", "20A", 20.0, 1_000_000L)
+ ));
+ ComplexDocument complexB = complex("complex-b", "B단지", "서울시 관악구", List.of(
+ unitType("type-3", "35A", 35.0, 3_000_000L),
+ unitType("type-4", "30A", 30.0, 2_000_000L)
+ ));
+
+ when(repository.findById(noticeId)).thenReturn(Optional.of(mock(NoticeDocument.class)));
+ when(complexService.loadSortedComplexes(noticeId, UnitTypeSortType.AREA_DESC))
+ .thenReturn(List.of(complexA, complexB));
+ when(facilityService.getNearFacilities(anyString())).thenReturn(null);
+
+ UnitTypeCompareResponse response =
+ noticeService.compareUnitTypes(noticeId, null, UnitTypeSortType.AREA_DESC, null, null);
+
+ assertThat(response.unitTypes())
+ .extracting(UnitTypeCompareResponse.UnitTypeComparisonItem::typeId)
+ .containsExactly("type-1", "type-3", "type-4", "type-2");
+ }
+
+ private ComplexDocument complex(String id, String name, String address, List unitTypes) {
+ ComplexDocument complex = mock(ComplexDocument.class);
+ when(complex.getId()).thenReturn(id);
+ when(complex.getName()).thenReturn(name);
+ when(complex.getAddress()).thenReturn(Address.builder().full(address).build());
+ when(complex.getUnitTypes()).thenReturn(unitTypes);
+ return complex;
+ }
+
+ private UnitType unitType(String typeId, String typeCode, double areaM2, long depositTotal) {
+ return UnitType.builder()
+ .typeId(typeId)
+ .typeCode(typeCode)
+ .exclusiveAreaM2(areaM2)
+ .deposit(Deposit.builder().total(depositTotal).build())
+ .group(List.of())
+ .build();
+ }
+}
diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java
index d456a7b8..6f4f7a6a 100644
--- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java
+++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java
@@ -19,6 +19,9 @@
@Component
public class HttpUtil {
+ private static final String BEARER_PREFIX = "Bearer ";
+ private static final String REFRESH_TOKEN_HEADER = "X-Refresh-Token";
+
@Value("${auth.jwt.access.expiration}")
private long accessExpiration;
@@ -39,12 +42,14 @@ public class HttpUtil {
/// 액세스 토큰 쿠키 가져오기
public Optional getAccessToken(HttpServletRequest request) {
- return extractToken(request, ACCESS_TOKEN);
+ return extractBearerAuthorizationToken(request)
+ .or(() -> extractCookieToken(request, ACCESS_TOKEN));
}
/// 리프레쉬 토큰 쿠키 가져오기
public Optional getRefreshToken(HttpServletRequest request) {
- return extractToken(request, REFRESH_TOKEN);
+ return extractHeaderToken(request, REFRESH_TOKEN_HEADER)
+ .or(() -> extractCookieToken(request, REFRESH_TOKEN));
}
/// 액세스 토큰을 쿠키에 저장하기
@@ -98,6 +103,10 @@ public HeaderInfo getClientInfo(HttpServletRequest request) {
return new HeaderInfo(ip, httpMethod, uri, username);
}
+ public String getClientIp(HttpServletRequest request) {
+ return extractClientIp(request);
+ }
+
/// 쿠키 생성하기
private void createCookie(HttpServletResponse response, String cookieName, String cookieValue, long maxAge) {
@@ -117,8 +126,26 @@ private void createCookie(HttpServletResponse response, String cookieName, Strin
// 내부 공통 함수
// =================
+ /// Authorization Bearer 헤더에서 액세스 토큰 가져오기
+ private Optional extractBearerAuthorizationToken(HttpServletRequest request) {
+ return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
+ .map(String::trim)
+ .filter(headerValue -> headerValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()))
+ .map(headerValue -> headerValue.substring(BEARER_PREFIX.length()).trim())
+ .filter(token -> !token.isEmpty());
+ }
+
+ /// 사용자 정의 헤더에서 토큰 가져오기
+ private Optional extractHeaderToken(HttpServletRequest request, String headerName) {
+ return Optional.ofNullable(request.getHeader(headerName))
+ .map(String::trim)
+ .filter(headerValue -> !headerValue.isEmpty())
+ .map(this::stripBearerPrefixIfPresent)
+ .filter(token -> !token.isEmpty());
+ }
+
/// 쿠키에서 토큰 가져오기
- private Optional extractToken(HttpServletRequest httpServletRequest, String type) {
+ private Optional extractCookieToken(HttpServletRequest httpServletRequest, String type) {
/// 쿠키 가져오기
Cookie[] cookies = httpServletRequest.getCookies();
@@ -136,6 +163,13 @@ private Optional extractToken(HttpServletRequest httpServletRequest, Str
return Optional.empty();
}
+ private String stripBearerPrefixIfPresent(String headerValue) {
+ if (!headerValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
+ return headerValue;
+ }
+ return headerValue.substring(BEARER_PREFIX.length()).trim();
+ }
+
/// 공통 쿠키 삭제 메서드
private void deleteCookie(HttpServletResponse response, String cookieName) {
ResponseCookie cookie = ResponseCookie.from(cookieName, "")
@@ -150,7 +184,7 @@ private void deleteCookie(HttpServletResponse response, String cookieName) {
}
/// 요청자의 실제 IP를 조회하기 위한 함수
- private String getClientIp(HttpServletRequest request) {
+ private String extractClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
/// X-Forwarded-For이 있다면
diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java
index 65cf664a..a2b5cad2 100644
--- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java
+++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java
@@ -22,6 +22,9 @@ public class KeyUtil {
private static final String DELIMITER = "-";
/// OAUTH
private static final String OAUTH2_TEMP_USER = "OAUTH2_TEMP_USER:";
+ /// EXCHANGE CODE
+ private static final String EXCHANGE_CODE = "EXCHANGE_CODE:";
+ private static final String EXCHANGE_CODE_RATE_LIMIT = "EXCHANGE_CODE_RATE_LIMIT:";
// =====================
// 합쳐서 사용하는 키 목록
@@ -31,9 +34,31 @@ public static String getRefreshTokenKey(UUID userId) {
return REFRESH_TOKEN + SEPARATOR + userId;
}
+ public static String getExchangeCodeKey(UUID userId) {
+ return EXCHANGE_CODE + userId;
+ }
+
+ public static String getExchangeCodeKey(String tempUserKey) {
+ return EXCHANGE_CODE + tempUserKey;
+ }
+
+ public static String getExchangeCodeRateLimitKey(String clientIdentifier) {
+ return EXCHANGE_CODE_RATE_LIMIT + clientIdentifier;
+ }
+
+ // =====================
+ // 키 생성 함수
+ // =====================
+
/// 키 생성 함수
public String generateOAuth2TempUserKey() {
return OAUTH2_TEMP_USER + UUID.randomUUID();
}
+ /// Exchange code 생성 (암호학적으로 안전한 48자 랜덤 문자열)
+ public String generateExchangeCode() {
+ return UUID.randomUUID().toString().replace("-", "")
+ + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ }
+
}
diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java
index 009b8900..be02301e 100644
--- a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java
+++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java
@@ -5,25 +5,33 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import co.kr.pinhouse.common.response.ApiResponse;
import co.kr.pinhouse.common.util.HttpUtil;
+import co.kr.pinhouse.security.auth.application.dto.request.ExchangeCodeRequest;
+import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse;
+import co.kr.pinhouse.security.auth.application.service.ExchangeCodeRateLimitService;
import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase;
import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse;
import co.kr.pinhouse.security.principal.AuthenticatedUser;
import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+@Slf4j
@RestController
@RequestMapping("/v1/auth")
@RequiredArgsConstructor
public class AuthApi implements AuthApiSpec {
private final AuthUseCase service;
+ private final ExchangeCodeRateLimitService exchangeCodeRateLimitService;
/// HTTP 서비스
private final HttpUtil httpUtil;
@@ -32,13 +40,30 @@ public class AuthApi implements AuthApiSpec {
// 퍼블릭 로직
// =================
+ /**
+ * Exchange code를 JWT 토큰으로 교환
+ */
+ @PostMapping("/exchange")
+ public ApiResponse exchangeCode(
+ HttpServletRequest httpServletRequest,
+ @RequestBody @Valid ExchangeCodeRequest request
+ ) {
+ String clientIp = httpUtil.getClientIp(httpServletRequest);
+ exchangeCodeRateLimitService.validateRequestAllowed(clientIp);
+
+ log.info("공개 토큰 교환 요청 - clientIp: {}, code: {}...",
+ clientIp, request.code().substring(0, Math.min(8, request.code().length())));
+
+ AuthExchangeResponse response = service.exchangeCode(request.code());
+ return ApiResponse.ok(response);
+ }
+
/**
* 로그아웃
*/
@DeleteMapping
public ApiResponse logout(
HttpServletRequest httpServletRequest,
- HttpServletResponse httpServletResponse,
@AuthenticationPrincipal AuthenticatedUser principalDetails) {
/// 리프레쉬 토큰 까보기
@@ -47,9 +72,7 @@ public ApiResponse logout(
/// 서비스 로직 실행
service.logout(principalDetails.getId(), refreshToken);
- /// 쿠키 삭제하기
- httpUtil.removeAccessTokenCookie(httpServletResponse);
- httpUtil.removeRefreshTokenCookie(httpServletResponse);
+ /// 쿠키 삭제는 Next가 처리
/// 리턴
return ApiResponse.deleted();
@@ -59,9 +82,8 @@ public ApiResponse logout(
* 토큰 재발급
*/
@PutMapping
- public ApiResponse reissue(
- HttpServletRequest httpServletRequest,
- HttpServletResponse httpServletResponse
+ public ApiResponse reissue(
+ HttpServletRequest httpServletRequest
) {
/// 리프레쉬 토큰 까보기
@@ -70,12 +92,9 @@ public ApiResponse reissue(
/// 서비스 로직 실행
JwtTokenResponse response = service.reissue(refreshToken);
- /// 토큰 재발급하기 (액세스/리프레쉬)
- httpUtil.addAccessTokenCookie(httpServletResponse, response.accessToken());
- httpUtil.addRefreshTokenCookie(httpServletResponse, response.refreshToken());
-
- /// 리턴
- return ApiResponse.updated();
+ /// 토큰을 응답 바디로 반환
+ /// Next가 쿠키로 설정
+ return ApiResponse.ok(response);
}
/**
diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java
index 8744b632..1a0c9efa 100644
--- a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java
+++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java
@@ -1,17 +1,31 @@
package co.kr.pinhouse.domain.auth;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.RequestBody;
import co.kr.pinhouse.common.response.ApiResponse;
+import co.kr.pinhouse.security.auth.application.dto.request.ExchangeCodeRequest;
+import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse;
+import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse;
import co.kr.pinhouse.security.principal.AuthenticatedUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
@Tag(name = "인증 API", description = "로그아웃/액세스 토큰 재발급을 수행하는 API입니다")
public interface AuthApiSpec {
+ /// Exchange code 교환
+ @Operation(
+ summary = "Exchange Code 교환 API",
+ description = "OAuth2 또는 회원가입 이후 받은 Exchange Code를 처리하여 토큰 발급 또는 회원가입 필요 상태를 반환합니다."
+ )
+ ApiResponse exchangeCode(
+ HttpServletRequest httpServletRequest,
+ @RequestBody @Valid ExchangeCodeRequest request
+ );
+
/// 로그아웃
@Operation(
summary = "로그아웃 API",
@@ -19,7 +33,6 @@ public interface AuthApiSpec {
)
ApiResponse logout(
HttpServletRequest httpServletRequest,
- HttpServletResponse httpServletResponse,
@AuthenticationPrincipal AuthenticatedUser customUserDetails);
/// 토큰 재발급
@@ -27,9 +40,8 @@ ApiResponse logout(
summary = "액세스토큰 재발급 API",
description = "액세스 토큰을 재발급받는 API"
)
- ApiResponse reissue(
- HttpServletRequest httpServletRequest,
- HttpServletResponse httpServletResponse
+ ApiResponse reissue(
+ HttpServletRequest httpServletRequest
);
/// 액세 토큰 여부 체크
diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java
index 128af58b..c560576f 100644
--- a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java
+++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java
@@ -20,6 +20,7 @@
import co.kr.pinhouse.domain.user.application.dto.request.UserRequest;
import co.kr.pinhouse.domain.user.application.dto.request.WithdrawRequest;
import co.kr.pinhouse.domain.user.application.dto.response.MyPageResponse;
+import co.kr.pinhouse.domain.user.application.dto.response.SignupResponse;
import co.kr.pinhouse.domain.user.application.dto.response.TempUserResponse;
import co.kr.pinhouse.domain.user.application.dto.response.UserResponse;
import co.kr.pinhouse.domain.user.application.usecase.UserUseCase;
@@ -28,7 +29,9 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+@Slf4j
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
@@ -48,22 +51,25 @@ public ApiResponse getUser(@RequestParam String tempKey) {
return ApiResponse.ok(service.getUserByKey(tempKey));
}
- /// 회원가입 (JWT 토큰 발급은 security 모듈/app 모듈에서 처리 필요)
+ /// 회원가입 (JWT 토큰 직접 발급)
@PostMapping()
- public ApiResponse signUp(HttpServletResponse httpServletResponse,
+ public ApiResponse signUp(
@RequestParam String tempKey,
@RequestBody @Valid UserRequest request) {
/// 서비스 - User 엔티티 반환
var user = service.saveUser(tempKey, request);
- /// 회원가입 직후 즉시 로그인 상태가 되도록 토큰 발급
+ /// JWT 토큰 직접 발급
var tokenResponse = authUseCase.issueTokens(user);
- httpUtil.addAccessTokenCookie(httpServletResponse, tokenResponse.accessToken());
- httpUtil.addRefreshTokenCookie(httpServletResponse, tokenResponse.refreshToken());
+
+ log.info("회원가입 완료 - userId: {}, 토큰 발급 완료", user.getId());
/// 리턴
- return ApiResponse.created();
+ return ApiResponse.ok(SignupResponse.of(
+ tokenResponse.accessToken(),
+ tokenResponse.refreshToken()
+ ));
}
/// 나의 정보 조회하기
diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java
index e4b534ad..43e0e88e 100644
--- a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java
+++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java
@@ -12,6 +12,7 @@
import co.kr.pinhouse.domain.user.application.dto.request.UserRequest;
import co.kr.pinhouse.domain.user.application.dto.request.WithdrawRequest;
import co.kr.pinhouse.domain.user.application.dto.response.MyPageResponse;
+import co.kr.pinhouse.domain.user.application.dto.response.SignupResponse;
import co.kr.pinhouse.domain.user.application.dto.response.TempUserResponse;
import co.kr.pinhouse.domain.user.application.dto.response.UserResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -26,9 +27,9 @@ public interface UserApiSpec {
/// 회원가입
@Operation(
summary = "회원가입 API",
- description = "온보딩의 데이터를 바탕으로 회원가입합니다."
+ description = "온보딩의 데이터를 바탕으로 회원가입하고 JWT 토큰을 발급합니다. 응답으로 accessToken과 refreshToken을 반환합니다."
)
- ApiResponse signUp(HttpServletResponse httpServletResponse,
+ ApiResponse signUp(
@RequestParam String tempKey,
@RequestBody @Valid UserRequest request);
diff --git a/module-security/build.gradle b/module-security/build.gradle
index 64897785..cc8ea948 100644
--- a/module-security/build.gradle
+++ b/module-security/build.gradle
@@ -7,6 +7,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework:spring-tx'
compileOnly 'jakarta.persistence:jakarta.persistence-api'
compileOnly 'org.springframework.data:spring-data-jpa'
@@ -15,7 +16,12 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+ // Swagger/OpenAPI for DTO annotations
+ implementation 'org.springdoc:springdoc-openapi-starter-common:2.8.6'
+
testImplementation 'org.springframework.security:spring-security-test'
+ testImplementation 'jakarta.persistence:jakarta.persistence-api'
+ testImplementation 'org.springframework.data:spring-data-jpa'
}
bootJar {
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java
new file mode 100644
index 00000000..3e739e9f
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java
@@ -0,0 +1,18 @@
+package co.kr.pinhouse.security.auth.application.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Builder;
+
+@Builder
+public record ExchangeCodeRequest(
+ @NotBlank(message = "Exchange Code는 비어 있을 수 없습니다.")
+ String code
+) {
+
+ /// 정적 팩토리 메서드
+ public static ExchangeCodeRequest of(String code) {
+ return ExchangeCodeRequest.builder()
+ .code(code)
+ .build();
+ }
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java
new file mode 100644
index 00000000..7136d5d2
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java
@@ -0,0 +1,39 @@
+package co.kr.pinhouse.security.auth.application.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Schema(name = "[응답][인증] Exchange Code 교환 응답", description = "Exchange Code 해석 결과를 반환합니다.")
+@Builder
+public record AuthExchangeResponse(
+ @Schema(description = "Exchange Code 처리 결과", example = "TOKEN_ISSUED")
+ AuthExchangeResultType result,
+
+ @Schema(description = "발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9...")
+ String accessToken,
+
+ @Schema(description = "발급된 리프레쉬 토큰", example = "eyJhbGciOiJIUzI1NiJ9...")
+ String refreshToken,
+
+ @Schema(description = "회원가입이 필요한 경우 사용할 임시 키", example = "OAUTH2_TEMP_USER:1234...")
+ String tempKey
+) {
+
+ public static AuthExchangeResponse tokenIssued(String accessToken, String refreshToken) {
+ return AuthExchangeResponse.builder()
+ .result(AuthExchangeResultType.TOKEN_ISSUED)
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+
+ public static AuthExchangeResponse signupRequired(String tempKey) {
+ return AuthExchangeResponse.builder()
+ .result(AuthExchangeResultType.SIGNUP_REQUIRED)
+ .tempKey(tempKey)
+ .build();
+ }
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java
new file mode 100644
index 00000000..e2b05876
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java
@@ -0,0 +1,6 @@
+package co.kr.pinhouse.security.auth.application.dto.response;
+
+public enum AuthExchangeResultType {
+ TOKEN_ISSUED,
+ SIGNUP_REQUIRED
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java
index 293dfe68..68b73050 100644
--- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java
@@ -1,23 +1,34 @@
package co.kr.pinhouse.security.auth.application.service;
+import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import co.kr.pinhouse.common.exception.code.SecurityErrorCode;
import co.kr.pinhouse.common.response.CustomException;
+import co.kr.pinhouse.common.util.KeyUtil;
import co.kr.pinhouse.domain.user.domain.entity.User;
+import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo;
import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository;
+import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse;
import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase;
import co.kr.pinhouse.security.jwt.application.dto.request.JwtTokenRequest;
import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse;
import co.kr.pinhouse.security.jwt.application.util.JwtProvider;
import co.kr.pinhouse.security.jwt.application.util.JwtValidator;
+import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCode;
+import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCodeType;
import co.kr.pinhouse.security.jwt.domain.entity.JwtRefreshToken;
+import co.kr.pinhouse.security.jwt.domain.repository.JwtExchangeCodeRepository;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
@@ -30,6 +41,17 @@ public class AuthService implements AuthUseCase {
private final JwtValidator jwtValidator;
private final JwtProvider jwtProvider;
+ /// Exchange code 저장소
+ private final JwtExchangeCodeRepository exchangeCodeRepository;
+ private final RedisTemplate redisTemplate;
+ private final KeyUtil keyUtil;
+
+ @Value("${auth.exchange.code.ttl-seconds:60}")
+ private long exchangeCodeTtlSeconds;
+
+ @Value("${auth.oauth2.temp-user.ttl-minutes:5}")
+ private long tempUserTtlMinutes;
+
// =================
// 퍼블릭 로직
// =================
@@ -84,6 +106,29 @@ public boolean checkToken(Optional optionalAccessToken) {
return true;
}
+ @Override
+ @Transactional
+ public String createExchangeCode(User user) {
+ String exchangeCode = keyUtil.generateExchangeCode();
+ JwtExchangeCode codeEntity = JwtExchangeCode.tokenIssuable(user.getId(), exchangeCode, exchangeCodeTtlSeconds);
+ exchangeCodeRepository.save(codeEntity);
+
+ return exchangeCode;
+ }
+
+ @Override
+ @Transactional
+ public String createSignupExchangeCode(TempUserInfo tempUserInfo) {
+ String tempUserKey = keyUtil.generateOAuth2TempUserKey();
+ redisTemplate.opsForValue().set(tempUserKey, tempUserInfo, Duration.ofMinutes(tempUserTtlMinutes));
+
+ String exchangeCode = keyUtil.generateExchangeCode();
+ JwtExchangeCode codeEntity = JwtExchangeCode.signupRequired(tempUserKey, exchangeCode, exchangeCodeTtlSeconds);
+ exchangeCodeRepository.save(codeEntity);
+
+ return exchangeCode;
+ }
+
/// 토큰 재발급
@Override
@Transactional
@@ -107,4 +152,36 @@ public JwtTokenResponse reissue(Optional refreshToken) {
return issueTokens(user);
}
+ /// Exchange code 처리
+ @Override
+ @Transactional
+ public AuthExchangeResponse exchangeCode(String exchangeCode) {
+
+ /// Exchange code 검증 및 조회
+ JwtExchangeCode codeEntity = exchangeCodeRepository.findByExchangeCode(exchangeCode)
+ .orElseThrow(() -> new CustomException(SecurityErrorCode.EXCHANGE_CODE_INVALID));
+
+ /// Exchange code 즉시 삭제 (one-time 보장)
+ exchangeCodeRepository.delete(codeEntity);
+
+ if (codeEntity.getExchangeCodeType() == JwtExchangeCodeType.SIGNUP_REQUIRED) {
+ log.info("Exchange code 사용 완료 - signup required, tempKey: {}", codeEntity.getTempUserKey());
+ return AuthExchangeResponse.signupRequired(codeEntity.getTempUserKey());
+ }
+
+ if (codeEntity.getExchangeCodeType() != JwtExchangeCodeType.TOKEN_ISSUABLE) {
+ throw new CustomException(SecurityErrorCode.EXCHANGE_CODE_INVALID);
+ }
+
+ /// 사용자 조회
+ User user = repository.findById(codeEntity.getUserId())
+ .orElseThrow(() -> new CustomException(SecurityErrorCode.NOT_FOUND_ID));
+
+ log.info("Exchange code 사용 완료 - userId: {}", user.getId());
+
+ /// 토큰 발급
+ JwtTokenResponse tokenResponse = issueTokens(user);
+ return AuthExchangeResponse.tokenIssued(tokenResponse.accessToken(), tokenResponse.refreshToken());
+ }
+
}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java
new file mode 100644
index 00000000..b02ce087
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java
@@ -0,0 +1,54 @@
+package co.kr.pinhouse.security.auth.application.service;
+
+import java.time.Duration;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import co.kr.pinhouse.common.exception.code.SecurityErrorCode;
+import co.kr.pinhouse.common.response.CustomException;
+import co.kr.pinhouse.common.util.KeyUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExchangeCodeRateLimitService {
+
+ private final RedisTemplate redisTemplate;
+
+ @Value("${auth.exchange.rate-limit.max-attempts:10}")
+ private long maxAttempts;
+
+ @Value("${auth.exchange.rate-limit.window-seconds:60}")
+ private long windowSeconds;
+
+ public void validateRequestAllowed(String clientIdentifier) {
+ String normalizedClientIdentifier = normalizeClientIdentifier(clientIdentifier);
+ String key = KeyUtil.getExchangeCodeRateLimitKey(normalizedClientIdentifier);
+ Long requestCount = redisTemplate.opsForValue().increment(key);
+
+ if (requestCount == null) {
+ throw new IllegalStateException("Exchange Code rate limit 카운트 증가에 실패했습니다.");
+ }
+
+ if (requestCount == 1L) {
+ redisTemplate.expire(key, Duration.ofSeconds(windowSeconds));
+ }
+
+ if (requestCount > maxAttempts) {
+ log.warn("Exchange Code 교환 요청 제한 초과 - clientIdentifier: {}, count: {}",
+ normalizedClientIdentifier, requestCount);
+ throw new CustomException(SecurityErrorCode.EXCHANGE_CODE_RATE_LIMITED);
+ }
+ }
+
+ private String normalizeClientIdentifier(String clientIdentifier) {
+ if (clientIdentifier == null || clientIdentifier.isBlank()) {
+ return "unknown";
+ }
+ return clientIdentifier.trim();
+ }
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java
index e07928d7..f6c0004b 100644
--- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java
+++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java
@@ -4,6 +4,8 @@
import java.util.UUID;
import co.kr.pinhouse.domain.user.domain.entity.User;
+import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo;
+import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse;
import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse;
public interface AuthUseCase {
@@ -20,4 +22,13 @@ public interface AuthUseCase {
/// 토큰 여부 판단하기
boolean checkToken(Optional accessToken);
+ /// 기존 유저용 Exchange code 생성
+ String createExchangeCode(User user);
+
+ /// 회원가입 필요 유저용 Exchange code 생성
+ String createSignupExchangeCode(TempUserInfo tempUserInfo);
+
+ /// Exchange code 처리
+ AuthExchangeResponse exchangeCode(String exchangeCode);
+
}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java
new file mode 100644
index 00000000..5394defd
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java
@@ -0,0 +1,69 @@
+package co.kr.pinhouse.security.jwt.domain.entity;
+
+import static co.kr.pinhouse.common.util.KeyUtil.getExchangeCodeKey;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.redis.core.RedisHash;
+import org.springframework.data.redis.core.TimeToLive;
+import org.springframework.data.redis.core.index.Indexed;
+
+import jakarta.persistence.Id;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@RedisHash
+public class JwtExchangeCode {
+
+ @Id
+ private String id;
+
+ @Indexed
+ private UUID userId;
+
+ @Indexed
+ private String tempUserKey;
+
+ @Indexed
+ private String exchangeCode;
+
+ private JwtExchangeCodeType exchangeCodeType;
+
+ @TimeToLive(unit = TimeUnit.SECONDS)
+ private long expireTime;
+
+ /// 빌더 생성자
+ @Builder
+ public JwtExchangeCode(String id, UUID userId, String tempUserKey, String exchangeCode,
+ JwtExchangeCodeType exchangeCodeType, long expireTime) {
+ this.id = id;
+ this.userId = userId;
+ this.tempUserKey = tempUserKey;
+ this.exchangeCode = exchangeCode;
+ this.exchangeCodeType = exchangeCodeType;
+ this.expireTime = expireTime;
+ }
+
+ public static JwtExchangeCode tokenIssuable(UUID userId, String exchangeCode, long expireTime) {
+ return JwtExchangeCode.builder()
+ .id(getExchangeCodeKey(userId))
+ .userId(userId)
+ .exchangeCode(exchangeCode)
+ .expireTime(expireTime)
+ .exchangeCodeType(JwtExchangeCodeType.TOKEN_ISSUABLE)
+ .build();
+ }
+
+ public static JwtExchangeCode signupRequired(String tempUserKey, String exchangeCode, long expireTime) {
+ return JwtExchangeCode.builder()
+ .id(getExchangeCodeKey(tempUserKey))
+ .tempUserKey(tempUserKey)
+ .exchangeCode(exchangeCode)
+ .expireTime(expireTime)
+ .exchangeCodeType(JwtExchangeCodeType.SIGNUP_REQUIRED)
+ .build();
+ }
+
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java
new file mode 100644
index 00000000..049693e3
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java
@@ -0,0 +1,6 @@
+package co.kr.pinhouse.security.jwt.domain.entity;
+
+public enum JwtExchangeCodeType {
+ TOKEN_ISSUABLE,
+ SIGNUP_REQUIRED
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java
new file mode 100644
index 00000000..10dc3e51
--- /dev/null
+++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java
@@ -0,0 +1,13 @@
+package co.kr.pinhouse.security.jwt.domain.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.repository.CrudRepository;
+
+import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCode;
+
+public interface JwtExchangeCodeRepository extends CrudRepository {
+
+ /// Exchange code로 조회
+ Optional findByExchangeCode(String exchangeCode);
+}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java
index aa7fc7d6..e69f76d2 100644
--- a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java
+++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java
@@ -39,6 +39,7 @@ public class RequestMatcherHolder {
new RequestInfo(POST, "/v1/auth/dev", null), /// 개발용 토큰
// auth
+ new RequestInfo(POST, "/v1/auth/exchange", null), /// exchange code 교환
new RequestInfo(DELETE, "/v1/auth", Role.USER), /// 로그아웃
new RequestInfo(PUT, "/v1/auth", null), /// 재발급
new RequestInfo(GET, "/v1/auth", null), /// 토큰 여부 체크
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java
index 256ec2ec..f13356ff 100644
--- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java
+++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java
@@ -1,16 +1,14 @@
package co.kr.pinhouse.security.oauth2.handler;
import java.io.IOException;
-import java.time.Duration;
-import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
-import co.kr.pinhouse.common.util.KeyUtil;
import co.kr.pinhouse.common.util.RedirectUrlResolver;
import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo;
+import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -22,9 +20,7 @@
@RequiredArgsConstructor
public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler {
- /// 레디스 의존성 도입
- private final RedisTemplate redisTemplate;
- private final KeyUtil keyUtil;
+ private final AuthUseCase authUseCase;
private final RedirectUrlResolver redirectUrlResolver;
/**
@@ -37,22 +33,29 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo
/// SignupRequiredException 예외처리 받았으면 실행
if (authException instanceof SignupRequiredException) {
- /// 레디스로 회원가입 키 생성
- String tempUserKey = keyUtil.generateOAuth2TempUserKey();
-
/// 임시 유저 처리
TempUserInfo userInfo = ((SignupRequiredException)authException).getUserInfo();
- /// 레디스에 값 추가( 유효시간 5분 )
- redisTemplate.opsForValue().set(tempUserKey, userInfo, Duration.ofMinutes(5));
+ /// 회원가입 필요 상태를 나타내는 Exchange code 생성
+ String exchangeCode = authUseCase.createSignupExchangeCode(userInfo);
/// 동적으로 리다이렉트 URL 결정
- String extraInfoUrl = redirectUrlResolver.resolveRedirectUrlWithPath(request, "/signup?state=" + tempUserKey);
+ String callbackUrl = buildCallbackUrl(request, exchangeCode);
/// 응답을 리다이렉트
- response.sendRedirect(extraInfoUrl);
+ response.sendRedirect(callbackUrl);
} else {
response.sendRedirect("/login?error");
}
}
+
+ private String buildCallbackUrl(HttpServletRequest request, String exchangeCode) {
+ String baseUrl = resolveCallbackUrl(request);
+ return baseUrl + "?code=" + exchangeCode;
+ }
+
+ private String resolveCallbackUrl(HttpServletRequest request) {
+ String frontUrl = redirectUrlResolver.resolveRedirectUrl(request);
+ return frontUrl + "/api/auth/callback";
+ }
}
diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java
index 4c59f1e1..f8910506 100644
--- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java
+++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java
@@ -7,11 +7,9 @@
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
-import co.kr.pinhouse.common.util.HttpUtil;
import co.kr.pinhouse.common.util.RedirectUrlResolver;
import co.kr.pinhouse.domain.user.domain.entity.User;
import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase;
-import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse;
import co.kr.pinhouse.security.principal.PrincipalDetails;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -25,12 +23,10 @@
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final AuthUseCase authUseCase;
- private final HttpUtil httpUtil;
private final RedirectUrlResolver redirectUrlResolver;
/*
- 기존에 존재하는 유저의 경우, 토큰 발급을 진행합니다.
- 리다이렉트 시킵니다.
+ 기존에 존재하는 유저의 경우, Exchange code를 발급하고 BFF로 리다이렉트합니다.
*/
@Override
@@ -42,23 +38,32 @@ public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpS
PrincipalDetails principal = (PrincipalDetails)authentication.getPrincipal();
User user = principal.getUser();
- /// Access, Refresh 토큰 생성
- JwtTokenResponse tokenResponse = authUseCase.issueTokens(user);
-
- /// HTTP 쿠키 추가
- httpUtil.addAccessTokenCookie(httpServletResponse, tokenResponse.accessToken());
- httpUtil.addRefreshTokenCookie(httpServletResponse, tokenResponse.refreshToken());
+ /// Exchange code 생성
+ String exchangeCode = authUseCase.createExchangeCode(user);
/// 시큐리티 홀더에 해당 멤버 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
- /// 동적으로 리다이렉트 URL 결정
- String redirectUrl = redirectUrlResolver.resolveRedirectUrl(httpServletRequest);
+ /// BFF 콜백 URL 생성
+ String redirectUrl = buildBffCallbackUrl(httpServletRequest, exchangeCode);
+
+ log.info("OAuth2 인증 성공 - userId: {}, BFF 리다이렉트: {}", user.getId(), redirectUrl);
- /// 쿠키와 함께 리다이렉트 (프론트 홈 주소)
+ /// BFF로 리다이렉트 (exchange code 포함)
getRedirectStrategy().sendRedirect(httpServletRequest, httpServletResponse, redirectUrl);
} catch (Exception e) {
- log.error("OAuth2 회원가입 진행중 에러 발생", e);
+ log.error("OAuth2 인증 처리 중 에러 발생", e);
+ throw e;
}
}
+
+ private String buildBffCallbackUrl(HttpServletRequest request, String exchangeCode) {
+ String baseUrl = resolveBffCallbackUrl(request);
+ return baseUrl + "?code=" + exchangeCode;
+ }
+
+ private String resolveBffCallbackUrl(HttpServletRequest request) {
+ String frontUrl = redirectUrlResolver.resolveRedirectUrl(request);
+ return frontUrl + "/api/auth/callback";
+ }
}