Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.dday.domain.auth.entity.PhoneVerification;
import com.dday.domain.auth.entity.VerificationPurpose;
import com.dday.domain.auth.repository.PhoneVerificationRepository;
import com.dday.domain.credit.service.CreditDemoProvisioner;
import com.dday.domain.user.entity.User;
import com.dday.domain.user.entity.UserStatus;
import com.dday.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -53,6 +54,9 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;

/** ⚠️ 시연용이다. 실제 신용평가사가 붙으면 이 의존과 {@link #signup} 안의 호출을 지운다. */
private final CreditDemoProvisioner creditDemoProvisioner;

// ── 휴대폰 인증 ──────────────────────────────────────────────────────────

@Transactional
Expand Down Expand Up @@ -132,6 +136,16 @@ public SignupResponse signup(SignupRequest request) {
.agreedLocation(request.isAgreedLocation())
.build());

/*
* ⚠️ 시연용. 신용점수 이력과 비금융 납부 이력(통신요금·건강보험료·국민연금)을
* 데모 계정에서 복제해 붙인다. 이 둘은 마이데이터 연동으로도 생기지 않아서
* (연동은 계좌·카드만 가져온다) 가입 시점에 넣지 않으면 신용관리 화면이 빈 채로 뜬다.
*
* 마이데이터 연동을 건너뛴 회원도 화면을 볼 수 있어야 해서 연동이 아니라 가입에 건다.
* 원본 시드가 없으면 조용히 건너뛰므로 가입 자체는 실패하지 않는다.
*/
creditDemoProvisioner.provision(user.getUserId());

return SignupResponse.builder()
.userId(user.getUserId())
.accessToken(jwtTokenProvider.createAccessToken(user.getUserId()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.dday.domain.credit.entity.CreditScore;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
Expand All @@ -27,4 +30,30 @@ public interface CreditScoreRepository extends JpaRepository<CreditScore, Long>
* 가장 최근 기록 한 건. 정렬 기준은 위와 같다 — 같은 시각의 기록이 섞이면 "최신"이 흔들린다.
*/
Optional<CreditScore> findFirstByUserUserIdOrderByUpdatedAtDescCreditScoreIdDesc(Long userId);

/** 이 회원에게 이력이 한 건이라도 있는지. 데모 이력을 두 번 붙이지 않으려고 본다. */
boolean existsByUserUserId(Long userId);

/**
* 다른 회원의 이력을 통째로 복제한다. 시연에서 누가 가입하든 같은 그래프가 보이게 하려고
* {@code CreditDemoProvisioner}가 부른다. 새로 넣은 건수를 돌려준다.
*
* <p><b>JPA가 아니라 네이티브 SQL을 쓰는 이유는 {@code updated_at} 때문이다.</b>
* 이 컬럼은 {@code @UpdateTimestamp}라, 엔티티로 저장하면 Hibernate가 무조건 '지금'으로
* 덮어쓴다. 그런데 화면은 이 값을 각 기록의 <b>월 표시</b>로 쓰므로(신용점수 이력 표),
* 여섯 건이 전부 오늘 날짜가 되면 같은 달이 여섯 줄 뜬다. 원본의 시각을 그대로 옮기려면
* 엔티티 생명주기를 우회하는 수밖에 없다.
*
* <p>{@code credit_score}에는 유일 제약이 없어 이 문장만으로는 멱등하지 않다.
* 중복 방지는 호출부의 {@link #existsByUserUserId} 검사가 맡는다.
*/
@Modifying(flushAutomatically = true)
@Query(value = """
insert into credit_score (user_id, agency, score, created_at, updated_at)
select :userId, t.agency, t.score, t.created_at, t.updated_at
from credit_score t
where t.user_id = :templateUserId
""", nativeQuery = true)
int copyHistoryFrom(@Param("userId") Long userId,
@Param("templateUserId") Long templateUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.dday.domain.credit.entity.NonFinancialPayment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

Expand All @@ -15,4 +18,29 @@
public interface NonFinancialPaymentRepository extends JpaRepository<NonFinancialPayment, Long> {

List<NonFinancialPayment> findAllByUserUserId(Long userId);

/**
* 다른 회원의 납부 이력을 통째로 복제한다. 시연에서 누가 가입하든 같은 납부 기록이
* 보이게 하려고 {@code CreditDemoProvisioner}가 부른다. 새로 넣은 건수를 돌려준다.
*
* <p><b>네이티브 SQL인 이유는 청구월·납부일을 원본 그대로 옮겨야 하기 때문이다.</b>
* {@code NonFinancialPaymentService.sync}는 '오늘'을 기준으로 12개월치를 만들어 내는데,
* 시드가 손으로 맞춰 둔 연체 위치(2026-03 통신요금)와 개월 수가 달라진다. 시연 화면을
* 시드와 똑같이 맞추려면 만들어 내는 게 아니라 복제해야 한다.
*
* <p>{@code insert ignore}라 여러 번 불러도 안전하다 —
* {@code uk_nonfinancial_payment_user_type_month}가 이미 있는 달을 걸러낸다.
*/
@Modifying(flushAutomatically = true)
@Query(value = """
insert ignore into nonfinancial_payment (
user_id, payment_type, institution_name, billing_month, amount,
due_date, paid_date, status, created_at, updated_at)
select :userId, t.payment_type, t.institution_name, t.billing_month, t.amount,
t.due_date, t.paid_date, t.status, t.created_at, t.updated_at
from nonfinancial_payment t
where t.user_id = :templateUserId
""", nativeQuery = true)
int copyHistoryFrom(@Param("userId") Long userId,
@Param("templateUserId") Long templateUserId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.dday.domain.credit.service;

import com.dday.domain.credit.repository.CreditScoreRepository;
import com.dday.domain.credit.repository.NonFinancialPaymentRepository;
import com.dday.domain.mockmydata.service.MockMydataProvisioner;
import com.dday.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 새로 가입한 회원에게 <b>신용관리 데모 데이터를 붙여준다.</b>
*
* <p>신용관리 화면 여섯 조각 중 넷은 마이데이터 연동이 알아서 채운다 —
* 보유 카드({@code user_card})와 대출 목록({@code user_account})은
* {@link MockMydataProvisioner}가 복제한 목데이터를 동기화가 옮겨온 결과다.
* 나머지 둘, <b>신용점수 이력과 비금융 납부 이력(통신요금·건강보험료·국민연금)은
* 어디서도 만들어지지 않아</b> 신규 가입자에게는 빈 화면이 떴다. 이 클래스가 그 둘을 채운다.
*
* <p><b>코드에 점수·금액을 다시 적지 않고 DB의 원본을 읽어 복제하는 이유</b>는
* {@link MockMydataProvisioner}와 같다. 하드코딩하면 {@code data.sql}의 시드와 두 벌이 되어
* 한쪽만 고쳤을 때 조용히 어긋난다. 데모 데이터를 바꾸고 싶으면 {@code data.sql} 한 곳만 고친다.
*
* <p>{@code NonFinancialPaymentService.sync}가 이미 데모 이력을 <i>만들어</i> 내지만 그걸 쓰지
* 않는다. 그쪽은 '오늘'을 기준으로 12개월치를 생성해서, 시드가 손으로 맞춰 둔 개월 수(6개월)와
* 연체 위치(2026-03 통신요금)가 달라진다. 요구는 "시드 그대로"라 생성이 아니라 복제여야 한다.
*
* <p><b>⚠️ 해커톤 시연 전용이다.</b> 실제 신용평가사·기관이 붙으면 이 클래스와 호출부를 지운다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CreditDemoProvisioner {

private final UserRepository userRepository;
private final CreditScoreRepository creditScoreRepository;
private final NonFinancialPaymentRepository paymentRepository;

/**
* 이 회원의 신용관리 데모 데이터를 만든다. <b>여러 번 불러도 안전하다</b> —
* 가입 때 한 번, 마이데이터 연동 때 한 번 불리므로 두 배가 되면 안 된다.
*
* <p>원본이 없으면(시드를 안 넣은 DB) 조용히 건너뛴다. 데모 데이터가 없다고 해서
* 회원가입이나 연동 자체가 실패하면 안 되기 때문이다 —
* {@link MockMydataProvisioner#provision}과 같은 방침이다.
*
* @return 실제로 한 건이라도 넣었으면 true, 건너뛰었으면 false
*/
@Transactional
public boolean provision(Long userId) {
if (userId == null) {
return false;
}

/*
* 원본은 목데이터와 같은 데모 계정이다. 상수를 여기서 새로 선언하지 않고
* MockMydataProvisioner 것을 그대로 쓴다 — 두 벌이 되면 한쪽만 바뀌었을 때
* 카드·대출은 A 계정에서, 신용점수는 B 계정에서 오는 화면이 된다.
*/
Long templateUserId = userRepository.findByEmail(MockMydataProvisioner.TEMPLATE_EMAIL)
.map(user -> user.getUserId())
.orElse(null);
if (templateUserId == null) {
log.warn("신용 데모 원본({})이 없어 복제를 건너뛴다. data.sql 시드를 확인할 것.",
MockMydataProvisioner.TEMPLATE_EMAIL);
return false;
}
if (templateUserId.equals(userId)) {
return false; // 원본 본인이면 복제할 게 없다
}

// 신용점수는 유일 제약이 없어 SQL만으로는 멱등하지 않다. 있으면 아예 넣지 않는다.
int scores = creditScoreRepository.existsByUserUserId(userId)
? 0
: creditScoreRepository.copyHistoryFrom(userId, templateUserId);

// 납부 이력은 (user_id, payment_type, billing_month) 유일 제약 + insert ignore라
// 그냥 다시 불러도 이미 있는 달이 걸러진다.
int payments = paymentRepository.copyHistoryFrom(userId, templateUserId);

if (scores + payments == 0) {
return false;
}

log.info("신용 데모 데이터를 복제했다: userId={}, 신용점수={}건, 납부이력={}건",
userId, scores, payments);
return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dday.domain.mydata.service;

import com.dday.domain.credit.service.CreditDemoProvisioner;
import com.dday.domain.mockmydata.service.MockMydataProvisioner;
import com.dday.domain.mydata.dto.response.MydataConnectResponse;
import com.dday.domain.mydata.repository.UserAccountRepository;
Expand All @@ -15,7 +16,7 @@
/**
* 마이데이터 연결. 온보딩의 "기관 선택 → 연동" 화면이 부른다.
*
* <p>네 가지를 한 번에 끝낸다. 화면에서는 버튼 하나라 왕복을 나누면 중간에 실패했을 때
* <p>다섯 가지를 한 번에 끝낸다. 화면에서는 버튼 하나라 왕복을 나누면 중간에 실패했을 때
* "동의는 됐는데 데이터는 없는" 어중간한 상태가 남는다.
*
* <ol>
Expand All @@ -27,13 +28,17 @@
* '자유 포켓'을 찾지 못해 404로 끝난다. 가입만으로는 포켓이 생기지 않는다.</li>
* <li><b>첫 동기화</b> — 목 서버의 거래를 우리 테이블로 옮긴다. 여기까지 해야
* 연동 직후 화면에 소비 내역이 뜬다.</li>
* <li><b>신용 데모 데이터 보정</b> — 신용점수·비금융 납부 이력은 가입 때 붙지만
* ({@code AuthService.signup}), 그 전에 가입한 회원과 시드로 만든 계정은 비어 있다.
* 여기서 한 번 더 불러 메운다 ({@link CreditDemoProvisioner}는 멱등하다).</li>
* </ol>
*
* <p>클래스에 {@code @Transactional}을 붙이지 않는다. 마지막 단계가 외부 공급자 호출을
* 포함해 오래 걸리기 때문이다({@link MydataService} 주석 참고). 쓰기 원자성은 각 단계의
* 짧은 트랜잭션이 맡는다.
*
* <p><b>⚠️ 2번은 해커톤 시연 전용이다.</b> 실제 마이데이터를 붙이면 그 단계만 빼면 된다.
* <p><b>⚠️ 2번과 5번은 해커톤 시연 전용이다.</b> 실제 마이데이터·신용평가사를 붙이면
* 그 두 단계만 빼면 된다.
*/
@Slf4j
@Service
Expand All @@ -48,6 +53,7 @@ public class MydataConnectService {

private final MydataConsentWriter consentWriter;
private final MockMydataProvisioner mockMydataProvisioner;
private final CreditDemoProvisioner creditDemoProvisioner;
private final MydataService mydataService;
private final UserAccountRepository userAccountRepository;
private final UserCardRepository userCardRepository;
Expand All @@ -69,7 +75,12 @@ public MydataConnectResponse connect(Long userId) {

MydataSyncResponse sync = mydataService.sync(userId, null, null);

log.info("마이데이터 연결: userId={}, 목데이터 복제={}", userId, provisioned);
// 가입 때 이미 붙었으면 아무 일도 하지 않는다. 이 호출은 그 전에 가입한 회원과
// 시드로 만든 계정처럼 신용 이력이 비어 있는 경우를 메우려고 남겨둔다.
boolean creditProvisioned = creditDemoProvisioner.provision(userId);

log.info("마이데이터 연결: userId={}, 목데이터 복제={}, 신용 데모 복제={}",
userId, provisioned, creditProvisioned);

// 동기화가 끝난 뒤 읽어야 이번에 새로 붙은 계좌까지 응답에 담긴다.
// 비활성 계좌·카드는 뺀다 — 기관을 정리한 뒤에도 예전에 연동했던 회원은 죽은
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dday.domain.auth.entity.PhoneVerification;
import com.dday.domain.auth.entity.VerificationPurpose;
import com.dday.domain.auth.repository.PhoneVerificationRepository;
import com.dday.domain.credit.service.CreditDemoProvisioner;
import com.dday.domain.user.entity.User;
import com.dday.domain.user.repository.UserRepository;
import com.dday.global.common.jwt.JwtTokenProvider;
Expand Down Expand Up @@ -49,15 +50,17 @@ class AuthServiceTest {
private PhoneVerificationRepository phoneVerificationRepository;
@Mock
private JwtTokenProvider jwtTokenProvider;
@Mock
private CreditDemoProvisioner creditDemoProvisioner;

private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

private AuthService authService;

@BeforeEach
void setUp() {
authService = new AuthService(
userRepository, phoneVerificationRepository, passwordEncoder, jwtTokenProvider);
authService = new AuthService(userRepository, phoneVerificationRepository, passwordEncoder,
jwtTokenProvider, creditDemoProvisioner);

given(userRepository.existsByEmail(any())).willReturn(false);
given(userRepository.save(any())).willAnswer(inv -> {
Expand Down Expand Up @@ -110,6 +113,31 @@ private static ErrorCode errorCodeOf(Throwable e) {
assertThat(response.getAccessToken()).isEqualTo("access");
}

/**
* 신용관리 화면(신용점수 이력·납부 이력)은 가입 시점에 채워지지 않으면 빈 채로 뜬다.
* 마이데이터 연동은 계좌·카드만 가져오므로 그쪽에 기댈 수 없다.
*/
@Test
void 가입하면_신용_데모_데이터가_붙는다() {
givenVerification(VerificationPurpose.SIGNUP, LocalDateTime.now().minusMinutes(5));

authService.signup(signupRequest());

verify(creditDemoProvisioner).provision(1L);
}

@Test
void 휴대폰_인증에_실패하면_신용_데모_데이터를_붙이지_않는다() {
given(phoneVerificationRepository
.findTopByPhoneAndPurposeAndVerifiedTrueOrderByVerificationIdDesc(any(), any()))
.willReturn(Optional.empty());

assertThatThrownBy(() -> authService.signup(signupRequest()))
.isInstanceOf(BusinessException.class);

verify(creditDemoProvisioner, never()).provision(any());
}

@Test
void 휴대폰_인증_없이는_가입할_수_없다() {
// 발송·검증 기록이 아예 없는 상태 — signup API를 직접 호출한 경우다.
Expand Down
Loading