feat: 가계부·게시판 백엔드 API 구현

- com.sb.web 패키지로 재구성: account / auth / board / user / admin / common
- 가계부(account): 내역(필터), 계좌·순자산, 예산, 분류, 정기 거래, 투자 포트폴리오
  (종목·매매 이력, 이동평균 평단·실현/평가손익)
- MyBatis + MariaDB + Redis 세션, BCrypt
- 스키마 자동 초기화(db/*.sql, 멱등), 사용자별 데이터 격리(member_id)
- 문서: docs/BACKEND.md, .env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-05-31 15:43:09 +09:00
co-authored by Claude Opus 4.8
parent 9243a41385
commit 2a2f81dc32
121 changed files with 5899 additions and 31 deletions
@@ -0,0 +1,465 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.AccountEntry;
import com.sb.web.account.domain.AccountTag;
import com.sb.web.account.domain.Wallet;
import com.sb.web.account.dto.AccountEntryRequest;
import com.sb.web.account.dto.AccountEntryResponse;
import com.sb.web.account.dto.AccountSummary;
import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
import com.sb.web.account.dto.NetWorthPoint;
import com.sb.web.account.dto.NetWorthResponse;
import com.sb.web.account.dto.PeriodStat;
import com.sb.web.account.dto.RepaymentRequest;
import com.sb.web.account.dto.WalletRequest;
import com.sb.web.account.dto.WalletResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.AccountTagMapper;
import com.sb.web.account.mapper.WalletMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 가계부 서비스. 항목·태그 모두 memberId(소유자)로 격리되어 본인 데이터만 접근한다.
* 가계부 태그는 사용자별(account_tag)로 관리한다.
*/
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountEntryMapper mapper;
private final AccountTagMapper tagMapper;
private final WalletMapper walletMapper;
private final InvestService investService;
/* ===================== 항목 ===================== */
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
String type, String category, Long walletId, String keyword, Long tagId) {
return mapper.findByMember(memberId, year, month,
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId).stream()
.map(this::toResponse)
.toList();
}
/** 분류(카테고리)별 합계 — 파이차트용. type=EXPENSE(기본)/INCOME */
public List<CategoryStat> categoryStats(Long memberId, String type, Integer year, Integer month) {
String t = "INCOME".equalsIgnoreCase(type) ? "INCOME" : "EXPENSE";
return mapper.sumByCategory(memberId, t, year, month).stream()
.map(r -> CategoryStat.builder()
.category((String) r.get("category"))
.total(((Number) r.get("total")).longValue())
.build())
.toList();
}
/** 월별 순자산 추이 (최근 months개월, 기본 12). 순자산 = 개시잔액 + 누적(수입−지출, 계좌 지정분) */
public List<NetWorthPoint> netWorthTrend(Long memberId, Integer months) {
int n = (months == null || months <= 0 || months > 60) ? 12 : months;
YearMonth current = YearMonth.from(java.time.LocalDate.now());
YearMonth start = current.minusMonths(n - 1L);
Map<String, Long> flow = new HashMap<>();
for (Map<String, Object> row : mapper.monthlyWalletFlow(memberId)) {
Object net = row.get("net");
flow.put((String) row.get("ym"), net == null ? 0L : ((Number) net).longValue());
}
// 개시잔액: 개시월이 범위 시작 이전(또는 미상)이면 기준선, 범위 내면 해당 월 버킷, 미래면 무시
List<Wallet> ws = walletMapper.findByMember(memberId);
Map<String, Long> openingByMonth = new HashMap<>();
long baseline = 0;
for (Wallet w : ws) {
long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance();
if (ob == 0) {
continue;
}
YearMonth om = w.getOpeningDate() == null ? null : YearMonth.from(w.getOpeningDate());
if (om == null || om.isBefore(start)) {
baseline += ob;
} else if (!om.isAfter(current)) {
openingByMonth.merge(om.toString(), ob, Long::sum);
}
}
// 범위 시작 이전의 현금흐름은 기준선에 누적
String startKey = start.toString();
for (Map.Entry<String, Long> e : flow.entrySet()) {
if (e.getKey().compareTo(startKey) < 0) {
baseline += e.getValue();
}
}
List<NetWorthPoint> result = new ArrayList<>();
long running = baseline;
for (YearMonth m = start; !m.isAfter(current); m = m.plusMonths(1)) {
String key = m.toString();
running += flow.getOrDefault(key, 0L);
running += openingByMonth.getOrDefault(key, 0L);
result.add(NetWorthPoint.builder().month(key).netWorth(running).build());
}
// 투자 손익(실현+평가)은 현재 시점에만 반영 (과거 평가 이력이 없으므로 마지막 점에 가산)
// 매매에 따른 예수금 증감 + 주식 평가금액 = 현금기준 대비 순자산 증감분
if (!result.isEmpty()) {
long investAdjust = 0;
for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) {
investAdjust += wi.cashDelta + wi.stockEval;
}
if (investAdjust != 0) {
NetWorthPoint last = result.get(result.size() - 1);
last.setNetWorth(last.getNetWorth() + investAdjust);
}
}
return result;
}
/** 기간 버킷(일/주/월/년)별 수입·지출 통계 */
public List<PeriodStat> stats(Long memberId, String unit, Integer year, Integer month) {
String u = switch (unit == null ? "" : unit.toUpperCase()) {
case "DAY", "WEEK", "YEAR" -> unit.toUpperCase();
default -> "MONTH";
};
return mapper.statsByPeriod(memberId, u, year, month).stream()
.map(row -> PeriodStat.builder()
.bucket(String.valueOf(((Number) row.get("bucket")).longValue()))
.income(((Number) row.get("income")).longValue())
.expense(((Number) row.get("expense")).longValue())
.build())
.toList();
}
public AccountSummary summary(Long memberId, Integer year, Integer month) {
long income = 0;
long expense = 0;
for (Map<String, Object> row : mapper.sumByType(memberId, year, month)) {
String type = (String) row.get("type");
long total = ((Number) row.get("total")).longValue();
if ("INCOME".equals(type)) {
income = total;
} else if ("EXPENSE".equals(type)) {
expense = total;
}
}
return AccountSummary.of(income, expense);
}
@Transactional
public AccountEntryResponse create(AccountEntryRequest req, Long memberId) {
validateEntryWallets(req, memberId);
boolean transfer = "TRANSFER".equals(req.getType());
AccountEntry entry = AccountEntry.builder()
.memberId(memberId)
.entryDate(req.getEntryDate())
.type(req.getType())
.category(transfer ? null : req.getCategory())
.amount(req.getAmount())
.memo(req.getMemo())
.walletId(req.getWalletId())
.toWalletId(transfer ? req.getToWalletId() : null)
.build();
mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId);
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
}
@Transactional
public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) {
AccountEntry entry = mustFind(id, memberId);
validateEntryWallets(req, memberId);
boolean transfer = "TRANSFER".equals(req.getType());
entry.setEntryDate(req.getEntryDate());
entry.setType(req.getType());
entry.setCategory(transfer ? null : req.getCategory());
entry.setAmount(req.getAmount());
entry.setMemo(req.getMemo());
entry.setWalletId(req.getWalletId());
entry.setToWalletId(transfer ? req.getToWalletId() : null);
mapper.update(entry);
mapper.deleteEntryTagsByEntryId(id);
applyTags(id, req.getTagIds(), memberId);
return toResponse(mapper.findByIdAndMember(id, memberId));
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
mapper.deleteEntryTagsByEntryId(id);
mapper.delete(id, memberId);
}
/* ===================== 상환/납부 ===================== */
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
@Transactional
public void repay(RepaymentRequest req, Long memberId) {
long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L;
long interest = req.getInterest() != null ? req.getInterest() : 0L;
if (principal <= 0 && interest <= 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "원금 또는 이자를 입력하세요.");
}
requireOwnedWallet(req.getFromWalletId(), memberId);
Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId);
if (target == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "대상 계좌가 올바르지 않습니다.");
}
if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다.");
}
if (req.getFromWalletId().equals(req.getTargetWalletId())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다.");
}
// 원금: 이체 (출금 → 대상, 부채 감소)
if (principal > 0) {
mapper.insert(AccountEntry.builder()
.memberId(memberId)
.entryDate(req.getEntryDate())
.type("TRANSFER")
.amount(principal)
.walletId(req.getFromWalletId())
.toWalletId(req.getTargetWalletId())
.memo(req.getMemo() != null ? req.getMemo() : "원금상환")
.build());
}
// 이자: 지출 (출금 계좌에서, 분류=이자)
if (interest > 0) {
mapper.insert(AccountEntry.builder()
.memberId(memberId)
.entryDate(req.getEntryDate())
.type("EXPENSE")
.category("이자")
.amount(interest)
.walletId(req.getFromWalletId())
.memo(req.getMemo() != null ? req.getMemo() : "이자")
.build());
}
}
/* ===================== 계좌/카드 (사용자별) ===================== */
public List<WalletResponse> listWallets(Long memberId) {
Map<Long, Long> balances = balanceMap(memberId);
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
return walletMapper.findByMember(memberId).stream()
.map(w -> {
long cash = balances.getOrDefault(w.getId(), 0L);
WalletResponse r = WalletResponse.from(w, cash);
if ("INVEST".equals(w.getType())) {
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
long deposit = cash + wi.cashDelta; // 예수금
long total = deposit + wi.stockEval; // 총평가
r.setBalance(total);
r.setInvestedAmount(cash); // 순입금(투입원금)
r.setDeposit(deposit);
r.setStockValue(wi.stockEval);
r.setValuationGain(total - cash); // 실현+평가 손익
}
return r;
})
.toList();
}
/** 순자산 = 총자산(BANK) − 총부채(CARD/LOAN) */
public NetWorthResponse netWorth(Long memberId) {
Map<Long, Long> balances = balanceMap(memberId);
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
long assets = 0;
long liabilities = 0;
for (Wallet w : walletMapper.findByMember(memberId)) {
long bal = balances.getOrDefault(w.getId(), 0L);
if ("INVEST".equals(w.getType())) {
// 투자: 예수금(현금+매매증감) + 주식 평가금액
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
assets += bal + wi.cashDelta + wi.stockEval;
} else if ("BANK".equals(w.getType())) {
assets += bal;
} else {
liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시
}
}
return NetWorthResponse.builder()
.totalAssets(assets)
.totalLiabilities(liabilities)
.netWorth(assets - liabilities)
.build();
}
@Transactional
public WalletResponse createWallet(WalletRequest req, Long memberId) {
Wallet wallet = toWallet(req);
wallet.setMemberId(memberId);
walletMapper.insert(wallet);
return WalletResponse.from(wallet, wallet.getOpeningBalance()); // 신규 → 잔액=초기잔액
}
@Transactional
public WalletResponse updateWallet(Long id, WalletRequest req, Long memberId) {
Wallet wallet = mustFindWallet(id, memberId);
wallet.setType(req.getType());
wallet.setName(req.getName().trim());
wallet.setIssuer(blankToNull(req.getIssuer()));
wallet.setAccountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null);
wallet.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null);
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
wallet.setOpeningDate(req.getOpeningDate());
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
walletMapper.update(wallet);
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
}
private Map<Long, Long> balanceMap(Long memberId) {
Map<Long, Long> map = new HashMap<>();
for (Map<String, Object> row : walletMapper.findBalances(memberId)) {
Long id = ((Number) row.get("id")).longValue();
Object b = row.get("balance");
map.put(id, b == null ? 0L : ((Number) b).longValue());
}
return map;
}
@Transactional
public void deleteWallet(Long id, Long memberId) {
mustFindWallet(id, memberId);
walletMapper.delete(id, memberId);
}
/** 특정 계좌와 연관된 내역 목록 */
public List<AccountEntryResponse> listEntriesByWallet(Long walletId, Long memberId) {
mustFindWallet(walletId, memberId);
return mapper.findByWalletAndMember(memberId, walletId).stream()
.map(this::toResponse)
.toList();
}
private Wallet toWallet(WalletRequest req) {
return Wallet.builder()
.type(req.getType())
.name(req.getName().trim())
.issuer(blankToNull(req.getIssuer()))
.accountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null)
.cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null)
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
.openingDate(req.getOpeningDate())
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
.build();
}
private Wallet mustFindWallet(Long id, Long memberId) {
Wallet wallet = walletMapper.findByIdAndMember(id, memberId);
if (wallet == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없습니다.");
}
return wallet;
}
/** 내역의 계좌 검증. 이체는 출금/입금 계좌 필수·본인·서로 다름, 그 외는 선택 계좌만 검증 */
private void validateEntryWallets(AccountEntryRequest req, Long memberId) {
if ("TRANSFER".equals(req.getType())) {
if (req.getWalletId() == null || req.getToWalletId() == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "이체는 출금/입금 계좌를 모두 선택해야 합니다.");
}
if (req.getWalletId().equals(req.getToWalletId())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/입금 계좌가 같을 수 없습니다.");
}
requireOwnedWallet(req.getWalletId(), memberId);
requireOwnedWallet(req.getToWalletId(), memberId);
} else if (req.getWalletId() != null) {
requireOwnedWallet(req.getWalletId(), memberId);
}
}
private void requireOwnedWallet(Long walletId, Long memberId) {
if (walletMapper.findByIdAndMember(walletId, memberId) == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "선택한 계좌가 올바르지 않습니다.");
}
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
/* ===================== 태그 (사용자별) ===================== */
public List<AccountTagResponse> listTags(Long memberId) {
return tagMapper.findByMember(memberId).stream().map(AccountTagResponse::from).toList();
}
@Transactional
public AccountTagResponse createTag(AccountTagRequest req, Long memberId) {
String name = req.getName().trim();
if (tagMapper.countByName(memberId, name, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
}
AccountTag tag = AccountTag.builder().memberId(memberId).name(name).build();
tagMapper.insert(tag);
return AccountTagResponse.from(tag);
}
@Transactional
public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) {
AccountTag tag = mustFindTag(id, memberId);
String name = req.getName().trim();
if (tagMapper.countByName(memberId, name, id) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
}
tag.setName(name);
tagMapper.update(tag);
return AccountTagResponse.from(tag);
}
@Transactional
public void deleteTag(Long id, Long memberId) {
mustFindTag(id, memberId);
tagMapper.deleteEntryLinksByTagId(id);
tagMapper.delete(id, memberId);
}
/* ===================== 내부 ===================== */
private AccountEntryResponse toResponse(AccountEntry entry) {
return AccountEntryResponse.from(entry, mapper.findTagNamesByEntryId(entry.getId()));
}
/** 선택된 태그 ID 중 해당 사용자 소유로 존재하는 것만 연결 */
private void applyTags(Long entryId, List<Long> tagIds, Long memberId) {
if (tagIds == null || tagIds.isEmpty()) {
return;
}
List<Long> distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList();
if (distinct.isEmpty()) {
return;
}
for (Long tagId : tagMapper.findExistingIds(memberId, distinct)) {
mapper.insertEntryTag(entryId, tagId);
}
}
private AccountEntry mustFind(Long id, Long memberId) {
AccountEntry entry = mapper.findByIdAndMember(id, memberId);
if (entry == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "내역을 찾을 수 없습니다.");
}
return entry;
}
private AccountTag mustFindTag(Long id, Long memberId) {
AccountTag tag = tagMapper.findByIdAndMember(id, memberId);
if (tag == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다.");
}
return tag;
}
}