From 94cb2a765083b8affcec6acf3855c24f1c570f87 Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Sun, 1 Nov 2026 22:35:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20:=20=EC=98=A8=ED=86=B5=EC=B2=AD?= =?UTF-8?q?=EB=85=84=20=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=EC=9A=A9=20youth=5Fpolicy=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8A=94=20=ED=8C=8C=EC=84=9C=EC=99=80=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fin-be | 2 +- .../youthpolicy/ApplyPeriodType.java | 20 +++ .../apicollector/youthpolicy/YouthPolicy.java | 101 ++++++++++++++ .../youthpolicy/YouthPolicyDraft.java | 20 +++ .../youthpolicy/YouthPolicyParser.java | 106 ++++++++++++++ .../youthpolicy/YouthPolicyRepository.java | 6 + .../youthpolicy/YouthPolicyRound.java | 54 ++++++++ .../youthpolicy/YouthPolicySyncService.java | 55 ++++++++ .../youthpolicy/YouthPolicyParserTest.java | 129 ++++++++++++++++++ .../YouthPolicySyncServiceTest.java | 106 ++++++++++++++ 10 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/ApplyPeriodType.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicy.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyDraft.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRepository.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRound.java create mode 100644 src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncService.java create mode 100644 src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java create mode 100644 src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncServiceTest.java diff --git a/fin-be b/fin-be index e30a018..7ef2b57 160000 --- a/fin-be +++ b/fin-be @@ -1 +1 @@ -Subproject commit e30a018b5d3ad7b0483f751f2fee8f100536f2e4 +Subproject commit 7ef2b572e02f79c42b0d5a2a3452e3bc773d61f2 diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/ApplyPeriodType.java b/src/main/java/apptive/fin/apicollector/youthpolicy/ApplyPeriodType.java new file mode 100644 index 0000000..fe1d74a --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/ApplyPeriodType.java @@ -0,0 +1,20 @@ +package apptive.fin.apicollector.youthpolicy; + +/** 온통청년 신청기간 구분(aplyPrdSeCd). Fin-BE의 같은 이름 enum과 저장값을 맞춘다. */ +public enum ApplyPeriodType { + PERIOD, // 0057001 특정기간: aplyYmd에 회차가 있다 + ALWAYS, // 0057002 상시 + CLOSED; // 0057003 마감 + + public static ApplyPeriodType fromOntongCode(String code) { + if (code == null) { + return null; + } + return switch (code) { + case "0057001" -> PERIOD; + case "0057002" -> ALWAYS; + case "0057003" -> CLOSED; + default -> null; + }; + } +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicy.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicy.java new file mode 100644 index 0000000..3b1f9e5 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicy.java @@ -0,0 +1,101 @@ +package apptive.fin.apicollector.youthpolicy; + +import apptive.fin.apicollector.global.entity.BaseTimeEntity; +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft.RoundDates; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 캘린더용 온통청년 정책. 스키마는 Fin-BE V10__create_youth_policy_schema.sql */ +@Entity +@Table(name = "youth_policy") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class YouthPolicy extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "policy_no", nullable = false, length = 50) + private String policyNo; + + @Column(name = "name", nullable = false, columnDefinition = "TEXT") + private String name; + + @Column(name = "institution", columnDefinition = "TEXT") + private String institution; + + @Column(name = "summary", columnDefinition = "TEXT") + private String summary; + + @Column(name = "min_age") + private Integer minAge; + + @Column(name = "max_age") + private Integer maxAge; + + @Column(name = "region_codes", columnDefinition = "TEXT") + private String regionCodes; + + @Enumerated(EnumType.STRING) + @Column(name = "apply_period_type", nullable = false, length = 20) + private ApplyPeriodType applyPeriodType; + + @Column(name = "is_active", nullable = false) + private boolean active; + + @Column(name = "synced_at", nullable = false) + private Instant syncedAt; + + @OneToMany(mappedBy = "policy", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("roundNo") + private List rounds = new ArrayList<>(); + + public YouthPolicy(String policyNo) { + this.policyNo = policyNo; + } + + /** 이번 수집 값으로 덮어쓴다. 회차는 순서(round_no)로 맞춰, 날짜가 바뀐 회차만 변경 시각을 남긴다. */ + public void apply(YouthPolicyDraft draft, Instant syncedAt) { + this.name = draft.name(); + this.institution = draft.institution(); + this.summary = draft.summary(); + this.minAge = draft.minAge(); + this.maxAge = draft.maxAge(); + this.regionCodes = draft.regionCodes(); + this.applyPeriodType = draft.applyPeriodType(); + this.active = true; + this.syncedAt = syncedAt; + syncRounds(draft.rounds(), syncedAt); + } + + public void deactivate() { + this.active = false; + } + + private void syncRounds(List next, Instant syncedAt) { + Map existing = rounds.stream() + .collect(Collectors.toMap(YouthPolicyRound::getRoundNo, Function.identity())); + for (int i = 0; i < next.size(); i++) { + int roundNo = i + 1; + RoundDates dates = next.get(i); + YouthPolicyRound round = existing.remove(roundNo); + if (round == null) { + rounds.add(new YouthPolicyRound(this, roundNo, dates.start(), dates.end())); + } + else { + round.reschedule(dates.start(), dates.end(), syncedAt); + } + } + rounds.removeAll(existing.values()); + } +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyDraft.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyDraft.java new file mode 100644 index 0000000..c0a63b5 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyDraft.java @@ -0,0 +1,20 @@ +package apptive.fin.apicollector.youthpolicy; + +import java.time.LocalDate; +import java.util.List; + +/** 온통청년 정책 하나를 저장할 값으로 바꾼 것. */ +public record YouthPolicyDraft( + String policyNo, + String name, + String institution, + String summary, + Integer minAge, + Integer maxAge, + String regionCodes, + ApplyPeriodType applyPeriodType, + List rounds +) { + public record RoundDates(LocalDate start, LocalDate end) { + } +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java new file mode 100644 index 0000000..870142f --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java @@ -0,0 +1,106 @@ +package apptive.fin.apicollector.youthpolicy; + +import apptive.fin.apicollector.global.util.JsonNodes; +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft.RoundDates; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 온통청년 정책(getPlcy 응답 항목)을 캘린더용 값으로 바꾼다. + * 캘린더 범위는 B1 PRD의 2계층(청년 금융 지원 정책)으로, 온통청년 중분류 '취약계층 및 금융지원'만 받는다. + */ +@Slf4j +@Component +public class YouthPolicyParser { + + static final String FINANCE_CATEGORY = "취약계층 및 금융지원"; + + // 여러 회차는 문자 그대로의 "\N"(역슬래시 + N)으로 이어져 온다. 예) "20260101 ~ 20261231\N20270101 ~ 20271231" + private static final String ROUND_SEPARATOR = "\\N"; + private static final Pattern ROUND = Pattern.compile("^(\\d{8})\\s*~\\s*(\\d{8})$"); + private static final DateTimeFormatter YMD = DateTimeFormatter.ofPattern("uuuuMMdd") + .withResolverStyle(ResolverStyle.STRICT); + + /** 캘린더 범위 밖이거나 필수 값이 없으면 empty. */ + public Optional parse(JsonNode raw) { + String categories = JsonNodes.text(raw, "mclsfNm"); + if (categories == null || !categories.contains(FINANCE_CATEGORY)) { + return Optional.empty(); + } + + String policyNo = JsonNodes.text(raw, "plcyNo"); + String name = JsonNodes.text(raw, "plcyNm"); + String periodCode = JsonNodes.text(raw, "aplyPrdSeCd"); + ApplyPeriodType applyPeriodType = ApplyPeriodType.fromOntongCode(periodCode); + if (policyNo == null || name == null || applyPeriodType == null) { + log.warn("YouthPolicyParser: skipped. plcyNo={}, plcyNm={}, aplyPrdSeCd={}", policyNo, name, periodCode); + return Optional.empty(); + } + + List rounds = applyPeriodType == ApplyPeriodType.PERIOD + ? parseRounds(policyNo, JsonNodes.text(raw, "aplyYmd")) + : List.of(); + + return Optional.of(new YouthPolicyDraft( + policyNo, + name, + JsonNodes.firstText(raw, "sprvsnInstCdNm", "rgtrInstCdNm", "operInstCdNm"), + JsonNodes.text(raw, "plcyExplnCn"), + ageOrNull(JsonNodes.integer(raw, "sprtTrgtMinAge")), + ageOrNull(JsonNodes.integer(raw, "sprtTrgtMaxAge")), + JsonNodes.text(raw, "zipCd"), + applyPeriodType, + rounds + )); + } + + // 읽을 수 없는 회차는 버린다. 회차가 하나도 없는 특정기간 정책은 캘린더에 나오지 않는다. + private List parseRounds(String policyNo, String aplyYmd) { + if (aplyYmd == null) { + log.warn("YouthPolicyParser: no apply period. plcyNo={}", policyNo); + return List.of(); + } + List rounds = new ArrayList<>(); + for (String part : aplyYmd.split(Pattern.quote(ROUND_SEPARATOR))) { + Optional round = parseRound(part.trim()); + if (round.isPresent()) { + rounds.add(round.get()); + } + else { + log.warn("YouthPolicyParser: unreadable apply period. plcyNo={}, aplyYmd={}", policyNo, part); + } + } + return rounds; + } + + private static Optional parseRound(String value) { + Matcher matcher = ROUND.matcher(value); + if (!matcher.matches()) { + return Optional.empty(); + } + try { + LocalDate start = LocalDate.parse(matcher.group(1), YMD); + LocalDate end = LocalDate.parse(matcher.group(2), YMD); + return start.isAfter(end) ? Optional.empty() : Optional.of(new RoundDates(start, end)); + } + catch (DateTimeParseException e) { + return Optional.empty(); + } + } + + // 온통청년은 나이 제한 없음을 0으로 준다. + private static Integer ageOrNull(Integer age) { + return age == null || age == 0 ? null : age; + } +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRepository.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRepository.java new file mode 100644 index 0000000..de6455b --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRepository.java @@ -0,0 +1,6 @@ +package apptive.fin.apicollector.youthpolicy; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface YouthPolicyRepository extends JpaRepository { +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRound.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRound.java new file mode 100644 index 0000000..898ab3e --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyRound.java @@ -0,0 +1,54 @@ +package apptive.fin.apicollector.youthpolicy; + +import apptive.fin.apicollector.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "youth_policy_round") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class YouthPolicyRound extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "policy_id", nullable = false) + private YouthPolicy policy; + + @Column(name = "round_no", nullable = false) + private Integer roundNo; + + @Column(name = "apply_start_date", nullable = false) + private LocalDate applyStartDate; + + @Column(name = "apply_end_date", nullable = false) + private LocalDate applyEndDate; + + // 날짜가 바뀐 시각. Fin-BE 캘린더가 7일간 '일정 변경'으로 표시한다. + @Column(name = "schedule_changed_at") + private Instant scheduleChangedAt; + + YouthPolicyRound(YouthPolicy policy, int roundNo, LocalDate applyStartDate, LocalDate applyEndDate) { + this.policy = policy; + this.roundNo = roundNo; + this.applyStartDate = applyStartDate; + this.applyEndDate = applyEndDate; + } + + void reschedule(LocalDate start, LocalDate end, Instant changedAt) { + if (start.equals(applyStartDate) && end.equals(applyEndDate)) { + return; + } + this.applyStartDate = start; + this.applyEndDate = end; + this.scheduleChangedAt = changedAt; + } +} diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncService.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncService.java new file mode 100644 index 0000000..7a93350 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncService.java @@ -0,0 +1,55 @@ +package apptive.fin.apicollector.youthpolicy; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class YouthPolicySyncService { + + private final YouthPolicyRepository youthPolicyRepository; + + public record SyncResult(int inserted, int updated, int deactivated) { + } + + /** + * 이번에 받은 정책 목록으로 저장 상태를 맞춘다. 목록에 없는 기존 정책은 비활성화한다. + * 캘린더 범위(금융지원 분류)는 수백 건이라 기존 정책을 한 번에 읽어 비교한다. + */ + @Transactional + public SyncResult sync(List drafts, Instant syncedAt) { + Map existing = youthPolicyRepository.findAll().stream() + .collect(Collectors.toMap(YouthPolicy::getPolicyNo, Function.identity())); + + int inserted = 0; + int updated = 0; + for (YouthPolicyDraft draft : drafts) { + YouthPolicy policy = existing.remove(draft.policyNo()); + if (policy == null) { + policy = new YouthPolicy(draft.policyNo()); + inserted++; + } + else { + updated++; + } + policy.apply(draft, syncedAt); + youthPolicyRepository.save(policy); + } + + int deactivated = 0; + for (YouthPolicy missing : existing.values()) { + if (missing.isActive()) { + missing.deactivate(); + deactivated++; + } + } + return new SyncResult(inserted, updated, deactivated); + } +} diff --git a/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java new file mode 100644 index 0000000..ecad414 --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java @@ -0,0 +1,129 @@ +package apptive.fin.apicollector.youthpolicy; + +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft.RoundDates; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +// 필드 모양은 2026-11 온통청년 getPlcy 응답 기준 +class YouthPolicyParserTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final YouthPolicyParser parser = new YouthPolicyParser(); + + @Test + void 특정기간_정책을_회차와_함께_읽는다() { + YouthPolicyDraft draft = parser.parse(policy(Map.of( + "aplyPrdSeCd", "0057001", + "aplyYmd", "20260502 ~ 20260521", + "sprtTrgtMinAge", "19", + "sprtTrgtMaxAge", "34", + "zipCd", "26110,26140" + ))).orElseThrow(); + + assertThat(draft.policyNo()).isEqualTo("20260101000000000001"); + assertThat(draft.name()).isEqualTo("청년 자산형성 지원"); + assertThat(draft.institution()).isEqualTo("부산광역시"); + assertThat(draft.summary()).isEqualTo("월 10만 원 저축 시 1:1 매칭"); + assertThat(draft.applyPeriodType()).isEqualTo(ApplyPeriodType.PERIOD); + assertThat(draft.rounds()).containsExactly( + new RoundDates(LocalDate.of(2026, 5, 2), LocalDate.of(2026, 5, 21))); + assertThat(draft.minAge()).isEqualTo(19); + assertThat(draft.maxAge()).isEqualTo(34); + assertThat(draft.regionCodes()).isEqualTo("26110,26140"); + } + + // 온통청년은 여러 회차를 문자 그대로의 "\N"으로 잇는다. + @Test + void 여러_회차를_순서대로_나눈다() { + YouthPolicyDraft draft = parser.parse(policy(Map.of( + "aplyPrdSeCd", "0057001", + "aplyYmd", "20260101 ~ 20261231\\N20270101 ~ 20271231" + ))).orElseThrow(); + + assertThat(draft.rounds()).containsExactly( + new RoundDates(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)), + new RoundDates(LocalDate.of(2027, 1, 1), LocalDate.of(2027, 12, 31))); + } + + @Test + void 상시와_마감_정책은_회차가_없다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "aplyYmd", ""))).orElseThrow()) + .satisfies(draft -> { + assertThat(draft.applyPeriodType()).isEqualTo(ApplyPeriodType.ALWAYS); + assertThat(draft.rounds()).isEmpty(); + }); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057003"))).orElseThrow().applyPeriodType()) + .isEqualTo(ApplyPeriodType.CLOSED); + } + + // 읽을 수 없는 회차는 버리고 정책은 남긴다. 회차가 없으면 캘린더에 찍히지 않는다. + @Test + void 읽을_수_없는_신청기간은_회차로_만들지_않는다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057001", "aplyYmd", "2026년 상반기"))).orElseThrow().rounds()) + .isEmpty(); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057001", "aplyYmd", "20260521 ~ 20260502"))).orElseThrow().rounds()) + .isEmpty(); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057001", "aplyYmd", "20260231 ~ 20260302"))).orElseThrow().rounds()) + .isEmpty(); + } + + @Test + void 나이_0은_제한_없음이다() { + YouthPolicyDraft draft = parser.parse(policy(Map.of( + "aplyPrdSeCd", "0057002", "sprtTrgtMinAge", "0", "sprtTrgtMaxAge", "39"))).orElseThrow(); + + assertThat(draft.minAge()).isNull(); + assertThat(draft.maxAge()).isEqualTo(39); + } + + @Test + void 금융지원_분류가_아니면_건너뛴다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "mclsfNm", "취업")))).isEmpty(); + } + + // 분류가 여러 개면 쉼표로 이어져 온다. + @Test + void 여러_분류_중_금융지원이_있으면_포함한다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "mclsfNm", "건강,취약계층 및 금융지원")))) + .isPresent(); + } + + @Test + void 정책번호나_이름이나_신청기간_구분이_없으면_건너뛴다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "plcyNo", "")))).isEmpty(); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "plcyNm", " ")))).isEmpty(); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "9999999")))).isEmpty(); + } + + @Test + void 주관기관이_없으면_등록기관을_쓴다() { + Optional draft = parser.parse(policy(Map.of( + "aplyPrdSeCd", "0057002", "sprvsnInstCdNm", "", "rgtrInstCdNm", "금융위원회"))); + + assertThat(draft.orElseThrow().institution()).isEqualTo("금융위원회"); + } + + private static JsonNode policy(Map overrides) { + ObjectNode node = MAPPER.createObjectNode(); + node.put("plcyNo", "20260101000000000001"); + node.put("plcyNm", "청년 자산형성 지원"); + node.put("plcyExplnCn", "월 10만 원 저축 시 1:1 매칭"); + node.put("mclsfNm", "취약계층 및 금융지원"); + node.put("sprvsnInstCdNm", "부산광역시"); + node.put("rgtrInstCdNm", "부산광역시 청년정책과"); + node.put("sprtTrgtMinAge", "0"); + node.put("sprtTrgtMaxAge", "0"); + node.put("zipCd", ""); + node.put("aplyYmd", ""); + overrides.forEach(node::put); + return node; + } +} diff --git a/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncServiceTest.java b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncServiceTest.java new file mode 100644 index 0000000..3a7141e --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicySyncServiceTest.java @@ -0,0 +1,106 @@ +package apptive.fin.apicollector.youthpolicy; + +import apptive.fin.apicollector.support.IntegrationTestSupport; +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft.RoundDates; +import apptive.fin.apicollector.youthpolicy.YouthPolicySyncService.SyncResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Fin-BE V10 스키마(서브모듈)에 실제로 쓰고 읽어 확인한다. */ +class YouthPolicySyncServiceTest extends IntegrationTestSupport { + + private static final Instant FIRST_SYNC = Instant.parse("2026-11-01T00:00:00Z"); + private static final Instant SECOND_SYNC = Instant.parse("2026-11-02T00:00:00Z"); + private static final RoundDates MAY = new RoundDates(LocalDate.of(2026, 5, 2), LocalDate.of(2026, 5, 21)); + private static final RoundDates JUNE = new RoundDates(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 15)); + + @Autowired private YouthPolicySyncService syncService; + @Autowired private JdbcTemplate jdbcTemplate; + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM youth_policy"); + } + + @Test + void 처음_받은_정책과_회차를_저장한다() { + SyncResult result = syncService.sync(List.of(draft("A", MAY, JUNE)), FIRST_SYNC); + + assertThat(result).isEqualTo(new SyncResult(1, 0, 0)); + Map policy = jdbcTemplate.queryForMap( + "SELECT name, min_age, max_age, region_codes, apply_period_type, is_active FROM youth_policy WHERE policy_no = 'A'"); + assertThat(policy) + .containsEntry("name", "정책 A") + .containsEntry("min_age", 19) + .containsEntry("max_age", 34) + .containsEntry("region_codes", "26110") + .containsEntry("apply_period_type", "PERIOD") + .containsEntry("is_active", true); + assertThat(rounds("A")).containsExactly("1:2026-05-02~2026-05-21:null", "2:2026-06-01~2026-06-15:null"); + } + + @Test + void 날짜가_바뀐_회차만_변경_시각을_남긴다() { + syncService.sync(List.of(draft("A", MAY, JUNE)), FIRST_SYNC); + RoundDates juneExtended = new RoundDates(JUNE.start(), LocalDate.of(2026, 6, 30)); + + SyncResult result = syncService.sync(List.of(draft("A", MAY, juneExtended)), SECOND_SYNC); + + assertThat(result).isEqualTo(new SyncResult(0, 1, 0)); + assertThat(rounds("A")).containsExactly( + "1:2026-05-02~2026-05-21:null", + "2:2026-06-01~2026-06-30:changed"); + } + + @Test + void 사라진_회차는_지우고_사라진_정책은_비활성화한다() { + syncService.sync(List.of(draft("A", MAY, JUNE), draft("B", MAY)), FIRST_SYNC); + + SyncResult result = syncService.sync(List.of(draft("A", MAY)), SECOND_SYNC); + + assertThat(result).isEqualTo(new SyncResult(0, 1, 1)); + assertThat(rounds("A")).containsExactly("1:2026-05-02~2026-05-21:null"); + assertThat(isActive("B")).isFalse(); + } + + @Test + void 다시_나타난_정책은_활성화한다() { + syncService.sync(List.of(draft("A", MAY)), FIRST_SYNC); + syncService.sync(List.of(draft("B", MAY)), SECOND_SYNC); + assertThat(isActive("A")).isFalse(); + + syncService.sync(List.of(draft("A", MAY), draft("B", MAY)), SECOND_SYNC); + + assertThat(isActive("A")).isTrue(); + } + + private Boolean isActive(String policyNo) { + return jdbcTemplate.queryForObject( + "SELECT is_active FROM youth_policy WHERE policy_no = ?", Boolean.class, policyNo); + } + + private List rounds(String policyNo) { + return jdbcTemplate.query(""" + SELECT r.round_no, r.apply_start_date, r.apply_end_date, r.schedule_changed_at + FROM youth_policy_round r JOIN youth_policy p ON p.id = r.policy_id + WHERE p.policy_no = ? ORDER BY r.round_no + """, + (rs, i) -> rs.getInt(1) + ":" + rs.getDate(2) + "~" + rs.getDate(3) + ":" + + (rs.getTimestamp(4) == null ? "null" : "changed"), + policyNo); + } + + private static YouthPolicyDraft draft(String policyNo, RoundDates... rounds) { + return new YouthPolicyDraft(policyNo, "정책 " + policyNo, "기관", "설명", 19, 34, "26110", + ApplyPeriodType.PERIOD, List.of(rounds)); + } +} From 355dbd892877740362a148efaaf53f60648d2c22 Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Sun, 1 Nov 2026 22:35:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20YOUTH=5FPOLICY=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20step=20=EC=B6=94=EA=B0=80=EC=99=80=20=EC=98=A8?= =?UTF-8?q?=ED=86=B5=EC=B2=AD=EB=85=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/apptive/fin/apicollector/Source.java | 3 +- .../fin/apicollector/batch/SourceDecider.java | 1 + .../client/OntongYouthClient.java | 22 +++++ .../config/FinancialProductSyncJobConfig.java | 26 ++++++ .../tasklet/BankProductUrlTasklet.java | 2 +- .../tasklet/SyncYouthPolicyTasklet.java | 61 ++++++++++++++ src/main/resources/application-dev.yml | 1 + .../client/ontong/OntongYouthClientTest.java | 82 +++++++++++++++++++ .../apicollector/config/AllSyncFlowTest.java | 4 +- .../tasklet/SyncYouthPolicyTaskletTest.java | 78 ++++++++++++++++++ 10 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/main/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTasklet.java create mode 100644 src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java create mode 100644 src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java diff --git a/src/main/java/apptive/fin/apicollector/Source.java b/src/main/java/apptive/fin/apicollector/Source.java index 19610e6..9d738f1 100644 --- a/src/main/java/apptive/fin/apicollector/Source.java +++ b/src/main/java/apptive/fin/apicollector/Source.java @@ -4,5 +4,6 @@ public enum Source { ALL, ONTONG, // 온통청년 API FSS, // 금감원 API - KFB // 은행연합회 소비자포털 비교공시(입출금자유예금) + KFB, // 은행연합회 소비자포털 비교공시(입출금자유예금) + YOUTH_POLICY // 온통청년 청년정책 → B1 캘린더(youth_policy). 상품이 아니라 product에는 쓰지 않는다 } diff --git a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java index 077e4c7..0871af4 100644 --- a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java +++ b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java @@ -23,6 +23,7 @@ public FlowExecutionStatus decide( case FSS -> new FlowExecutionStatus("FSS"); case ONTONG -> new FlowExecutionStatus("ONTONG_YOUTH"); case KFB -> new FlowExecutionStatus("KFB"); + case YOUTH_POLICY -> new FlowExecutionStatus("YOUTH_POLICY"); }; } } diff --git a/src/main/java/apptive/fin/apicollector/client/OntongYouthClient.java b/src/main/java/apptive/fin/apicollector/client/OntongYouthClient.java index 275c9ef..d2c5277 100644 --- a/src/main/java/apptive/fin/apicollector/client/OntongYouthClient.java +++ b/src/main/java/apptive/fin/apicollector/client/OntongYouthClient.java @@ -1,17 +1,22 @@ package apptive.fin.apicollector.client; import apptive.fin.apicollector.config.CollectorProperties; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import tools.jackson.databind.JsonNode; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import java.util.ArrayList; import java.util.List; +@Slf4j @Component public class OntongYouthClient { + private static final int MAX_ATTEMPTS_PER_PAGE = 3; + private final CollectorProperties properties; private final RestClient ontongYouthRestClient; @@ -49,7 +54,24 @@ public List fetchAll() { return result; } + // 온통청년은 가끔 JSON 대신 HTML 오류 페이지를 돌려준다(2026-11 확인, 바로 다시 요청하면 정상). + // I/O 오류 메시지에는 API 키가 든 요청 URL이 들어가므로 예외 종류만 남긴다. private JsonNode fetchPage(RestClient restClient, int page, int pageSize) { + String lastFailure = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS_PER_PAGE; attempt++) { + try { + return requestPage(restClient, page, pageSize); + } + catch (RestClientException e) { + lastFailure = e.getClass().getSimpleName(); + log.warn("OntongYouthClient: page {} attempt {}/{} failed. {}", page, attempt, MAX_ATTEMPTS_PER_PAGE, lastFailure); + } + } + throw new IllegalStateException( + "Ontong youth page " + page + " failed " + MAX_ATTEMPTS_PER_PAGE + " times. last=" + lastFailure); + } + + private JsonNode requestPage(RestClient restClient, int page, int pageSize) { return restClient.get() .uri(uriBuilder -> uriBuilder .path("/go/ythip/getPlcy") diff --git a/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java b/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java index bafc679..94b9c7e 100644 --- a/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java +++ b/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java @@ -9,6 +9,7 @@ import apptive.fin.apicollector.tasklet.FetchKfbRawTasklet; import apptive.fin.apicollector.tasklet.FetchManualRawTasklet; import apptive.fin.apicollector.tasklet.ResolveProductDisplayNameTasklet; +import apptive.fin.apicollector.tasklet.SyncYouthPolicyTasklet; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.job.Job; import org.springframework.batch.core.job.builder.FlowBuilder; @@ -44,6 +45,7 @@ public Job financialProductSyncJob( Flow fssSyncFlow, Flow ontongYouthSyncFlow, Flow kfbSyncFlow, + Flow youthPolicySyncFlow, Flow allSyncFlow ) { return new JobBuilder("financialProductSyncJob", jobRepository) @@ -54,6 +56,8 @@ public Job financialProductSyncJob( .on("ONTONG_YOUTH").to(ontongYouthSyncFlow) .from(sourceDecider) .on("KFB").to(kfbSyncFlow) + .from(sourceDecider) + .on("YOUTH_POLICY").to(youthPolicySyncFlow) .from(sourceDecider) .on("ALL").to(allSyncFlow) .end() @@ -93,6 +97,25 @@ public Step fetchKfbRawStep( .build(); } + // 정책 목록 조회와 저장을 한 트랜잭션에 묶지 않는다. 저장은 YouthPolicySyncService가 트랜잭션을 연다. + @Bean + public Step syncYouthPolicyStep( + JobRepository jobRepository, + SyncYouthPolicyTasklet syncYouthPolicyTasklet + ) { + return new StepBuilder(SyncYouthPolicyTasklet.STEP_NAME, jobRepository) + .tasklet(syncYouthPolicyTasklet, new ResourcelessTransactionManager()) + .build(); + } + + // B1 캘린더는 하루 1회 이상 갱신해야 해서 상품 수집과 따로 돌릴 수 있게 둔다. + @Bean + public Flow youthPolicySyncFlow(Step syncYouthPolicyStep) { + return new FlowBuilder("youthPolicySyncFlow") + .start(syncYouthPolicyStep) + .build(); + } + @Bean public Flow fssSyncFlow( Step fetchFssRawStep, @@ -154,8 +177,10 @@ public Flow allSyncFlow( Step deactivateMissingProductStep, Step bankProductUrlStep, Step resolveProductDisplayNameStep, + Step syncYouthPolicyStep, Step failIfAnyStepFailedStep ) { + // 캘린더 정책 수집은 상품과 무관해 맨 뒤에 둔다. 온통청년이 실패해도 상품 수집은 이미 끝나 있다. // KFB는 외부 HTML 스크래핑이라 깨지기 쉽다. 실패해도 다른 소스는 끝까지 돌리고(KFB 비활성화는 // DeactivateMissingProductTasklet이 건너뜀), 마지막 step에서 잡을 FAILED로 끝내 실패를 드러낸다. return new FlowBuilder("allSyncFlow") @@ -170,6 +195,7 @@ public Flow allSyncFlow( .next(deactivateMissingProductStep) .next(bankProductUrlStep) .next(resolveProductDisplayNameStep) + .next(syncYouthPolicyStep) .next(failIfAnyStepFailedStep) .build(); } diff --git a/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java index 3bd762b..6c73ba7 100644 --- a/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java +++ b/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java @@ -86,7 +86,7 @@ private List targetSourceCodes() { return switch (collectorProperties.source()) { case ALL -> List.of(Source.FSS.name(), Source.KFB.name()); case FSS, KFB -> List.of(collectorProperties.source().name()); - case ONTONG -> List.of(); + case ONTONG, YOUTH_POLICY -> List.of(); }; } diff --git a/src/main/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTasklet.java new file mode 100644 index 0000000..36cac21 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTasklet.java @@ -0,0 +1,61 @@ +package apptive.fin.apicollector.tasklet; + +import apptive.fin.apicollector.client.OntongYouthClient; +import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft; +import apptive.fin.apicollector.youthpolicy.YouthPolicyParser; +import apptive.fin.apicollector.youthpolicy.YouthPolicySyncService; +import apptive.fin.apicollector.youthpolicy.YouthPolicySyncService.SyncResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** 온통청년 정책을 받아 B1 캘린더용 youth_policy에 맞춘다. raw 테이블을 거치지 않는다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SyncYouthPolicyTasklet implements Tasklet { + + public static final String STEP_NAME = "syncYouthPolicyStep"; + + private final OntongYouthClient ontongYouthClient; + private final YouthPolicyParser youthPolicyParser; + private final YouthPolicySyncService youthPolicySyncService; + private final CollectorProperties properties; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (properties.mode().isNormalizeOnly()) { + log.info("SyncYouthPolicyTasklet skipped. mode={}", properties.mode()); + return RepeatStatus.FINISHED; + } + + List policies = ontongYouthClient.fetchAll(); + List drafts = policies.stream() + .map(youthPolicyParser::parse) + .flatMap(Optional::stream) + .toList(); + + // 응답 형식이나 분류 이름이 바뀌어 0건이 되면, 저장된 정책이 전부 비활성화되지 않게 실패시킨다. + if (drafts.isEmpty()) { + throw new IllegalStateException( + "No youth policy in calendar scope. fetched=" + policies.size()); + } + + SyncResult result = youthPolicySyncService.sync(drafts, Instant.now()); + log.info( + "SyncYouthPolicyTasklet: fetched={}, inScope={}, inserted={}, updated={}, deactivated={}", + policies.size(), drafts.size(), result.inserted(), result.updated(), result.deactivated() + ); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f4f3e56..9623bd1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -29,6 +29,7 @@ collector: enabled: true # ONTONG = 수동 정부 정책상품(manual-products.json) 동기화. 온통청년 API 폐기로 FetchManualRawTasklet이 원천. # FSS(은행 공시)까지 함께 돌리려면 ALL 로 변경. KFB = 은행연합회 입출금자유예금(파킹통장) 공시. + # YOUTH_POLICY = 온통청년 청년정책 → B1 캘린더(youth_policy). ALL 실행에도 마지막 step으로 들어 있다. source: ALL mode: SYNC # REQ-001 상품명 후행 괄호 제거 재정규화를 위해 27 -> 28. (PR #20이 27로 올린 것 위에 얹음) diff --git a/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java b/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java new file mode 100644 index 0000000..ad346c2 --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java @@ -0,0 +1,82 @@ +package apptive.fin.apicollector.client.ontong; + +import apptive.fin.apicollector.Mode; +import apptive.fin.apicollector.Source; +import apptive.fin.apicollector.client.OntongYouthClient; +import apptive.fin.apicollector.config.CollectorProperties; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.ResponseCreator; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.JsonNode; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.ExpectedCount.times; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class OntongYouthClientTest { + + private static final int PAGE_SIZE = 2; + + private final RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost"); + private final MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + private final OntongYouthClient client = new OntongYouthClient(properties(), builder.build()); + + @Test + void 마지막_페이지까지_모은다() { + server.expect(queryParam("pageNum", "1")).andRespond(page("A", "B")); + server.expect(queryParam("pageNum", "2")).andRespond(page("C")); + + List policies = client.fetchAll(); + + server.verify(); + assertThat(policies).extracting(policy -> policy.path("plcyNo").asString()).containsExactly("A", "B", "C"); + } + + // 온통청년은 가끔 JSON 대신 HTML 오류 페이지를 돌려준다. + @Test + void HTML_응답이면_같은_페이지를_다시_요청한다() { + server.expect(queryParam("pageNum", "1")).andRespond(html()); + server.expect(queryParam("pageNum", "1")).andRespond(page("A")); + + assertThat(client.fetchAll()).hasSize(1); + server.verify(); + } + + @Test + void 세_번_모두_실패하면_중단한다() { + server.expect(times(3), queryParam("pageNum", "1")).andRespond(html()); + + assertThatThrownBy(client::fetchAll) + .isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("secret-key"); + server.verify(); + } + + private static ResponseCreator page(String... policyNos) { + StringBuilder items = new StringBuilder(); + for (String policyNo : policyNos) { + if (!items.isEmpty()) { + items.append(','); + } + items.append("{\"plcyNo\":\"").append(policyNo).append("\"}"); + } + return withSuccess("{\"result\":{\"youthPolicyList\":[" + items + "]}}", MediaType.APPLICATION_JSON); + } + + private static ResponseCreator html() { + return withSuccess("", MediaType.TEXT_HTML); + } + + private static CollectorProperties properties() { + return new CollectorProperties( + true, Source.YOUTH_POLICY, Mode.SYNC, 1, 100, 1, + new CollectorProperties.OntongYouth("http://localhost", "secret-key", PAGE_SIZE), + null, null); + } +} diff --git a/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java b/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java index cbf2b58..d130d50 100644 --- a/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java +++ b/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java @@ -66,7 +66,8 @@ void runsOtherSourcesAndFailsJobWhenKfbFetchFails() throws Exception { "normalizeFssRawProductStep", "normalizeKfbRawProductStep", "bankProductUrlStep", - "resolveProductDisplayNameStep" + "resolveProductDisplayNameStep", + "syncYouthPolicyStep" ); verify(productSyncService).disableAllUnseenProducts(eq(Source.ONTONG), any()); verify(productSyncService).disableAllUnseenProducts(eq(Source.FSS), any()); @@ -93,6 +94,7 @@ private JobExecution run(Step fetchKfbRawStep) throws Exception { step("deactivateMissingProductStep", new DeactivateMissingProductTasklet(productSyncService, allSyncProperties())), recording("bankProductUrlStep"), recording("resolveProductDisplayNameStep"), + recording("syncYouthPolicyStep"), step("failIfAnyStepFailedStep", new FailIfAnyStepFailedTasklet()) ); Job job = new JobBuilder("allSyncFlowTestJob", jobRepository).start(flow).end().build(); diff --git a/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java b/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java new file mode 100644 index 0000000..be653d2 --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java @@ -0,0 +1,78 @@ +package apptive.fin.apicollector.tasklet; + +import apptive.fin.apicollector.Mode; +import apptive.fin.apicollector.Source; +import apptive.fin.apicollector.client.OntongYouthClient; +import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.youthpolicy.ApplyPeriodType; +import apptive.fin.apicollector.youthpolicy.YouthPolicyDraft; +import apptive.fin.apicollector.youthpolicy.YouthPolicyParser; +import apptive.fin.apicollector.youthpolicy.YouthPolicySyncService; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SyncYouthPolicyTaskletTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final OntongYouthClient client = mock(OntongYouthClient.class); + // 범위·필드 해석은 YouthPolicyParserTest가 본다. 여기서는 tasklet이 파서 판정을 따르는지만 본다. + private final YouthPolicyParser parser = mock(YouthPolicyParser.class); + private final YouthPolicySyncService syncService = mock(YouthPolicySyncService.class); + + @Test + void 범위_안의_정책만_저장한다() { + JsonNode inScope = MAPPER.createObjectNode().put("plcyNo", "A"); + JsonNode outOfScope = MAPPER.createObjectNode().put("plcyNo", "B"); + YouthPolicyDraft draft = draft("A"); + when(client.fetchAll()).thenReturn(List.of(inScope, outOfScope)); + when(parser.parse(inScope)).thenReturn(Optional.of(draft)); + when(parser.parse(outOfScope)).thenReturn(Optional.empty()); + when(syncService.sync(any(), any())).thenReturn(new YouthPolicySyncService.SyncResult(1, 0, 0)); + + tasklet(Mode.SYNC).execute(null, null); + + verify(syncService).sync(eq(List.of(draft)), any()); + } + + // 응답 형식이나 분류 이름이 바뀌어 0건이 되면 저장된 정책이 전부 비활성화되지 않게 실패시킨다. + @Test + void 범위_안의_정책이_하나도_없으면_실패한다() { + JsonNode policy = MAPPER.createObjectNode().put("plcyNo", "A"); + when(client.fetchAll()).thenReturn(List.of(policy)); + when(parser.parse(policy)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tasklet(Mode.SYNC).execute(null, null)) + .isInstanceOf(IllegalStateException.class); + verifyNoInteractions(syncService); + } + + @Test + void 정규화만_하는_실행에서는_건너뛴다() { + tasklet(Mode.NORMALIZE_ONLY).execute(null, null); + + verifyNoInteractions(client, syncService); + } + + private SyncYouthPolicyTasklet tasklet(Mode mode) { + CollectorProperties properties = new CollectorProperties( + true, Source.YOUTH_POLICY, mode, 1, 100, 1, null, null, null); + return new SyncYouthPolicyTasklet(client, parser, syncService, properties); + } + + private static YouthPolicyDraft draft(String policyNo) { + return new YouthPolicyDraft(policyNo, "정책", null, null, null, null, null, ApplyPeriodType.ALWAYS, List.of()); + } +} From 9f48cde9ac814fad77341ab2d6ed203263eae0d5 Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Mon, 2 Nov 2026 01:25:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat=20:=20=EC=BA=98=EB=A6=B0=EB=8D=94=202?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EC=A0=80?= =?UTF-8?q?=EC=B6=95=C2=B7=EA=B3=B5=EC=A0=9C=C2=B7=EC=8B=A0=EC=9A=A9?= =?UTF-8?q?=ED=9A=8C=EB=B3=B5=20=EC=A0=95=EC=B1=85=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../youthpolicy/YouthPolicyParser.java | 18 ++++++--- .../youthpolicy/YouthPolicyParserTest.java | 39 ++++++++++++++++--- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java index 870142f..c794085 100644 --- a/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java +++ b/src/main/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParser.java @@ -18,13 +18,22 @@ /** * 온통청년 정책(getPlcy 응답 항목)을 캘린더용 값으로 바꾼다. - * 캘린더 범위는 B1 PRD의 2계층(청년 금융 지원 정책)으로, 온통청년 중분류 '취약계층 및 금융지원'만 받는다. + * 캘린더 범위는 B1 PRD의 2계층 '청년 금융 지원 정책(자산형성 저축·공제·신용회복)'이고, 주거 정책은 제외한다. + * 정책 이름으로 고른다. 온통청년 분류로 먼저 거르면 내일채움공제(재직자)와 학자금 신용회복(교육비지원)이 빠지고, + * '취약계층 및 금융지원' 분류에는 출산지원금, 돌봄처럼 금융과 먼 정책이 70% 넘게 섞여 있다. */ @Slf4j @Component public class YouthPolicyParser { - static final String FINANCE_CATEGORY = "취약계층 및 금융지원"; + // 키워드는 2026-11 온통청년 전체(3,843건)를 검토해 정했다. 온통청년 키워드 태그(plcyKywdNm)는 + // '금리혜택'이 교통비 환급에 붙는 식으로 부정확해서 쓰지 않는다. + static final Pattern SCOPE_NAME = Pattern.compile( + "저축|적금|통장|자산형성|재형|도약계좌" // 자산형성 저축 + + "|공제|사랑채움" // 공제 (경북 사랑채움은 이름에 '공제'가 없다) + + "|신용회복|신용 회복|채무조정|장기연체|부실채무|신용유의"); // 신용회복 + // 세금 공제와 주거 정책(청약통장, 전세·임차 대출이자 등)은 범위 밖이다. + static final Pattern EXCLUDED_NAME = Pattern.compile("소득공제|세액공제|전세|월세|주거|주택|임대|임차|보금자리|청약"); // 여러 회차는 문자 그대로의 "\N"(역슬래시 + N)으로 이어져 온다. 예) "20260101 ~ 20261231\N20270101 ~ 20271231" private static final String ROUND_SEPARATOR = "\\N"; @@ -34,13 +43,12 @@ public class YouthPolicyParser { /** 캘린더 범위 밖이거나 필수 값이 없으면 empty. */ public Optional parse(JsonNode raw) { - String categories = JsonNodes.text(raw, "mclsfNm"); - if (categories == null || !categories.contains(FINANCE_CATEGORY)) { + String name = JsonNodes.text(raw, "plcyNm"); + if (name != null && (!SCOPE_NAME.matcher(name).find() || EXCLUDED_NAME.matcher(name).find())) { return Optional.empty(); } String policyNo = JsonNodes.text(raw, "plcyNo"); - String name = JsonNodes.text(raw, "plcyNm"); String periodCode = JsonNodes.text(raw, "aplyPrdSeCd"); ApplyPeriodType applyPeriodType = ApplyPeriodType.fromOntongCode(periodCode); if (policyNo == null || name == null || applyPeriodType == null) { diff --git a/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java index ecad414..7289593 100644 --- a/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java +++ b/src/test/java/apptive/fin/apicollector/youthpolicy/YouthPolicyParserTest.java @@ -85,15 +85,40 @@ class YouthPolicyParserTest { } @Test - void 금융지원_분류가_아니면_건너뛴다() { - assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "mclsfNm", "취업")))).isEmpty(); + void 이름에_저축_공제_신용회복_키워드가_있으면_포함한다() { + assertThat(named("전북청년 함께 두배적금")).isPresent(); + assertThat(named("청년내일저축계좌")).isPresent(); + assertThat(named("광주형 청년일자리 공제")).isPresent(); + assertThat(named("청년근로자 사랑채움사업")).isPresent(); + assertThat(named("청년 특례 채무조정")).isPresent(); + assertThat(named("학자금 대출 신용 회복 지원")).isPresent(); + assertThat(named("2026년 청년 학자금대출 장기연체자 지원사업")).isPresent(); } - // 분류가 여러 개면 쉼표로 이어져 온다. + // 2026-11 실제 데이터에서 걸러야 했던 이름들 @Test - void 여러_분류_중_금융지원이_있으면_포함한다() { - assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "mclsfNm", "건강,취약계층 및 금융지원")))) + void 범위_밖_정책은_건너뛴다() { + assertThat(named("출산육아수당 지원")).isEmpty(); + assertThat(named("청년·대학생 햇살론(햇살론유스)")).isEmpty(); + assertThat(named("소프트웨어(SW) 미래채움 사업")).isEmpty(); + assertThat(named("취업성공 디딤돌 청년인턴 사업")).isEmpty(); + } + + // PRD는 주거 정책을 뺀다. 저축·이자 지원이라도 주거 목적이면 제외한다. + @Test + void 주거_정책은_건너뛴다() { + assertThat(named("청년주택드림청약통장")).isEmpty(); + assertThat(named("신혼부부 전세자금 대출이자 지원사업")).isEmpty(); + assertThat(named("신혼부부 임차보증금 이자지원")).isEmpty(); + } + + // 온통청년 분류와 관계없이 이름으로 고른다. 내일채움공제는 '재직자', 학자금 신용회복은 '교육비지원' 분류에 있다. + @Test + void 온통청년_분류와_관계없이_이름으로_고른다() { + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "plcyNm", "대구형 청년 그린 내일채움공제", "mclsfNm", "재직자")))) .isPresent(); + assertThat(parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "plcyNm", "취업청년 정착수당", "mclsfNm", "취약계층 및 금융지원")))) + .isEmpty(); } @Test @@ -111,6 +136,10 @@ class YouthPolicyParserTest { assertThat(draft.orElseThrow().institution()).isEqualTo("금융위원회"); } + private Optional named(String name) { + return parser.parse(policy(Map.of("aplyPrdSeCd", "0057002", "plcyNm", name))); + } + private static JsonNode policy(Map overrides) { ObjectNode node = MAPPER.createObjectNode(); node.put("plcyNo", "20260101000000000001"); From 11c9dbbd1fefcf06c1ffaf906f99a00d4b560888 Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Mon, 2 Nov 2026 01:32:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat=20:=20=EC=A0=95=EC=B1=85=EC=83=81?= =?UTF-8?q?=ED=92=88=202026=20=EC=8B=A0=EC=B2=AD=20=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=EC=99=80=20?= =?UTF-8?q?=EC=84=B8=EC=A2=85=20=EC=83=81=ED=92=88=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/manual-product-rounds.json | 171 ++++++++++++++++++ src/main/resources/manual-products.json | 4 +- 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/manual-product-rounds.json diff --git a/src/main/resources/manual-product-rounds.json b/src/main/resources/manual-product-rounds.json new file mode 100644 index 0000000..9dcfe19 --- /dev/null +++ b/src/main/resources/manual-product-rounds.json @@ -0,0 +1,171 @@ +[ + { + "productCode": "POLICY001", + "rounds": [ + { + "start": "2026-05-04", + "end": "2026-05-20", + "source": "https://www.bokjiro.go.kr/ssis-tbu/cms/pc/customer/notice/1309680_1141.html" + } + ] + }, + { + "productCode": "POLICY002", + "rounds": [ + { + "start": "2026-06-22", + "end": "2026-07-03", + "source": "https://www.fsc.go.kr/no010101/87158" + }, + { + "start": "2026-10-07", + "end": "2026-10-16", + "source": "https://www.fsc.go.kr/no010101/87726" + } + ] + }, + { + "productCode": "POLICY003", + "rounds": [ + { + "start": "2026-08-10", + "end": "2026-08-21", + "source": "https://www.youthcenter.go.kr/youthPolicy/ythPlcyTotalSearch/ythPlcyDetail/20260513005400213197" + } + ] + }, + { + "productCode": "POLICY004", + "rounds": [], + "note": "2026 모집 없음(2026-11-02 기준). 공식 누리집: '사업 시기 및 진행 여부 등 논의 중으로 추후 별도 공지 예정', 모집규모 미정 (https://account.gfgf.kr/main/businessGuide.do). 2025는 08-01~08-18" + }, + { + "productCode": "POLICY005", + "rounds": [ + { + "start": "2026-05-04", + "end": "2026-05-15", + "source": "https://www.metroseoul.co.kr/article/20260429500254" + } + ] + }, + { + "productCode": "POLICY006", + "rounds": [ + { + "start": "2026-03-23", + "end": "2026-04-03", + "source": "https://www.naewoeilbo.com/news/articleView.html?idxno=2323212" + } + ] + }, + { + "productCode": "POLICY007", + "rounds": [ + { + "start": "2026-04-13", + "end": "2026-05-04", + "source": "https://www.kwtotalnews.kr/58910", + "note": "시군별 모집. 춘천시 기준(공고일 04-13, 마감 05-04). 원주시도 05-04 마감 보도. 2026부터 대상 만 15~40세 미만" + } + ] + }, + { + "productCode": "POLICY008", + "rounds": [ + { + "start": "2026-01-08", + "end": "2026-01-18", + "source": "https://www.etoday.co.kr/news/view/2543831" + } + ] + }, + { + "productCode": "POLICY009", + "rounds": [ + { + "start": "2026-04-20", + "end": "2026-05-08", + "source": "https://www.youthcenter.go.kr/youthPolicy/ythPlcyTotalSearch/ythPlcyDetail/20260421005400212832", + "note": "전남 도 일괄 모집. 영암(08-18~08-28)·영광(10-13~10-26) 등 시군 추가 모집은 별도" + } + ] + }, + { + "productCode": "POLICY010", + "rounds": [ + { + "start": "2026-01-19", + "end": "2026-02-12", + "source": "https://m.go.seoul.co.kr/news/2026/01/19/20260119500044" + }, + { + "start": "2026-07-27", + "end": "2026-08-19", + "source": "https://mobile.newsis.com/view/NISX20260728_0003726537" + } + ] + }, + { + "productCode": "POLICY011", + "rounds": [ + { + "start": "2026-03-16", + "end": "2026-04-10", + "source": "https://samchokcci.korcham.net/file/dext5uploaddata/2026/(붙임1) (강원특별자치도 공고 제2026-334호) 2026년 청년 디딤돌 2배 적금 지원사업 시행공고__.pdf" + }, + { + "start": "2026-06-15", + "end": "2026-07-03", + "source": "https://state.gwd.go.kr/egf/bp/common/front/260922/download" + } + ] + }, + { + "productCode": "POLICY012", + "rounds": [ + { + "start": "2026-03-03", + "end": "2026-03-16", + "source": "https://www.gunsan.go.kr/_cms/board/eFileDownload/7/9649658/00cf02fd3a41a165536de21e252d5f93" + } + ] + }, + { + "productCode": "POLICY013", + "rounds": [], + "note": "2026 모집 공고 없음(2026-11-02 기준). 마지막 모집은 2025-04-07~04-18" + }, + { + "productCode": "POLICY014", + "rounds": [ + { + "start": "2026-05-06", + "end": "2026-05-15", + "source": "https://www.news1.kr/local/sejong-chungbuk/6162310", + "note": "2026년 사업명 '세종청년희망적금'(구 세종 청년미래적금). 시작일 05-06은 사용자 확인값, 마감 05-15는 보도자료. 온통청년 등록분(04-01~06-30)과 다름" + } + ] + }, + { + "productCode": "POLICY015", + "rounds": [ + { + "start": "2026-03-23", + "end": "2026-04-15", + "source": "https://www.youthcenter.go.kr/youthPolicy/ythPlcyTotalSearch/ythPlcyDetail/20261020005400214085" + } + ] + }, + { + "productCode": "POLICY016", + "rounds": [ + { + "start": "2026-05-08", + "end": "2026-05-22", + "source": "https://www.kpinews.kr/newsView/1065590365826879", + "note": "시작일 05-08은 사용자 확인값, 마감 05-22는 보도(2026-05-12). 8명 모집, 군청 방문 접수. 2026부터 3년 적립(본인 20만+군 30만)으로 개편" + } + ] + } +] diff --git a/src/main/resources/manual-products.json b/src/main/resources/manual-products.json index 50c7b94..00972ad 100644 --- a/src/main/resources/manual-products.json +++ b/src/main/resources/manual-products.json @@ -368,9 +368,9 @@ }, { "productCode": "POLICY014", - "productName": "세종 청년미래적금", + "productName": "세종청년희망적금", "content": "매칭 유형: 정률 1:1 | 본인 부담금: 월 15만 원 x 36개월 = 540만 원 | 매칭 기여금: 세종시 540만 원(1:1, 만기 일시지급) | 정부기여금 환산수익률: 연 33.3% (3년)", - "cautionText": "세종시. 금융위 상품과 별개 지자체 사업", + "cautionText": "세종시. 2026년 '세종 청년미래적금'에서 이름 변경. 금융위 청년미래적금과 별개 지자체 사업", "properties": [ { "providerCode": "SEJONG", From 1740a91cfe51317562c76a5b7cd40dd0a21b8a0f Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Mon, 2 Nov 2026 01:32:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat=20:=20=EC=A0=95=EC=B1=85=EC=83=81?= =?UTF-8?q?=ED=92=88=20=ED=9A=8C=EC=B0=A8=EB=A5=BC=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=ED=9A=8C=EC=B0=A8=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=20=EB=8F=99=EA=B8=B0=ED=99=94=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=EC=86=8C=EC=8A=A4=EB=A5=BC=20CALENDAR?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/apptive/fin/apicollector/Source.java | 2 +- .../fin/apicollector/batch/SourceDecider.java | 2 +- .../config/FinancialProductSyncJobConfig.java | 29 ++++- .../policyround/PolicyProductRound.java | 68 +++++++++++ .../PolicyProductRoundRepository.java | 6 + .../PolicyProductRoundSyncService.java | 85 ++++++++++++++ .../policyround/ProductRounds.java | 29 +++++ .../tasklet/BankProductUrlTasklet.java | 2 +- .../SyncPolicyProductRoundTasklet.java | 63 ++++++++++ src/main/resources/application-dev.yml | 2 +- .../client/ontong/OntongYouthClientTest.java | 2 +- .../apicollector/config/AllSyncFlowTest.java | 2 + .../PolicyProductRoundSyncServiceTest.java | 110 ++++++++++++++++++ .../tasklet/ManualProductRoundsFileTest.java | 38 ++++++ .../tasklet/SyncYouthPolicyTaskletTest.java | 2 +- 15 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 src/main/java/apptive/fin/apicollector/policyround/PolicyProductRound.java create mode 100644 src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundRepository.java create mode 100644 src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncService.java create mode 100644 src/main/java/apptive/fin/apicollector/policyround/ProductRounds.java create mode 100644 src/main/java/apptive/fin/apicollector/tasklet/SyncPolicyProductRoundTasklet.java create mode 100644 src/test/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncServiceTest.java create mode 100644 src/test/java/apptive/fin/apicollector/tasklet/ManualProductRoundsFileTest.java diff --git a/src/main/java/apptive/fin/apicollector/Source.java b/src/main/java/apptive/fin/apicollector/Source.java index 9d738f1..94876b7 100644 --- a/src/main/java/apptive/fin/apicollector/Source.java +++ b/src/main/java/apptive/fin/apicollector/Source.java @@ -5,5 +5,5 @@ public enum Source { ONTONG, // 온통청년 API FSS, // 금감원 API KFB, // 은행연합회 소비자포털 비교공시(입출금자유예금) - YOUTH_POLICY // 온통청년 청년정책 → B1 캘린더(youth_policy). 상품이 아니라 product에는 쓰지 않는다 + CALENDAR // B1 캘린더: 정책상품 회차(manual-product-rounds.json → policy_product_round)와 온통청년 청년정책(youth_policy) } diff --git a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java index 0871af4..92191b8 100644 --- a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java +++ b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java @@ -23,7 +23,7 @@ public FlowExecutionStatus decide( case FSS -> new FlowExecutionStatus("FSS"); case ONTONG -> new FlowExecutionStatus("ONTONG_YOUTH"); case KFB -> new FlowExecutionStatus("KFB"); - case YOUTH_POLICY -> new FlowExecutionStatus("YOUTH_POLICY"); + case CALENDAR -> new FlowExecutionStatus("CALENDAR"); }; } } diff --git a/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java b/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java index 94b9c7e..4a66227 100644 --- a/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java +++ b/src/main/java/apptive/fin/apicollector/config/FinancialProductSyncJobConfig.java @@ -9,6 +9,7 @@ import apptive.fin.apicollector.tasklet.FetchKfbRawTasklet; import apptive.fin.apicollector.tasklet.FetchManualRawTasklet; import apptive.fin.apicollector.tasklet.ResolveProductDisplayNameTasklet; +import apptive.fin.apicollector.tasklet.SyncPolicyProductRoundTasklet; import apptive.fin.apicollector.tasklet.SyncYouthPolicyTasklet; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.job.Job; @@ -45,7 +46,7 @@ public Job financialProductSyncJob( Flow fssSyncFlow, Flow ontongYouthSyncFlow, Flow kfbSyncFlow, - Flow youthPolicySyncFlow, + Flow calendarSyncFlow, Flow allSyncFlow ) { return new JobBuilder("financialProductSyncJob", jobRepository) @@ -57,7 +58,7 @@ public Job financialProductSyncJob( .from(sourceDecider) .on("KFB").to(kfbSyncFlow) .from(sourceDecider) - .on("YOUTH_POLICY").to(youthPolicySyncFlow) + .on("CALENDAR").to(calendarSyncFlow) .from(sourceDecider) .on("ALL").to(allSyncFlow) .end() @@ -108,11 +109,24 @@ public Step syncYouthPolicyStep( .build(); } + @Bean + public Step syncPolicyProductRoundStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + SyncPolicyProductRoundTasklet syncPolicyProductRoundTasklet + ) { + return new StepBuilder(SyncPolicyProductRoundTasklet.STEP_NAME, jobRepository) + .tasklet(syncPolicyProductRoundTasklet, transactionManager) + .build(); + } + // B1 캘린더는 하루 1회 이상 갱신해야 해서 상품 수집과 따로 돌릴 수 있게 둔다. + // 정책상품 회차는 로컬 파일이라 온통청년 호출보다 먼저 둔다. @Bean - public Flow youthPolicySyncFlow(Step syncYouthPolicyStep) { - return new FlowBuilder("youthPolicySyncFlow") - .start(syncYouthPolicyStep) + public Flow calendarSyncFlow(Step syncPolicyProductRoundStep, Step syncYouthPolicyStep) { + return new FlowBuilder("calendarSyncFlow") + .start(syncPolicyProductRoundStep) + .next(syncYouthPolicyStep) .build(); } @@ -177,10 +191,12 @@ public Flow allSyncFlow( Step deactivateMissingProductStep, Step bankProductUrlStep, Step resolveProductDisplayNameStep, + Step syncPolicyProductRoundStep, Step syncYouthPolicyStep, Step failIfAnyStepFailedStep ) { - // 캘린더 정책 수집은 상품과 무관해 맨 뒤에 둔다. 온통청년이 실패해도 상품 수집은 이미 끝나 있다. + // 캘린더는 맨 뒤에 둔다. 정책상품 회차는 정부상품 정규화 뒤라야 상품을 찾을 수 있고, + // 온통청년이 실패해도 상품 수집은 이미 끝나 있다. // KFB는 외부 HTML 스크래핑이라 깨지기 쉽다. 실패해도 다른 소스는 끝까지 돌리고(KFB 비활성화는 // DeactivateMissingProductTasklet이 건너뜀), 마지막 step에서 잡을 FAILED로 끝내 실패를 드러낸다. return new FlowBuilder("allSyncFlow") @@ -195,6 +211,7 @@ public Flow allSyncFlow( .next(deactivateMissingProductStep) .next(bankProductUrlStep) .next(resolveProductDisplayNameStep) + .next(syncPolicyProductRoundStep) .next(syncYouthPolicyStep) .next(failIfAnyStepFailedStep) .build(); diff --git a/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRound.java b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRound.java new file mode 100644 index 0000000..16c58ac --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRound.java @@ -0,0 +1,68 @@ +package apptive.fin.apicollector.policyround; + +import apptive.fin.apicollector.global.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +/** 1계층(Y-Fin 정책상품) 신청 회차. 스키마는 Fin-BE V10__create_youth_policy_schema.sql */ +@Entity +@Table(name = "policy_product_round") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyProductRound extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "round_no", nullable = false) + private Integer roundNo; + + @Column(name = "apply_start_date", nullable = false) + private LocalDate applyStartDate; + + @Column(name = "apply_end_date", nullable = false) + private LocalDate applyEndDate; + + @Column(name = "source_url", columnDefinition = "TEXT") + private String sourceUrl; + + // 날짜가 바뀐 시각. Fin-BE 캘린더가 7일간 '일정 변경'으로 표시한다. + @Column(name = "schedule_changed_at") + private Instant scheduleChangedAt; + + PolicyProductRound(Long productId, int roundNo, ProductRounds.Round round) { + this.productId = productId; + this.roundNo = roundNo; + this.applyStartDate = round.start(); + this.applyEndDate = round.end(); + this.sourceUrl = round.source(); + } + + /** @return 바뀐 값이 있으면 true */ + boolean apply(ProductRounds.Round round, Instant changedAt) { + boolean datesChanged = !round.start().equals(applyStartDate) || !round.end().equals(applyEndDate); + boolean sourceChanged = !Objects.equals(round.source(), sourceUrl); + if (datesChanged) { + this.applyStartDate = round.start(); + this.applyEndDate = round.end(); + this.scheduleChangedAt = changedAt; + } + this.sourceUrl = round.source(); + return datesChanged || sourceChanged; + } +} diff --git a/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundRepository.java b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundRepository.java new file mode 100644 index 0000000..300b95e --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundRepository.java @@ -0,0 +1,6 @@ +package apptive.fin.apicollector.policyround; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyProductRoundRepository extends JpaRepository { +} diff --git a/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncService.java b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncService.java new file mode 100644 index 0000000..5e29686 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncService.java @@ -0,0 +1,85 @@ +package apptive.fin.apicollector.policyround; + +import apptive.fin.apicollector.Source; +import apptive.fin.apicollector.product.entity.Product; +import apptive.fin.apicollector.product.entity.ProductSource; +import apptive.fin.apicollector.product.repository.ProductRepository; +import apptive.fin.apicollector.product.repository.ProductSourceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * manual-product-rounds.json으로 policy_product_round를 맞춘다. 파일이 원천이라 파일에 없는 회차는 지운다. + * 회차는 상품별 파일 안 순서(round_no)로 맞춰, 날짜가 바뀐 회차만 변경 시각을 남긴다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyProductRoundSyncService { + + // 수동 정부상품은 product_source ONTONG으로 저장된다(ManualProductNormalizer). + private static final String MANUAL_PRODUCT_SOURCE = Source.ONTONG.name(); + + private final PolicyProductRoundRepository roundRepository; + private final ProductSourceRepository productSourceRepository; + private final ProductRepository productRepository; + + /** @param missingProducts DB에 아직 없는 상품 코드(정부상품 수집 전이면 생긴다) */ + public record SyncResult(int inserted, int updated, int deleted, List missingProducts) { + } + + @Transactional + public SyncResult sync(List entries, Instant syncedAt) { + Optional source = productSourceRepository.findByCode(MANUAL_PRODUCT_SOURCE); + Map> existingByProduct = roundRepository.findAll().stream() + .collect(Collectors.groupingBy(PolicyProductRound::getProductId)); + + int inserted = 0; + int updated = 0; + int deleted = 0; + List missingProducts = new ArrayList<>(); + for (ProductRounds entry : entries) { + Optional product = source.flatMap(s -> productRepository.findBySourceAndProductCode(s, entry.productCode())); + if (product.isEmpty()) { + missingProducts.add(entry.productCode()); + continue; + } + Long productId = product.get().getId(); + Map existing = existingByProduct.getOrDefault(productId, List.of()).stream() + .collect(Collectors.toMap(PolicyProductRound::getRoundNo, Function.identity())); + existingByProduct.remove(productId); + + for (int i = 0; i < entry.rounds().size(); i++) { + int roundNo = i + 1; + ProductRounds.Round next = entry.rounds().get(i); + PolicyProductRound round = existing.remove(roundNo); + if (round == null) { + roundRepository.save(new PolicyProductRound(productId, roundNo, next)); + inserted++; + } + else if (round.apply(next, syncedAt)) { + updated++; + } + } + roundRepository.deleteAll(existing.values()); + deleted += existing.size(); + } + + // 파일에서 상품 항목이 통째로 빠졌으면 그 상품의 회차도 지운다. + List orphaned = existingByProduct.values().stream().flatMap(List::stream).toList(); + roundRepository.deleteAll(orphaned); + deleted += orphaned.size(); + + return new SyncResult(inserted, updated, deleted, missingProducts); + } +} diff --git a/src/main/java/apptive/fin/apicollector/policyround/ProductRounds.java b/src/main/java/apptive/fin/apicollector/policyround/ProductRounds.java new file mode 100644 index 0000000..233e77b --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/policyround/ProductRounds.java @@ -0,0 +1,29 @@ +package apptive.fin.apicollector.policyround; + +import java.time.LocalDate; +import java.util.List; + +/** + * manual-product-rounds.json의 한 항목. 상품 코드(manual-products.json의 productCode)별 신청 회차. + * 회차가 없으면(그해 모집이 없거나 공고 전) rounds를 비우고 note에 사유를 적는다. + */ +public record ProductRounds(String productCode, List rounds, String note) { + + /** @param source 신청기간 근거(공고문, 보도자료 등) */ + public record Round(LocalDate start, LocalDate end, String source, String note) { + } + + /** 사람이 고치는 파일이라 저장 전에 형식을 확인한다. 틀리면 파일을 고쳐야 하므로 실패시킨다. */ + public void validate() { + if (productCode == null || productCode.isBlank() || rounds == null) { + throw new IllegalStateException("productCode와 rounds는 필수입니다. productCode=" + productCode); + } + for (Round round : rounds) { + if (round.start() == null || round.end() == null || round.start().isAfter(round.end())) { + throw new IllegalStateException( + "회차의 시작일과 종료일이 없거나 순서가 틀렸습니다. productCode=%s, start=%s, end=%s" + .formatted(productCode, round.start(), round.end())); + } + } + } +} diff --git a/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java index 6c73ba7..13baa3a 100644 --- a/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java +++ b/src/main/java/apptive/fin/apicollector/tasklet/BankProductUrlTasklet.java @@ -86,7 +86,7 @@ private List targetSourceCodes() { return switch (collectorProperties.source()) { case ALL -> List.of(Source.FSS.name(), Source.KFB.name()); case FSS, KFB -> List.of(collectorProperties.source().name()); - case ONTONG, YOUTH_POLICY -> List.of(); + case ONTONG, CALENDAR -> List.of(); }; } diff --git a/src/main/java/apptive/fin/apicollector/tasklet/SyncPolicyProductRoundTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/SyncPolicyProductRoundTasklet.java new file mode 100644 index 0000000..5894d11 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/tasklet/SyncPolicyProductRoundTasklet.java @@ -0,0 +1,63 @@ +package apptive.fin.apicollector.tasklet; + +import apptive.fin.apicollector.policyround.PolicyProductRoundSyncService; +import apptive.fin.apicollector.policyround.PolicyProductRoundSyncService.SyncResult; +import apptive.fin.apicollector.policyround.ProductRounds; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.InputStream; +import java.time.Instant; +import java.util.List; + +/** + * B1 캘린더 1계층(Y-Fin 정책상품) 신청 회차를 manual-product-rounds.json에서 읽어 policy_product_round에 맞춘다. + * 온통청년은 연도·회차마다 정책번호가 바뀌고 공고보다 늦게 올라오는 경우가 많아, 사람이 확인한 회차를 파일로 관리한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SyncPolicyProductRoundTasklet implements Tasklet { + + public static final String STEP_NAME = "syncPolicyProductRoundStep"; + static final String RESOURCE = "/manual-product-rounds.json"; + + private final ObjectMapper objectMapper; + private final PolicyProductRoundSyncService syncService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + List entries = load(objectMapper); + SyncResult result = syncService.sync(entries, Instant.now()); + if (!result.missingProducts().isEmpty()) { + log.warn("SyncPolicyProductRoundTasklet: products not collected yet, rounds skipped. productCodes={}", + result.missingProducts()); + } + log.info("SyncPolicyProductRoundTasklet: products={}, inserted={}, updated={}, deleted={}", + entries.size(), result.inserted(), result.updated(), result.deleted()); + return RepeatStatus.FINISHED; + } + + static List load(ObjectMapper objectMapper) { + try (InputStream in = SyncPolicyProductRoundTasklet.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Policy product rounds resource not found: " + RESOURCE); + } + List entries = List.of(objectMapper.readValue(in, ProductRounds[].class)); + entries.forEach(ProductRounds::validate); + return entries; + } + catch (IllegalStateException e) { + throw e; + } + catch (Exception e) { + throw new IllegalStateException("Failed to load policy product rounds from " + RESOURCE, e); + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9623bd1..8555fff 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -29,7 +29,7 @@ collector: enabled: true # ONTONG = 수동 정부 정책상품(manual-products.json) 동기화. 온통청년 API 폐기로 FetchManualRawTasklet이 원천. # FSS(은행 공시)까지 함께 돌리려면 ALL 로 변경. KFB = 은행연합회 입출금자유예금(파킹통장) 공시. - # YOUTH_POLICY = 온통청년 청년정책 → B1 캘린더(youth_policy). ALL 실행에도 마지막 step으로 들어 있다. + # CALENDAR = B1 캘린더(정책상품 회차 manual-product-rounds.json, 온통청년 청년정책). ALL 실행에도 마지막 step들로 들어 있다. source: ALL mode: SYNC # REQ-001 상품명 후행 괄호 제거 재정규화를 위해 27 -> 28. (PR #20이 27로 올린 것 위에 얹음) diff --git a/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java b/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java index ad346c2..3df284e 100644 --- a/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java +++ b/src/test/java/apptive/fin/apicollector/client/ontong/OntongYouthClientTest.java @@ -75,7 +75,7 @@ private static ResponseCreator html() { private static CollectorProperties properties() { return new CollectorProperties( - true, Source.YOUTH_POLICY, Mode.SYNC, 1, 100, 1, + true, Source.CALENDAR, Mode.SYNC, 1, 100, 1, new CollectorProperties.OntongYouth("http://localhost", "secret-key", PAGE_SIZE), null, null); } diff --git a/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java b/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java index d130d50..0c656ff 100644 --- a/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java +++ b/src/test/java/apptive/fin/apicollector/config/AllSyncFlowTest.java @@ -67,6 +67,7 @@ void runsOtherSourcesAndFailsJobWhenKfbFetchFails() throws Exception { "normalizeKfbRawProductStep", "bankProductUrlStep", "resolveProductDisplayNameStep", + "syncPolicyProductRoundStep", "syncYouthPolicyStep" ); verify(productSyncService).disableAllUnseenProducts(eq(Source.ONTONG), any()); @@ -94,6 +95,7 @@ private JobExecution run(Step fetchKfbRawStep) throws Exception { step("deactivateMissingProductStep", new DeactivateMissingProductTasklet(productSyncService, allSyncProperties())), recording("bankProductUrlStep"), recording("resolveProductDisplayNameStep"), + recording("syncPolicyProductRoundStep"), recording("syncYouthPolicyStep"), step("failIfAnyStepFailedStep", new FailIfAnyStepFailedTasklet()) ); diff --git a/src/test/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncServiceTest.java b/src/test/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncServiceTest.java new file mode 100644 index 0000000..c413e72 --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/policyround/PolicyProductRoundSyncServiceTest.java @@ -0,0 +1,110 @@ +package apptive.fin.apicollector.policyround; + +import apptive.fin.apicollector.policyround.PolicyProductRoundSyncService.SyncResult; +import apptive.fin.apicollector.policyround.ProductRounds.Round; +import apptive.fin.apicollector.support.IntegrationTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Fin-BE V10 스키마(서브모듈)에 실제로 쓰고 읽어 확인한다. */ +class PolicyProductRoundSyncServiceTest extends IntegrationTestSupport { + + private static final Instant FIRST_SYNC = Instant.parse("2026-11-01T00:00:00Z"); + private static final Instant SECOND_SYNC = Instant.parse("2026-11-02T00:00:00Z"); + private static final Round MARCH = new Round(LocalDate.of(2026, 3, 3), LocalDate.of(2026, 3, 16), "https://a", null); + private static final Round JUNE = new Round(LocalDate.of(2026, 6, 15), LocalDate.of(2026, 7, 3), "https://b", null); + + @Autowired private PolicyProductRoundSyncService syncService; + @Autowired private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + deleteProducts(); + jdbcTemplate.update("INSERT INTO product_source (code, name) VALUES ('ONTONG', 'ONTONG') ON CONFLICT (code) DO NOTHING"); + insertProduct("RTEST_1"); + insertProduct("RTEST_2"); + } + + @AfterEach + void tearDown() { + deleteProducts(); + } + + @Test + void 파일의_회차를_순서대로_저장한다() { + SyncResult result = syncService.sync(List.of(entry("RTEST_1", MARCH, JUNE)), FIRST_SYNC); + + assertThat(result).isEqualTo(new SyncResult(2, 0, 0, List.of())); + assertThat(rounds("RTEST_1")).containsExactly( + "1:2026-03-03~2026-03-16:https://a:null", + "2:2026-06-15~2026-07-03:https://b:null"); + } + + @Test + void 날짜가_바뀐_회차만_변경_시각을_남긴다() { + syncService.sync(List.of(entry("RTEST_1", MARCH, JUNE)), FIRST_SYNC); + Round juneExtended = new Round(JUNE.start(), LocalDate.of(2026, 7, 10), JUNE.source(), null); + Round marchNewSource = new Round(MARCH.start(), MARCH.end(), "https://a2", null); + + SyncResult result = syncService.sync(List.of(entry("RTEST_1", marchNewSource, juneExtended)), SECOND_SYNC); + + assertThat(result).isEqualTo(new SyncResult(0, 2, 0, List.of())); + assertThat(rounds("RTEST_1")).containsExactly( + "1:2026-03-03~2026-03-16:https://a2:null", + "2:2026-06-15~2026-07-10:https://b:changed"); + } + + @Test + void 파일에서_빠진_회차와_상품의_회차는_지운다() { + syncService.sync(List.of(entry("RTEST_1", MARCH, JUNE), entry("RTEST_2", MARCH)), FIRST_SYNC); + + SyncResult result = syncService.sync(List.of(entry("RTEST_1", MARCH)), SECOND_SYNC); + + assertThat(result).isEqualTo(new SyncResult(0, 0, 2, List.of())); + assertThat(rounds("RTEST_1")).containsExactly("1:2026-03-03~2026-03-16:https://a:null"); + assertThat(rounds("RTEST_2")).isEmpty(); + } + + // 정부상품 수집 전이면 상품이 없다. 그 상품만 건너뛰고 나머지는 저장한다. + @Test + void 아직_수집되지_않은_상품은_건너뛴다() { + SyncResult result = syncService.sync(List.of(entry("RTEST_NONE", MARCH), entry("RTEST_1", MARCH)), FIRST_SYNC); + + assertThat(result).isEqualTo(new SyncResult(1, 0, 0, List.of("RTEST_NONE"))); + } + + private static ProductRounds entry(String productCode, Round... rounds) { + return new ProductRounds(productCode, List.of(rounds), null); + } + + private void insertProduct(String code) { + jdbcTemplate.update(""" + INSERT INTO product (source_id, type, product_code, product_name, content) + VALUES ((SELECT id FROM product_source WHERE code = 'ONTONG'), 'POLICY', ?, ?, '') + """, code, code); + } + + private List rounds(String productCode) { + return jdbcTemplate.query(""" + SELECT r.round_no, r.apply_start_date, r.apply_end_date, r.source_url, r.schedule_changed_at + FROM policy_product_round r JOIN product p ON p.id = r.product_id + WHERE p.product_code = ? ORDER BY r.round_no + """, + (rs, i) -> rs.getInt(1) + ":" + rs.getDate(2) + "~" + rs.getDate(3) + ":" + rs.getString(4) + ":" + + (rs.getTimestamp(5) == null ? "null" : "changed"), + productCode); + } + + private void deleteProducts() { + jdbcTemplate.update("DELETE FROM product WHERE product_code LIKE 'RTEST_%'"); + } +} diff --git a/src/test/java/apptive/fin/apicollector/tasklet/ManualProductRoundsFileTest.java b/src/test/java/apptive/fin/apicollector/tasklet/ManualProductRoundsFileTest.java new file mode 100644 index 0000000..fac3fd6 --- /dev/null +++ b/src/test/java/apptive/fin/apicollector/tasklet/ManualProductRoundsFileTest.java @@ -0,0 +1,38 @@ +package apptive.fin.apicollector.tasklet; + +import apptive.fin.apicollector.policyround.ProductRounds; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.io.InputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 사람이 고치는 manual-product-rounds.json이 읽히고, 상품 코드가 manual-products.json과 맞는지 확인한다. */ +class ManualProductRoundsFileTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void 회차_파일은_형식이_맞고_정부상품_코드를_빠짐없이_가진다() throws Exception { + List entries = SyncPolicyProductRoundTasklet.load(objectMapper); + + Set roundCodes = new HashSet<>(); + entries.forEach(entry -> assertThat(roundCodes.add(entry.productCode())).as("중복 " + entry.productCode()).isTrue()); + assertThat(roundCodes).isEqualTo(manualProductCodes()); + } + + private Set manualProductCodes() throws Exception { + try (InputStream in = getClass().getResourceAsStream("/manual-products.json")) { + Set codes = new HashSet<>(); + for (JsonNode product : objectMapper.readTree(in)) { + codes.add(product.get("productCode").asString()); + } + return codes; + } + } +} diff --git a/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java b/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java index be653d2..45244fe 100644 --- a/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java +++ b/src/test/java/apptive/fin/apicollector/tasklet/SyncYouthPolicyTaskletTest.java @@ -68,7 +68,7 @@ class SyncYouthPolicyTaskletTest { private SyncYouthPolicyTasklet tasklet(Mode mode) { CollectorProperties properties = new CollectorProperties( - true, Source.YOUTH_POLICY, mode, 1, 100, 1, null, null, null); + true, Source.CALENDAR, mode, 1, 100, 1, null, null, null); return new SyncYouthPolicyTasklet(client, parser, syncService, properties); } From c0081f5460b72b2ada641bd2937ad2b2384f79cc Mon Sep 17 00:00:00 2001 From: yuyeol3 Date: Mon, 2 Nov 2026 01:32:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore=20:=20fin-be=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=84=20=EC=BA=98=EB=A6=B0=EB=8D=94=201?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=B0=98=EC=98=81=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fin-be | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fin-be b/fin-be index 7ef2b57..a877ba8 160000 --- a/fin-be +++ b/fin-be @@ -1 +1 @@ -Subproject commit 7ef2b572e02f79c42b0d5a2a3452e3bc773d61f2 +Subproject commit a877ba8bd79613bbdbbfd743c3b4c942b0aa0cb1