From 5cf6d6f674a61bead5036ab11efed645dbfe8b3e Mon Sep 17 00:00:00 2001 From: gene Date: Wed, 9 Sep 2026 14:24:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9E=90=EA=B2=A9=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=EC=97=90=20=EC=A7=80=EC=97=AD(=EC=8B=9C=C2=B7?= =?UTF-8?q?=EB=8F=84)=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 시·도 대상 제도(welfare_program.region_code != null)는 거주 시·도가 같아야 적격이고, 같으면 matchedCriteria에 region을 남긴다. 전국·중앙부처 제도(region_code == null)는 지역을 보지 않는다. 유저 region_code는 환경마다 형식이 달라(시드=법정동 코드, 온보딩="시도-시군구" 문자열) 신뢰할 수 없어, region_name을 RegionCodeResolver로 시도 2자리로 다시 풀어 비교한다. 거주지 미등록이면 지역 한정 제도는 부적격. 프론트 CRITERIA_LABEL에 이미 region 라벨이 있어 별도 변경 없음. Co-Authored-By: Claude Co-Authored-By: Claude Sonnet 5 --- .../eligibility/EligibilityEvaluator.java | 25 ++++++++++-- .../eligibility/EligibilityEvaluatorTest.java | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java index a7c46cd..6720a3b 100644 --- a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java +++ b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java @@ -1,6 +1,7 @@ package com.dday.domain.welfare.collector.eligibility; import com.dday.domain.user.entity.User; +import com.dday.domain.welfare.collector.curation.RegionCodeResolver; import com.dday.domain.welfare.entity.ProtectionPhase; import com.dday.domain.welfare.entity.WelfareProgram; import org.springframework.stereotype.Component; @@ -21,10 +22,12 @@ * 청년월세·청년적금처럼 자립준비청년이 아니어도 받는 제도가 여기 해당한다 * * - *

일반 제도를 "적격"으로 내보내는 건 낙관적이다 — 나이·소득·지역을 아직 못 본다 - * ({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가, 지역은 코드↔이름 - * 매핑 테이블이 필요하다). 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내 - * 사용자에게 되묻는다. 정밀 판정은 후속. + *

지역은 시·도 단위로 본다: 제도가 특정 시·도 대상({@code region_code != null})이면 거주 + * 시·도가 같아야 적격이고, 같으면 {@code region}을 근거로 남긴다. 전국·중앙부처 제도 + * ({@code region_code == null})는 지역을 보지 않는다. 시·군·구 정밀도와 나이·소득은 아직 못 본다 + * ({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가 필요하다). + * 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내 사용자에게 되묻는다. + * 정밀 판정은 후속. */ @Component public class EligibilityEvaluator { @@ -42,12 +45,26 @@ public EligibilityResult evaluate(User user, WelfareProgram program) { "자립준비청년(보호종료) 대상 제도입니다. 보호종료일을 등록하면 매칭됩니다."); } + // 지역 게이트: 특정 시·도 대상 제도(region_code != null)면 거주 시·도가 같아야 한다. + // 유저 region_code 형식이 환경마다 달라(시드는 법정동 코드, 온보딩은 "시도-시군구" 문자열) + // 코드 대신 region_name을 시도 2자리로 다시 풀어 비교한다. + String programRegion = program.getRegionCode(); + boolean regionScoped = programRegion != null && !programRegion.isBlank(); + if (regionScoped + && !programRegion.equals(RegionCodeResolver.resolve(user.getRegionName(), null))) { + return EligibilityResult.ineligible(matched, + "%s 거주자 대상 제도입니다.".formatted(program.getCtpvNm())); + } + if (careLeaver) { matched.add("protectionEndDate"); if (phase != null) { matched.add("protectionPhase"); } } + if (regionScoped) { + matched.add("region"); + } return EligibilityResult.eligible(matched); } } diff --git a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java index fc9c9bc..8cc688e 100644 --- a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java +++ b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java @@ -57,9 +57,49 @@ class EligibilityEvaluatorTest { assertThat(result.matchedCriteria()).containsExactly("protectionEndDate"); } + @Test + void 특정_시도_대상_제도는_거주_시도가_다르면_부적격() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "경기도"), seoulOnly); + + assertThat(result.eligible()).isFalse(); + assertThat(result.ineligibleReason()).contains("서울특별시"); + } + + @Test + void 특정_시도_대상_제도는_거주_시도가_같으면_region을_근거로_남긴다() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "서울특별시"), seoulOnly); + + assertThat(result.eligible()).isTrue(); + assertThat(result.matchedCriteria()).containsExactly("region"); + } + + @Test + void 거주지_미등록이면_특정_시도_대상_제도는_부적격() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null), seoulOnly); + + assertThat(result.eligible()).isFalse(); + } + private static User user(LocalDate protectionEndDate) { + return user(protectionEndDate, null); + } + + private static User user(LocalDate protectionEndDate, String regionName) { User u = BeanUtils.instantiateClass(User.class); ReflectionTestUtils.setField(u, "protectionEndDate", protectionEndDate); + ReflectionTestUtils.setField(u, "regionName", regionName); return u; } From 21e5bd3b4d1ed5a78965defc3826ea4a5016e00d Mon Sep 17 00:00:00 2001 From: gene Date: Wed, 9 Sep 2026 14:55:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=EC=9D=84=20=EC=8B=9C=EA=B5=B0=EA=B5=AC(=EC=9A=A9?= =?UTF-8?q?=EC=82=B0=C2=B7=EC=84=9C=EB=8C=80=EB=AC=B8)=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EA=B9=8C=EC=A7=80=20=EB=82=B4=EB=A6=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=91=EB=91=90=20=EB=B9=84=EA=B5=90=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegionCodeResolver에 SIGUNGU 표를 추가해 서울 용산구·서대문구는 법정동 10자리로 푼다 (프론트 stores/onboarding.ts HARDCODED_REGION_CODES와 동일). 나머지는 기존대로 시도 2자리. QualityReportTasklet이 매 수집마다 refreshRegionCode를 전 행에 호출하므로 이미 저장된 sgg_nm=용산구/서대문구 행도 다음 수집에 10자리로 바뀐다. EligibilityEvaluator는 유저 region_code(형식이 들쭉날쭉)를 안 쓰고 region_name+district_name을 RegionCodeResolver로 다시 푼 뒤, 제도 코드가 유저 코드의 접두인지로 포함 여부를 판정한다: 제도 "11"(서울) ⊃ 유저 "1117000000"(용산) → 매칭 제도 "1141000000"(서대문) vs 유저 "1117000000" → 불일치 시도만 아는 거주자는 시군구 한정 제도를 확정할 수 없어 부적격. Co-Authored-By: Claude Co-Authored-By: Claude Sonnet 5 --- .../curation/RegionCodeResolver.java | 36 +++++++++++++++---- .../eligibility/EligibilityEvaluator.java | 36 ++++++++++--------- .../curation/RegionCodeResolverTest.java | 12 +++++-- .../eligibility/EligibilityEvaluatorTest.java | 32 ++++++++++++++++- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java b/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java index e8d4ed0..af3fb6e 100644 --- a/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java +++ b/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java @@ -6,9 +6,9 @@ * 제도의 지역명({@code ctpvNm} 시도 / {@code sggNm} 시군구)을 법정동 코드로 바꾼다. * 결과는 {@code welfare_program.region_code}에 들어가고, 나중에 유저 거주지와 대조하는 데 쓴다. * - *

v1 — 시도 단위(2자리)만. 시군구 5자리 정밀도는 자격 매칭이 실제로 붙을 때 넣는다. - * 지금은 이 값을 읽는 쪽이 없어서(매칭 미구현), "용산구 사업"이 "서울(11)"로 풀려도 문제가 없다. - * 분해능을 올리면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다. + *

시도 2자리가 기본, 일부 시군구만 10자리. 자격 매칭이 먼저 붙는 용산구·서대문구만 + * {@link #SIGUNGU}에 10자리로 넣고 나머지는 시도로 떨어뜨린다. 대상 구가 늘거나 전국 매핑 + * 테이블이 생기면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다. * *

표에 없는 시도명(오타·신설 미반영)은 {@code null}을 돌려주고, LOCAL 제도가 이러면 * {@link WelfareIssue#REGION_UNRESOLVED}로 리뷰 큐에 올린다 — 값을 추측해 넣지 않는다. @@ -42,18 +42,42 @@ public final class RegionCodeResolver { Map.entry("강원도", "51"), Map.entry("전라북도", "52")); + /** + * {@code "시도|시군구"} → 법정동 10자리 코드. 지역 매칭이 시군구 단위로 붙는 구만 넣는다. + * 여기 없으면 {@link #resolve}는 시도 2자리로 떨어진다. + * + *

프론트 {@code stores/onboarding.ts}의 {@code HARDCODED_REGION_CODES}와 값이 같아야 한다. + * 유저는 그 코드로, 제도는 이 표로 각각 채워져 자격 판별이 두 값을 접두 비교한다. + * 전국 법정동 매핑 테이블이 생기면 이 표와 프론트 하드코딩을 둘 다 지운다. + */ + private static final Map SIGUNGU = Map.of( + "서울특별시|용산구", "1117000000", + "서울특별시|서대문구", "1141000000"); + private RegionCodeResolver() { } /** + * 지역명을 법정동 코드로 푼다. {@link #SIGUNGU}에 있는 시군구면 10자리, 아니면 시도 2자리. + * * @param ctpvNm 시도명. {@code null}이면(CENTRAL/전국) {@code null} 반환 - * @param sggNm 시군구명. v1에서는 쓰지 않는다 (시도 단위 코드만) - * @return 법정동 2자리 시도 코드, 또는 못 풀면 {@code null} + * @param sggNm 시군구명. {@link #SIGUNGU}에 등록된 곳만 10자리로, 나머지는 무시 + * @return 법정동 코드(2자리 또는 10자리), 또는 못 풀면 {@code null} */ public static String resolve(String ctpvNm, String sggNm) { if (ctpvNm == null || ctpvNm.isBlank()) { return null; } - return SIDO.get(ctpvNm.strip()); + String sido = SIDO.get(ctpvNm.strip()); + if (sido == null) { + return null; + } + if (sggNm != null && !sggNm.isBlank()) { + String sigungu = SIGUNGU.get(ctpvNm.strip() + "|" + sggNm.strip()); + if (sigungu != null) { + return sigungu; + } + } + return sido; } } diff --git a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java index 6720a3b..a1c62b1 100644 --- a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java +++ b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java @@ -22,10 +22,11 @@ * 청년월세·청년적금처럼 자립준비청년이 아니어도 받는 제도가 여기 해당한다 * * - *

지역은 시·도 단위로 본다: 제도가 특정 시·도 대상({@code region_code != null})이면 거주 - * 시·도가 같아야 적격이고, 같으면 {@code region}을 근거로 남긴다. 전국·중앙부처 제도 - * ({@code region_code == null})는 지역을 보지 않는다. 시·군·구 정밀도와 나이·소득은 아직 못 본다 - * ({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가 필요하다). + *

지역은 법정동 코드 접두 비교로 본다: 지역 한정 제도({@code region_code != null})는 거주지가 + * 그 코드 범위 안에 들어야 적격이고({@code userCode.startsWith(programCode)}), 들면 {@code region}을 + * 근거로 남긴다. 제도는 시·도(2자리) 또는 등록된 시·군·구(10자리, {@code RegionCodeResolver.SIGUNGU}) + * 단위다. 전국·중앙부처 제도({@code region_code == null})는 지역을 보지 않는다. 나이·소득은 아직 + * 못 본다({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가 필요하다). * 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내 사용자에게 되묻는다. * 정밀 판정은 후속. */ @@ -45,26 +46,29 @@ public EligibilityResult evaluate(User user, WelfareProgram program) { "자립준비청년(보호종료) 대상 제도입니다. 보호종료일을 등록하면 매칭됩니다."); } - // 지역 게이트: 특정 시·도 대상 제도(region_code != null)면 거주 시·도가 같아야 한다. - // 유저 region_code 형식이 환경마다 달라(시드는 법정동 코드, 온보딩은 "시도-시군구" 문자열) - // 코드 대신 region_name을 시도 2자리로 다시 풀어 비교한다. - String programRegion = program.getRegionCode(); - boolean regionScoped = programRegion != null && !programRegion.isBlank(); - if (regionScoped - && !programRegion.equals(RegionCodeResolver.resolve(user.getRegionName(), null))) { - return EligibilityResult.ineligible(matched, - "%s 거주자 대상 제도입니다.".formatted(program.getCtpvNm())); - } - if (careLeaver) { matched.add("protectionEndDate"); if (phase != null) { matched.add("protectionPhase"); } } - if (regionScoped) { + + // 지역 게이트: 지역 한정 제도(region_code != null)는 거주지가 그 안에 들어야 한다. + // 제도 코드가 유저 법정동 코드의 접두면 포함으로 본다: + // 제도 "11"(서울) ⊃ 유저 "1117000000"(용산) → 매칭 + // 제도 "1141000000"(서대문) vs 유저 "1117000000"(용산) → 불일치 + // 유저 region_code는 형식이 들쭉날쭉해(시드=코드, 온보딩=문자열) 신뢰하지 않고, + // region_name+district_name을 RegionCodeResolver로 다시 푼다. + String programRegion = program.getRegionCode(); + if (programRegion != null && !programRegion.isBlank()) { + String userRegion = RegionCodeResolver.resolve(user.getRegionName(), user.getDistrictName()); + if (userRegion == null || !userRegion.startsWith(programRegion)) { + return EligibilityResult.ineligible(matched, + "%s 거주자 대상 제도입니다.".formatted(program.getCtpvNm())); + } matched.add("region"); } + return EligibilityResult.eligible(matched); } } diff --git a/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java b/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java index d1338d4..164186d 100644 --- a/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java +++ b/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java @@ -5,15 +5,23 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * 지역명 → 법정동 시도 코드. v1은 시도 2자리만. + * 지역명 → 법정동 코드. 시도 2자리가 기본, 등록된 시군구는 10자리. */ class RegionCodeResolverTest { @Test void 시도명을_2자리_코드로_푼다() { - assertThat(RegionCodeResolver.resolve("서울특별시", "용산구")).isEqualTo("11"); assertThat(RegionCodeResolver.resolve("서울특별시", null)).isEqualTo("11"); assertThat(RegionCodeResolver.resolve("경기도", "수원시")).isEqualTo("41"); + // 등록 안 된 시군구는 시도로 떨어진다 + assertThat(RegionCodeResolver.resolve("서울특별시", "강남구")).isEqualTo("11"); + } + + @Test + void 등록된_시군구는_10자리_법정동_코드() { + // 프론트 stores/onboarding.ts HARDCODED_REGION_CODES와 같아야 한다 + assertThat(RegionCodeResolver.resolve("서울특별시", "용산구")).isEqualTo("1117000000"); + assertThat(RegionCodeResolver.resolve("서울특별시", "서대문구")).isEqualTo("1141000000"); } @Test diff --git a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java index 8cc688e..4923473 100644 --- a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java +++ b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java @@ -92,14 +92,44 @@ class EligibilityEvaluatorTest { assertThat(result.eligible()).isFalse(); } + @Test + void 시군구_한정_제도는_같은_구_거주자에게만_적격() { + WelfareProgram yongsanOnly = program(null); + ReflectionTestUtils.setField(yongsanOnly, "regionCode", "1117000000"); + ReflectionTestUtils.setField(yongsanOnly, "ctpvNm", "서울특별시"); + ReflectionTestUtils.setField(yongsanOnly, "sggNm", "용산구"); + + assertThat(evaluator.evaluate(user(null, "서울특별시", "용산구"), yongsanOnly).eligible()).isTrue(); + assertThat(evaluator.evaluate(user(null, "서울특별시", "서대문구"), yongsanOnly).eligible()).isFalse(); + // 시도만 아는 서울 거주자는 용산 한정 제도를 확정할 수 없다 → 부적격 + assertThat(evaluator.evaluate(user(null, "서울특별시", "강남구"), yongsanOnly).eligible()).isFalse(); + } + + @Test + void 시도_제도는_그_시도의_시군구_거주자도_적격() { + WelfareProgram seoulWide = program(null); + ReflectionTestUtils.setField(seoulWide, "regionCode", "11"); + ReflectionTestUtils.setField(seoulWide, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "서울특별시", "용산구"), seoulWide); + + assertThat(result.eligible()).isTrue(); + assertThat(result.matchedCriteria()).containsExactly("region"); + } + private static User user(LocalDate protectionEndDate) { - return user(protectionEndDate, null); + return user(protectionEndDate, null, null); } private static User user(LocalDate protectionEndDate, String regionName) { + return user(protectionEndDate, regionName, null); + } + + private static User user(LocalDate protectionEndDate, String regionName, String districtName) { User u = BeanUtils.instantiateClass(User.class); ReflectionTestUtils.setField(u, "protectionEndDate", protectionEndDate); ReflectionTestUtils.setField(u, "regionName", regionName); + ReflectionTestUtils.setField(u, "districtName", districtName); return u; }