diff --git a/src/main/java/com/sb/web/account/controller/BackupController.java b/src/main/java/com/sb/web/account/controller/BackupController.java new file mode 100644 index 0000000..dee7bf1 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/BackupController.java @@ -0,0 +1,33 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.dto.RestoreRequest; +import com.sb.web.account.service.BackupService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RestController; + +/** + * 데이터 가져오기(복구) API. (로그인 필요 — /api/account/** 보호) + * POST /api/account/restore 엑셀 백업으로 전체 데이터 복구(기존 데이터 덮어씀) + */ +@RestController +@RequestMapping("/api/account") +@RequiredArgsConstructor +public class BackupController { + + private final BackupService backupService; + + @PostMapping("/restore") + public ResponseEntity restore( + @RequestBody RestoreRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + backupService.restore(current.getId(), req); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/RestoreRequest.java b/src/main/java/com/sb/web/account/dto/RestoreRequest.java new file mode 100644 index 0000000..8ab5225 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/RestoreRequest.java @@ -0,0 +1,70 @@ +package com.sb.web.account.dto; + +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * 데이터 복구(가져오기) 요청 — 엑셀 백업을 파싱한 결과. + * 참조는 이름 기준(계좌명·분류 대분류명·태그명). 서버에서 새 ID 로 재매핑한다. + */ +@Data +public class RestoreRequest { + + private List wallets; // 계좌 (이름 참조 없음 → 요청 DTO 재사용) + private List categories; // 분류 (parent = 대분류명) + private List tags; // 태그명 + private List entries; // 내역 + private List recurrings; // 고정지출 + private List budgets; // 예산 (분류명 참조) + private List quickEntries; // 자주 쓰는 내역 + + @Data + public static class Cat { + private String type; + private String name; + private String parent; // 대분류명 (없으면 대분류) + } + + @Data + public static class Entry { + private LocalDate entryDate; + private String type; + private String category; + private Long amount; + private String memo; + private String wallet; // 계좌명 + private String toWallet; // 입금 계좌명(이체) + private Integer installmentMonths; + private List tags; // 태그명 + } + + @Data + public static class Recur { + private String title; + private String type; + private Long amount; + private String category; + private String memo; + private String wallet; + private String toWallet; + private String frequency; + private Integer dayOfMonth; + private Integer dayOfWeek; + private Integer monthOfYear; + private LocalDate startDate; + private LocalDate endDate; + private Boolean active; + } + + @Data + public static class Quick { + private String label; + private String type; + private String category; + private Long amount; + private String memo; + private String wallet; + } +} diff --git a/src/main/java/com/sb/web/account/mapper/BackupMapper.java b/src/main/java/com/sb/web/account/mapper/BackupMapper.java new file mode 100644 index 0000000..22f3c6e --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/BackupMapper.java @@ -0,0 +1,23 @@ +package com.sb.web.account.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 데이터 복구 시 회원의 기존 가계부 데이터를 전부 삭제(트랜잭션 내에서 호출). + */ +@Mapper +public interface BackupMapper { + + int deleteEntryTags(@Param("memberId") Long memberId); + int deleteEntries(@Param("memberId") Long memberId); + int deleteRecurrings(@Param("memberId") Long memberId); + int deleteBudgets(@Param("memberId") Long memberId); + int deleteBudgetIncome(@Param("memberId") Long memberId); + int deleteQuickEntries(@Param("memberId") Long memberId); + int deleteInvestTrades(@Param("memberId") Long memberId); + int deleteInvestHoldings(@Param("memberId") Long memberId); + int deleteCategories(@Param("memberId") Long memberId); + int deleteTags(@Param("memberId") Long memberId); + int deleteWallets(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/account/service/BackupService.java b/src/main/java/com/sb/web/account/service/BackupService.java new file mode 100644 index 0000000..b6b5e0e --- /dev/null +++ b/src/main/java/com/sb/web/account/service/BackupService.java @@ -0,0 +1,183 @@ +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 + Map walletMap = new HashMap<>(); + if (req.getWallets() != null) { + for (WalletRequest w : req.getWallets()) { + if (w == null || w.getName() == null) continue; + walletMap.put(w.getName(), accountService.createWallet(w, memberId).getId()); + } + } + + // 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; + 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(walletMap.get(e.getWallet())); + er.setToWalletId(walletMap.get(e.getToWallet())); + 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; + 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(walletMap.get(r.getWallet())); + rr.setToWalletId(walletMap.get(r.getToWallet())); + 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) { + for (BudgetRequest b : req.getBudgets()) { + if (b == null || b.getCategory() == null) continue; + budgetService.create(b, memberId); + } + } + + // 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.getWallet())); + 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 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(); + } +} diff --git a/src/main/resources/mapper/BackupMapper.xml b/src/main/resources/mapper/BackupMapper.xml new file mode 100644 index 0000000..aac7b43 --- /dev/null +++ b/src/main/resources/mapper/BackupMapper.xml @@ -0,0 +1,41 @@ + + + + + + DELETE FROM account_entry_tag + WHERE entry_id IN (SELECT id FROM account_entry WHERE member_id = #{memberId}) + + + DELETE FROM account_entry WHERE member_id = #{memberId} + + + DELETE FROM recurring WHERE member_id = #{memberId} + + + DELETE FROM budget WHERE member_id = #{memberId} + + + DELETE FROM budget_income WHERE member_id = #{memberId} + + + DELETE FROM quick_entry WHERE member_id = #{memberId} + + + DELETE FROM invest_trade WHERE member_id = #{memberId} + + + DELETE FROM invest_holding WHERE member_id = #{memberId} + + + DELETE FROM account_category WHERE member_id = #{memberId} + + + DELETE FROM account_tag WHERE member_id = #{memberId} + + + DELETE FROM wallet WHERE member_id = #{memberId} + + +