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 @@ -21,7 +21,7 @@ public class AuthController {
private final AuthService authService;

@Operation(summary = "휴대폰 인증번호 발송", description = """
실제 SMS는 보내지 않는다. 인증번호는 항상 `000000`이고 3분 뒤 만료된다.
실제 SMS는 보내지 않는다. 인증번호는 항상 `371829`이고 3분 뒤 만료된다.
""")
@PostMapping("/phone/send")
public ResponseEntity<ApiResponse<PhoneSendResponse>> sendPhoneCode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
public class AuthService {

/** 목 인증번호. 실 발송을 붙이는 순간 함께 제거한다. */
private static final String MOCK_CODE = "000000";
private static final String MOCK_CODE = "371829";

/** 인증번호 유효시간(초). 응답의 expiresIn으로도 나간다. */
private static final int CODE_EXPIRES_IN_SECONDS = 180;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,61 @@
package com.dday.domain.mydata.dto.response;

import com.dday.domain.mydata.entity.AccountType;
import com.dday.domain.mydata.entity.Institution;
import com.dday.domain.mydata.entity.UserAccount;
import com.dday.domain.mydata.entity.UserCard;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* 마이데이터 연결 결과. 연동 완료 화면과 "모아둔 돈" 화면이 함께 쓴다.
*
* <p>연결과 첫 동기화가 한 번에 끝나므로 불러온 계좌를 그대로 담아 준다 —
* 프론트가 연결 직후 계좌 목록을 또 부를 필요가 없다.
* <p>연결과 첫 동기화가 한 번에 끝나므로 불러온 계좌·카드를 그대로 담아 준다 —
* 프론트가 연결 직후 목록을 또 부를 필요가 없다.
*/
@Getter
@Builder
@AllArgsConstructor
public class MydataConnectResponse {

/** 연결된 계좌 수. 완료 화면이 "N개의 금융기관이 연결되었습니다"에 쓴다. */
/**
* 연결된 <b>기관</b> 수 — 계좌·카드 행 수가 아니다. 완료 화면이
* "N개의 금융기관이 연결되었습니다"에 쓰는데, 국민은행 계좌가 3개라고 3개 기관은
* 아니다. {@link #institutions}의 크기와 같다.
*/
private final int connectedCount;

private final List<ConnectedAccount> accounts;

/** 연결된 기관 목록(계좌·카드 통틀어 중복 제거). 완료 화면의 체크리스트가 이걸 그린다. */
private final List<ConnectedInstitution> institutions;

/** 이번에 새로 저장된 거래 수. 재연결이면 0이다(이미 있는 거래는 건너뛴다). */
private final int insertedTransactionCount;

public static MydataConnectResponse of(List<UserAccount> userAccounts, int insertedTransactionCount) {
public static MydataConnectResponse of(List<UserAccount> userAccounts, List<UserCard> userCards,
int insertedTransactionCount) {
List<ConnectedAccount> accounts = userAccounts.stream()
.map(ConnectedAccount::from)
.toList();

// orgCode로 중복을 걸러내되, 처음 등장한 순서(계좌 먼저, 카드는 그 다음)를 유지한다.
Map<String, ConnectedInstitution> institutionsByOrgCode = new LinkedHashMap<>();
userAccounts.forEach(account -> institutionsByOrgCode
.computeIfAbsent(account.getOrgCode(), ConnectedInstitution::of));
userCards.forEach(card -> institutionsByOrgCode
.computeIfAbsent(card.getOrgCode(), ConnectedInstitution::of));
List<ConnectedInstitution> institutions = List.copyOf(institutionsByOrgCode.values());

return MydataConnectResponse.builder()
.connectedCount(accounts.size())
.connectedCount(institutions.size())
.accounts(accounts)
.institutions(institutions)
.insertedTransactionCount(insertedTransactionCount)
.build();
}
Expand All @@ -61,12 +82,19 @@ public static class ConnectedAccount {

private final Long balance;

/**
* DEPOSIT · SAVINGS · LOAN · INVESTMENT · ETC. "모아둔 자산" 화면이 대출 잔액을
* 자산으로 합산하지 않으려면 이 값으로 걸러야 한다 — 대출은 빚이지 자산이 아니다.
*/
private final AccountType accountType;

public static ConnectedAccount from(UserAccount account) {
return ConnectedAccount.builder()
.institutionId(account.getOrgCode())
.institutionName(Institution.nameOf(account.getOrgCode()))
.accountNumber(mask(account.getAccountNum()))
.balance(account.getBalance())
.accountType(account.getAccountType())
.build();
}

Expand All @@ -81,4 +109,21 @@ private static String mask(String accountNum) {
return "****" + accountNum.substring(accountNum.length() - 4);
}
}

/** 연동된 기관 하나(계좌든 카드든). 완료 화면이 "무엇을 연결했는지"만 보여줄 때 쓴다. */
@Getter
@Builder
@AllArgsConstructor
public static class ConnectedInstitution {

private final String institutionId;
private final String institutionName;

static ConnectedInstitution of(String orgCode) {
return ConnectedInstitution.builder()
.institutionId(orgCode)
.institutionName(Institution.nameOf(orgCode))
.build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public enum Institution {

HYUNDAI_CARD("0302", "현대카드", FinancialSector.NON_BANK),
SAMSUNG_CARD("0303", "삼성카드", FinancialSector.NON_BANK),
KB_CARD("0301", "KB국민카드", FinancialSector.NON_BANK),
KB_CARD("0301", "국민카드", FinancialSector.NON_BANK),
SHINHAN_CARD("0306", "신한카드", FinancialSector.NON_BANK),
WOORI_CARD("0313", "우리카드", FinancialSector.NON_BANK),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ Optional<UserAccount> findByUserUserIdAndOrgCodeAndAccountNum(
/** 표시 순서가 화면에서 바뀌면 안 되니 등록 순서(id)로 고정한다. */
List<UserAccount> findAllByUserUserIdOrderByAccountIdAsc(Long userId);

/**
* 연동 직후 응답(연결된 기관·계좌 수)에 쓴다. <b>비활성 계좌를 뺀다</b> — 안 그러면
* 기관을 정리해도(예: 신한 계좌 삭제) 예전에 연동했던 회원은 죽은 계좌까지 세어
* "연결된 기관 수"가 실제보다 많게 나온다.
*/
List<UserAccount> findAllByUserUserIdAndActiveTrueOrderByAccountIdAsc(Long userId);

/**
* <b>id만으로 찾지 않고 소유자를 함께 건다.</b> 마이페이지에서 계좌 선택여부를 바꿀 때
* 이 메서드를 쓰면 남의 계좌를 건드리는 요청이 조회 단계에서 빈 값이 되어 애초에 진행되지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Optional<UserCard> findByUserUserIdAndOrgCodeAndCardIdentifier(
/** 표시 순서가 화면에서 바뀌면 안 되니 등록 순서(id)로 고정한다. */
List<UserCard> findAllByUserUserIdOrderByCardIdAsc(Long userId);

/** 연동 직후 응답에 쓴다. 비활성 카드를 빼는 이유는 {@link UserAccountRepository}와 같다. */
List<UserCard> findAllByUserUserIdAndActiveTrueOrderByCardIdAsc(Long userId);

/** 기관 연결 해제 대상을 찾는다. 같은 기관 카드가 여러 개일 수 있어 리스트다. */
List<UserCard> findAllByUserUserIdAndOrgCode(Long userId, String orgCode);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.dday.domain.mockmydata.service.MockMydataProvisioner;
import com.dday.domain.mydata.dto.response.MydataConnectResponse;
import com.dday.domain.mydata.repository.UserAccountRepository;
import com.dday.domain.mydata.repository.UserCardRepository;
import com.dday.domain.pocket.service.PocketService;
import com.dday.domain.mydata.dto.response.MydataSyncResponse;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -49,6 +50,7 @@ public class MydataConnectService {
private final MockMydataProvisioner mockMydataProvisioner;
private final MydataService mydataService;
private final UserAccountRepository userAccountRepository;
private final UserCardRepository userCardRepository;
private final PocketService pocketService;

/**
Expand All @@ -70,8 +72,11 @@ public MydataConnectResponse connect(Long userId) {
log.info("마이데이터 연결: userId={}, 목데이터 복제={}", userId, provisioned);

// 동기화가 끝난 뒤 읽어야 이번에 새로 붙은 계좌까지 응답에 담긴다.
// 비활성 계좌·카드는 뺀다 — 기관을 정리한 뒤에도 예전에 연동했던 회원은 죽은
// 행까지 세어 "연결된 기관 수"가 실제보다 많게 나오면 안 된다.
return MydataConnectResponse.of(
userAccountRepository.findAllByUserUserIdOrderByAccountIdAsc(userId),
userAccountRepository.findAllByUserUserIdAndActiveTrueOrderByAccountIdAsc(userId),
userCardRepository.findAllByUserUserIdAndActiveTrueOrderByCardIdAsc(userId),
sync.getInsertedTransactionCount());
}
}
39 changes: 31 additions & 8 deletions frontend/src/api/mydata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ import logoKakaobank from '@/assets/logos/kakaobank.svg'
import logoTossbank from '@/assets/logos/tossbank.svg'
import logoHyundaiCard from '@/assets/logos/hyundai-card.svg'
import logoSamsungCard from '@/assets/logos/samsung-card.png'
import logoHyundaiCapital from '@/assets/logos/hyundai-capital.svg'
import logoLotteCapital from '@/assets/logos/lotte-capital.svg'
import logoWooriCapital from '@/assets/logos/woori-capital.svg'
import logoKbCapital from '@/assets/logos/kb-capital.svg'
import logoHanaCapital from '@/assets/logos/hana-capital.svg'

export type InstitutionCategory = 'BANK' | 'CARD' | 'SECURITIES'
export type InstitutionCategory = 'BANK' | 'CARD' | 'CAPITAL' | 'SECURITIES'

export interface Institution {
/** 기관 코드(백엔드 `Institution` enum의 code). 연동·해제 요청 때 이 값을 보낸다 */
Expand All @@ -47,14 +52,24 @@ export interface ConnectedAccount {
/** 마스킹된 계좌번호 (예: 110-***-4567) */
accountNumber: string
balance: number
/** DEPOSIT · SAVINGS · LOAN · INVESTMENT · ETC. 대출은 자산이 아니라 빚이라 합산에서 뺀다 */
accountType: AccountType
}

/** 연동된 기관 하나(계좌든 카드든, 중복 제거됨). 완료 화면의 체크리스트가 이걸 그린다 */
export interface ConnectedInstitution {
institutionId: string
institutionName: string
}

export interface ConnectResponse {
/** 연결된 "기관" 수다 — accounts 배열 길이가 아니다. institutions.length와 같다 */
connectedCount: number
accounts: ConnectedAccount[]
institutions: ConnectedInstitution[]
}

export type AccountType = 'DEPOSIT' | 'SAVINGS' | 'LOAN'
export type AccountType = 'DEPOSIT' | 'SAVINGS' | 'LOAN' | 'INVESTMENT' | 'ETC'

/** 연동 계좌 한 건. 기관 이름은 안 내려온다 — orgCode만으로 표시해야 하면 서버에 필드 추가가 필요하다 */
export interface UserAccount {
Expand Down Expand Up @@ -87,10 +102,12 @@ export interface UserCard {
const delay = (ms = 400) => new Promise((resolve) => setTimeout(resolve, ms))

/**
* 은행 7개 + 카드사 5개 — 백엔드 `Institution` enum과 코드를 맞췄다(대출 전용 기관인
* 현대캐피탈·SBI저축은행은 연동 화면에서 고를 대상이 아니라 뺐다).
* 은행 7개 + 카드사 5개 + 캐피탈 5개 — 백엔드 `Institution` enum과 코드를 맞췄다.
*
* 대표 3개(`popular`)는 국내 이용자 수 기준 상위권 은행/카드사로 골랐다.
* **분류마다 우리가 실제로 데모 데이터를 붙여둔 기관(국민은행·국민카드·우리카드·
* 현대캐피탈)을 배열 맨 앞, `popular`로 둔다.** 무엇을 고르든 서버는 이 4곳만
* 연동해주기 때문에, 목록 첫 화면에서부터 실제로 연동될 기관이 먼저 보여야
* "왜 고른 게 아니라 딴 게 연결됐지"라는 혼란이 없다.
*/
const MOCK_INSTITUTIONS: Institution[] = [
{ institutionId: '004', name: '국민은행', category: 'BANK', logoUrl: logoKookmin, popular: true },
Expand All @@ -101,11 +118,17 @@ const MOCK_INSTITUTIONS: Institution[] = [
{ institutionId: '011', name: '농협은행', category: 'BANK', logoUrl: logoNonghyup },
{ institutionId: '092', name: '토스뱅크', category: 'BANK', logoUrl: logoTossbank },

{ institutionId: '0301', name: '국민카드', category: 'CARD', logoUrl: logoKookmin, popular: true },
{ institutionId: '0313', name: '우리카드', category: 'CARD', logoUrl: logoWoori, popular: true },
{ institutionId: '0306', name: '신한카드', category: 'CARD', logoUrl: logoShinhan, popular: true },
{ institutionId: '0301', name: 'KB국민카드', category: 'CARD', logoUrl: logoKookmin, popular: true },
{ institutionId: '0303', name: '삼성카드', category: 'CARD', logoUrl: logoSamsungCard, popular: true },
{ institutionId: '0303', name: '삼성카드', category: 'CARD', logoUrl: logoSamsungCard },
{ institutionId: '0302', name: '현대카드', category: 'CARD', logoUrl: logoHyundaiCard },
{ institutionId: '0313', name: '우리카드', category: 'CARD', logoUrl: logoWoori },

{ institutionId: '0602', name: '현대캐피탈', category: 'CAPITAL', logoUrl: logoHyundaiCapital, popular: true },
{ institutionId: '0603', name: '롯데캐피탈', category: 'CAPITAL', logoUrl: logoLotteCapital, popular: true },
{ institutionId: '0604', name: 'KB캐피탈', category: 'CAPITAL', logoUrl: logoKbCapital, popular: true },
{ institutionId: '0605', name: '우리금융캐피탈', category: 'CAPITAL', logoUrl: logoWooriCapital },
{ institutionId: '0606', name: '하나캐피탈', category: 'CAPITAL', logoUrl: logoHanaCapital },
]

export const mydataApi = {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/assets/logos/hana-capital.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/assets/logos/hyundai-capital.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/assets/logos/kb-capital.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/assets/logos/lotte-capital.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/assets/logos/woori-capital.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions frontend/src/views/onboarding/AssetsSavedView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ async function connectMydata() {
const result = await mydataApi.connect({
institutionIds: bankInstitutions.map((i) => i.institutionId),
})
// 대출 계좌는 뺀다 — 대출 잔액은 갚아야 할 빚이지 "모아둔 자산"이 아니다.
// 기관 이름은 서버가 내려준 값을 그대로 쓴다. 프론트에서 코드를 이름으로 바꾸지 않는다.
accounts.value = result.accounts
onboarding.totalSaved = result.accounts.reduce((sum, account) => sum + account.balance, 0)
const savedAccounts = result.accounts.filter((account) => account.accountType !== 'LOAN')
accounts.value = savedAccounts
onboarding.totalSaved = savedAccounts.reduce((sum, account) => sum + account.balance, 0)
connected.value = true
} catch (e) {
accounts.value = []
Expand Down
17 changes: 4 additions & 13 deletions frontend/src/views/signup/MydataDoneView.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { mydataApi, type Institution } from '@/api/mydata'
import { useSignupStore } from '@/stores/signup'
import PrimaryButton from '@/components/common/PrimaryButton.vue'

const route = useRoute()
const router = useRouter()
const signup = useSignupStore()

const institutions = ref<Institution[]>([])

onMounted(async () => {
institutions.value = await mydataApi.findInstitutions()
})

// 서버가 기관 이름을 내려주므로 코드→이름 매핑이 필요 없다.
// 같은 은행 계좌가 여럿이면 이름이 중복되므로 한 번씩만 보여준다.
const connectedNames = computed(() =>
[...new Set(signup.connectResult?.accounts.map((a) => a.institutionName) ?? [])],
)
// 서버가 계좌·카드를 통틀어 중복 제거한 기관 목록을 내려준다 — 몇 개를 골랐든
// 실제로 연동된 기관 수(4개)와 이름이 여기 그대로 나온다.
const connectedNames = computed(() => signup.connectResult?.institutions.map((i) => i.institutionName) ?? [])

const connectedCount = computed(() => signup.connectResult?.connectedCount ?? 0)

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/views/signup/MydataSelectView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,22 @@ onMounted(async () => {

const banks = computed(() => institutions.value.filter((i) => i.category === 'BANK'))
const cards = computed(() => institutions.value.filter((i) => i.category === 'CARD'))
const capitals = computed(() => institutions.value.filter((i) => i.category === 'CAPITAL'))

/** 처음엔 대표 기관 3개만 보여주고, "자세히 보기"를 눌러야 전체가 펼쳐진다. */
const showAllBanks = ref(false)
const showAllCards = ref(false)
const showAllCapitals = ref(false)

const visibleBanks = computed(() =>
showAllBanks.value ? banks.value : banks.value.filter((i) => i.popular),
)
const visibleCards = computed(() =>
showAllCards.value ? cards.value : cards.value.filter((i) => i.popular),
)
const visibleCapitals = computed(() =>
showAllCapitals.value ? capitals.value : capitals.value.filter((i) => i.popular),
)

const allSelected = computed({
get: () =>
Expand Down Expand Up @@ -131,6 +136,25 @@ function next() {
카드 전체보기 ({{ cards.length }})
</button>
</section>

<section v-if="capitals.length" class="group">
<h2 class="group-title">캐피탈</h2>
<InstitutionItem
v-for="capital in visibleCapitals"
:key="capital.institutionId"
:institution="capital"
:model-value="isSelected(capital.institutionId)"
@update:model-value="signup.toggleInstitution(capital.institutionId)"
/>
<button
v-if="!showAllCapitals && capitals.length > visibleCapitals.length"
type="button"
class="more-btn"
@click="showAllCapitals = true"
>
캐피탈 전체보기 ({{ capitals.length }})
</button>
</section>
</template>
</div>

Expand Down