Files
sb-backend/src/main/java/com/sb/web/account/service/BackupService.java
T
ByungCheolandClaude Opus 4.8 24e235404b feat: 월별 예산 + 전월/다음달 복사
- budget 테이블에 year/month 추가, (member,category)→(member,category,year,month) 유니크 교체
  기존 상시 예산은 현재 월로 1회 이관
- 예산 조회/생성/현황/기간차트를 월별로(findByMember에 year/month)
- copyMonth: 다른 월 예산을 대상 월로 복사(같은 분류 덮어씀) + POST /budgets/copy
- 백업 복구 시 예산은 현재 월로 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:35:32 +09:00

206 lines
9.1 KiB
Java

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<Long, Long> 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<String, Long> 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<String, Long> 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();
}
}