Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fin-be
Submodule fin-be updated 50 files
+1 −1 .github/workflows/backend-ci.yml
+6 −0 README.md
+56 −0 docs/README.md
+80 −0 docs/implementation/architecture.md
+85 −0 docs/implementation/auth-and-access.md
+63 −0 docs/implementation/calendar.md
+130 −0 docs/implementation/data-model.md
+100 −0 docs/implementation/detail-calculator-favorites.md
+74 −0 docs/implementation/development.md
+66 −0 docs/implementation/plan-vs-implementation.md
+201 −0 docs/implementation/recommendation-engine.md
+228 −0 docs/planning/a1-a3-product-expansion.md
+377 −0 docs/planning/b1-government-calendar.md
+441 −0 docs/planning/b2-notification.md
+71 −0 docs/planning/feature-map.md
+74 −0 docs/planning/service-overview.md
+98 −0 docs/planning/y1-auth-and-terms.md
+50 −0 docs/planning/y2-landing-and-explore.md
+126 −0 docs/planning/y3-1-input.md
+192 −0 docs/planning/y3-2-recommendation-list.md
+117 −0 docs/planning/y4-product-detail.md
+106 −0 docs/planning/y5-mypage.md
+39 −0 src/main/java/apptive/fin/calendar/controller/CalendarController.java
+11 −0 src/main/java/apptive/fin/calendar/dto/CalendarEventDto.java
+6 −0 src/main/java/apptive/fin/calendar/dto/CalendarEventType.java
+11 −0 src/main/java/apptive/fin/calendar/dto/CalendarEventsResponseDto.java
+40 −0 src/main/java/apptive/fin/calendar/dto/CalendarRoundDto.java
+19 −0 src/main/java/apptive/fin/calendar/dto/CalendarSummaryResponseDto.java
+7 −0 src/main/java/apptive/fin/calendar/dto/CalendarTier.java
+8 −0 src/main/java/apptive/fin/calendar/dto/Eligibility.java
+8 −0 src/main/java/apptive/fin/calendar/dto/ScheduleKind.java
+8 −0 src/main/java/apptive/fin/calendar/entity/ApplyPeriodType.java
+41 −0 src/main/java/apptive/fin/calendar/entity/PolicyProductRound.java
+52 −0 src/main/java/apptive/fin/calendar/entity/YouthPolicy.java
+38 −0 src/main/java/apptive/fin/calendar/entity/YouthPolicyRound.java
+27 −0 src/main/java/apptive/fin/calendar/repository/PolicyProductRoundRepository.java
+17 −0 src/main/java/apptive/fin/calendar/repository/YouthPolicyRepository.java
+29 −0 src/main/java/apptive/fin/calendar/repository/YouthPolicyRoundRepository.java
+47 −0 src/main/java/apptive/fin/calendar/service/CalendarSchedule.java
+254 −0 src/main/java/apptive/fin/calendar/service/CalendarService.java
+108 −0 src/main/java/apptive/fin/calendar/service/PolicyEligibility.java
+1 −0 src/main/java/apptive/fin/global/config/SecurityConfig.java
+14 −0 src/main/java/apptive/fin/global/error/GlobalExceptionHandler.java
+1 −45 src/main/resources/application-dev.yml
+11 −0 src/main/resources/application-server.yml
+47 −0 src/main/resources/application.yml
+65 −0 src/main/resources/db/migration/V10__create_youth_policy_schema.sql
+324 −0 src/test/java/apptive/fin/calendar/CalendarApiIntegrationTest.java
+89 −0 src/test/java/apptive/fin/calendar/CalendarScheduleTest.java
+87 −0 src/test/java/apptive/fin/calendar/PolicyEligibilityTest.java
3 changes: 2 additions & 1 deletion src/main/java/apptive/fin/apicollector/Source.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum Source {
ALL,
ONTONG, // 온통청년 API
FSS, // 금감원 API
KFB // 은행연합회 소비자포털 비교공시(입출금자유예금)
KFB, // 은행연합회 소비자포털 비교공시(입출금자유예금)
CALENDAR // B1 캘린더: 정책상품 회차(manual-product-rounds.json → policy_product_round)와 온통청년 청년정책(youth_policy)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public FlowExecutionStatus decide(
case FSS -> new FlowExecutionStatus("FSS");
case ONTONG -> new FlowExecutionStatus("ONTONG_YOUTH");
case KFB -> new FlowExecutionStatus("KFB");
case CALENDAR -> new FlowExecutionStatus("CALENDAR");
};
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -49,7 +54,24 @@ public List<JsonNode> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
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;
import org.springframework.batch.core.job.builder.FlowBuilder;
Expand Down Expand Up @@ -44,6 +46,7 @@ public Job financialProductSyncJob(
Flow fssSyncFlow,
Flow ontongYouthSyncFlow,
Flow kfbSyncFlow,
Flow calendarSyncFlow,
Flow allSyncFlow
) {
return new JobBuilder("financialProductSyncJob", jobRepository)
Expand All @@ -54,6 +57,8 @@ public Job financialProductSyncJob(
.on("ONTONG_YOUTH").to(ontongYouthSyncFlow)
.from(sourceDecider)
.on("KFB").to(kfbSyncFlow)
.from(sourceDecider)
.on("CALENDAR").to(calendarSyncFlow)
.from(sourceDecider)
.on("ALL").to(allSyncFlow)
.end()
Expand Down Expand Up @@ -93,6 +98,38 @@ 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();
}

@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 calendarSyncFlow(Step syncPolicyProductRoundStep, Step syncYouthPolicyStep) {
return new FlowBuilder<Flow>("calendarSyncFlow")
.start(syncPolicyProductRoundStep)
.next(syncYouthPolicyStep)
.build();
}

@Bean
public Flow fssSyncFlow(
Step fetchFssRawStep,
Expand Down Expand Up @@ -154,8 +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<Flow>("allSyncFlow")
Expand All @@ -170,6 +211,8 @@ public Flow allSyncFlow(
.next(deactivateMissingProductStep)
.next(bankProductUrlStep)
.next(resolveProductDisplayNameStep)
.next(syncPolicyProductRoundStep)
.next(syncYouthPolicyStep)
.next(failIfAnyStepFailedStep)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package apptive.fin.apicollector.policyround;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PolicyProductRoundRepository extends JpaRepository<PolicyProductRound, Long> {
}
Original file line number Diff line number Diff line change
@@ -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<String> missingProducts) {
}

@Transactional
public SyncResult sync(List<ProductRounds> entries, Instant syncedAt) {
Optional<ProductSource> source = productSourceRepository.findByCode(MANUAL_PRODUCT_SOURCE);
Map<Long, List<PolicyProductRound>> existingByProduct = roundRepository.findAll().stream()
.collect(Collectors.groupingBy(PolicyProductRound::getProductId));

int inserted = 0;
int updated = 0;
int deleted = 0;
List<String> missingProducts = new ArrayList<>();
for (ProductRounds entry : entries) {
Optional<Product> product = source.flatMap(s -> productRepository.findBySourceAndProductCode(s, entry.productCode()));
if (product.isEmpty()) {
missingProducts.add(entry.productCode());
continue;
}
Long productId = product.get().getId();
Map<Integer, PolicyProductRound> 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<PolicyProductRound> orphaned = existingByProduct.values().stream().flatMap(List::stream).toList();
roundRepository.deleteAll(orphaned);
deleted += orphaned.size();

return new SyncResult(inserted, updated, deleted, missingProducts);
}
}
Original file line number Diff line number Diff line change
@@ -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<Round> 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()));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private List<String> 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, CALENDAR -> List.of();
};
}

Expand Down
Loading
Loading