package com.sb.web.account.service; import com.sb.web.account.dto.AccountEntryRequest; import com.sb.web.account.dto.AccountTagRequest; import com.sb.web.account.dto.BudgetRequest; import com.sb.web.account.dto.CategoryRequest; import com.sb.web.account.dto.QuickEntryRequest; import com.sb.web.account.dto.RecurringRequest; import com.sb.web.account.dto.RestoreRequest; import com.sb.web.account.dto.WalletRequest; import com.sb.web.account.mapper.BackupMapper; import com.sb.web.common.exception.ApiException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * 데이터 가져오기(복구) — 기존 데이터를 전부 삭제하고 백업(이름 기준)을 새로 재생성한다. * 전체를 한 트랜잭션으로 처리해 중간 실패 시 롤백(기존 데이터 보존)된다. * ※ 투자 종목/매매내역은 백업에 포함되지 않아 복구 시 삭제된다(평가액 계좌 자체는 복원). */ @Slf4j @Service @RequiredArgsConstructor public class BackupService { private final BackupMapper backupMapper; private final AccountService accountService; private final CategoryService categoryService; private final RecurringService recurringService; private final BudgetService budgetService; private final QuickEntryService quickEntryService; @Transactional public void restore(Long memberId, RestoreRequest req) { if (isEmpty(req)) { throw new ApiException(HttpStatus.BAD_REQUEST, "복구할 데이터가 비어 있습니다. 백업 파일을 확인해주세요."); } // 1) 기존 데이터 전부 삭제 (FK 안전 순서) backupMapper.deleteEntryTags(memberId); backupMapper.deleteEntries(memberId); backupMapper.deleteRecurrings(memberId); backupMapper.deleteBudgets(memberId); backupMapper.deleteBudgetIncome(memberId); backupMapper.deleteQuickEntries(memberId); backupMapper.deleteInvestTrades(memberId); backupMapper.deleteInvestHoldings(memberId); backupMapper.deleteCategories(memberId); backupMapper.deleteTags(memberId); backupMapper.deleteWallets(memberId); // 2) 계좌 → 원본 id(oldId) → 새 id (같은 이름 계좌가 있어도 정확) Map walletMap = new HashMap<>(); if (req.getWallets() != null) { for (RestoreRequest.Wal w : req.getWallets()) { if (w == null || w.getName() == null) continue; WalletRequest wr = new WalletRequest(); wr.setType(w.getType()); wr.setName(w.getName()); wr.setIssuer(w.getIssuer()); wr.setAccountNumber(w.getAccountNumber()); wr.setCardType(w.getCardType()); wr.setOpeningBalance(w.getOpeningBalance()); wr.setOpeningDate(w.getOpeningDate()); wr.setCurrentValue(w.getCurrentValue()); Long newId = accountService.createWallet(wr, memberId).getId(); if (w.getOldId() != null) walletMap.put(w.getOldId(), newId); } } // 3) 태그 → 이름→새 ID Map tagMap = new HashMap<>(); if (req.getTags() != null) { for (String name : req.getTags()) { if (name == null || name.isBlank()) continue; AccountTagRequest tr = new AccountTagRequest(); tr.setName(name.trim()); tagMap.put(name.trim(), accountService.createTag(tr, memberId).getId()); } } // 4) 분류 — 대분류 먼저, 소분류는 부모 이름으로 매핑 ("TYPE|name" → id) Map catMap = new HashMap<>(); if (req.getCategories() != null) { for (RestoreRequest.Cat c : req.getCategories()) { if (c.getName() == null || (c.getParent() != null && !c.getParent().isBlank())) continue; CategoryRequest cr = new CategoryRequest(); cr.setType(c.getType()); cr.setName(c.getName()); catMap.put(key(c.getType(), c.getName()), categoryService.create(cr, memberId).getId()); } for (RestoreRequest.Cat c : req.getCategories()) { if (c.getName() == null || c.getParent() == null || c.getParent().isBlank()) continue; CategoryRequest cr = new CategoryRequest(); cr.setType(c.getType()); cr.setName(c.getName()); cr.setParentId(catMap.get(key(c.getType(), c.getParent()))); categoryService.create(cr, memberId); } } // 5) 내역 if (req.getEntries() != null) { for (RestoreRequest.Entry e : req.getEntries()) { if (e.getEntryDate() == null || e.getType() == null) continue; Long wid = walletMap.get(e.getWalletId()); Long twid = walletMap.get(e.getToWalletId()); if (badTransfer(e.getType(), wid, twid)) continue; // 이체 계좌가 없거나 같으면 건너뜀 AccountEntryRequest er = new AccountEntryRequest(); er.setEntryDate(e.getEntryDate()); er.setType(e.getType()); er.setCategory(e.getCategory()); er.setAmount(e.getAmount()); er.setMemo(e.getMemo()); er.setWalletId(wid); er.setToWalletId(twid); er.setInstallmentMonths(e.getInstallmentMonths()); if (e.getTags() != null) { er.setTagIds(e.getTags().stream().map(tagMap::get).filter(Objects::nonNull).toList()); } accountService.create(er, memberId); } } // 6) 고정지출 if (req.getRecurrings() != null) { for (RestoreRequest.Recur r : req.getRecurrings()) { if (r.getTitle() == null) continue; Long wid = walletMap.get(r.getWalletId()); Long twid = walletMap.get(r.getToWalletId()); if (badTransfer(r.getType(), wid, twid)) continue; RecurringRequest rr = new RecurringRequest(); rr.setTitle(r.getTitle()); rr.setType(r.getType()); rr.setAmount(r.getAmount()); rr.setCategory(r.getCategory()); rr.setMemo(r.getMemo()); rr.setWalletId(wid); rr.setToWalletId(twid); rr.setFrequency(r.getFrequency()); rr.setDayOfMonth(r.getDayOfMonth()); rr.setDayOfWeek(r.getDayOfWeek()); rr.setMonthOfYear(r.getMonthOfYear()); rr.setStartDate(r.getStartDate()); rr.setEndDate(r.getEndDate()); rr.setActive(r.getActive() == null || r.getActive()); recurringService.create(rr, memberId); } } // 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음) if (req.getBudgets() != null) { java.time.LocalDate today = java.time.LocalDate.now(); for (BudgetRequest b : req.getBudgets()) { if (b == null || b.getCategory() == null) continue; budgetService.create(b, memberId, today.getYear(), today.getMonthValue()); } } // 8) 자주 쓰는 내역 if (req.getQuickEntries() != null) { for (RestoreRequest.Quick q : req.getQuickEntries()) { if (q.getType() == null) continue; QuickEntryRequest qr = new QuickEntryRequest(); qr.setLabel(q.getLabel()); qr.setType(q.getType()); qr.setCategory(q.getCategory()); qr.setAmount(q.getAmount()); qr.setMemo(q.getMemo()); qr.setWalletId(walletMap.get(q.getWalletId())); quickEntryService.create(qr, memberId); } } log.info("[restore] member={} wallets={} entries={} restored", memberId, walletMap.size(), req.getEntries() == null ? 0 : req.getEntries().size()); } private static String key(String type, String name) { return type + "|" + name; } /** 이체인데 출금/입금 계좌가 없거나 같으면 복구에서 제외(검증 오류로 전체 롤백 방지) */ private static boolean badTransfer(String type, Long walletId, Long toWalletId) { return "TRANSFER".equals(type) && (walletId == null || toWalletId == null || walletId.equals(toWalletId)); } private boolean isEmpty(RestoreRequest r) { return empty(r.getWallets()) && empty(r.getCategories()) && empty(r.getTags()) && empty(r.getEntries()) && empty(r.getRecurrings()) && empty(r.getBudgets()) && empty(r.getQuickEntries()); } private boolean empty(List l) { return l == null || l.isEmpty(); } }