- POST /api/account/restore: 기존 데이터 전부 삭제 후 이름 기준 재생성 (계좌→태그→분류(대/소)→내역→고정→예산→자주내역), 한 트랜잭션(실패 시 롤백) - 빈 데이터 안전장치, 투자 종목/매매는 복구 대상 외(삭제됨) - BackupMapper(deleteByMember), RestoreRequest DTO, BackupService/Controller Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Void> restore(
|
||||||
|
@RequestBody RestoreRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
backupService.restore(current.getId(), req);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WalletRequest> wallets; // 계좌 (이름 참조 없음 → 요청 DTO 재사용)
|
||||||
|
private List<Cat> categories; // 분류 (parent = 대분류명)
|
||||||
|
private List<String> tags; // 태그명
|
||||||
|
private List<Entry> entries; // 내역
|
||||||
|
private List<Recur> recurrings; // 고정지출
|
||||||
|
private List<BudgetRequest> budgets; // 예산 (분류명 참조)
|
||||||
|
private List<Quick> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<String, Long> 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<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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.account.mapper.BackupMapper">
|
||||||
|
|
||||||
|
<delete id="deleteEntryTags">
|
||||||
|
DELETE FROM account_entry_tag
|
||||||
|
WHERE entry_id IN (SELECT id FROM account_entry WHERE member_id = #{memberId})
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteEntries">
|
||||||
|
DELETE FROM account_entry WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteRecurrings">
|
||||||
|
DELETE FROM recurring WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteBudgets">
|
||||||
|
DELETE FROM budget WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteBudgetIncome">
|
||||||
|
DELETE FROM budget_income WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteQuickEntries">
|
||||||
|
DELETE FROM quick_entry WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteInvestTrades">
|
||||||
|
DELETE FROM invest_trade WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteInvestHoldings">
|
||||||
|
DELETE FROM invest_holding WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteCategories">
|
||||||
|
DELETE FROM account_category WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteTags">
|
||||||
|
DELETE FROM account_tag WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteWallets">
|
||||||
|
DELETE FROM wallet WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
Reference in New Issue
Block a user