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,235 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.InvestHolding;
import com.sb.web.account.domain.InvestTrade;
import com.sb.web.account.domain.Wallet;
import com.sb.web.account.dto.HoldingRequest;
import com.sb.web.account.dto.HoldingResponse;
import com.sb.web.account.dto.TradeRequest;
import com.sb.web.account.dto.TradeResponse;
import com.sb.web.account.mapper.InvestMapper;
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.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 투자 포트폴리오(C). 보유종목·매매이력 관리와 평단/실현·평가손익 산출.
* 모든 데이터는 memberId(소유자)로 격리한다.
*/
@Service
@RequiredArgsConstructor
public class InvestService {
private final InvestMapper investMapper;
private final WalletMapper walletMapper;
/* ===================== 보유 종목 ===================== */
public List<HoldingResponse> listHoldings(Long memberId, Long walletId) {
requireInvestWallet(walletId, memberId);
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
return investMapper.findHoldingsByWallet(memberId, walletId).stream()
.map(h -> toResponse(h, tradesByHolding.getOrDefault(h.getId(), List.of())))
.toList();
}
@Transactional
public HoldingResponse createHolding(HoldingRequest req, Long memberId) {
requireInvestWallet(req.getWalletId(), memberId);
InvestHolding h = InvestHolding.builder()
.memberId(memberId)
.walletId(req.getWalletId())
.name(req.getName().trim())
.ticker(blankToNull(req.getTicker()))
.currentPrice(req.getCurrentPrice())
.build();
investMapper.insertHolding(h);
return toResponse(h, List.of());
}
@Transactional
public HoldingResponse updateHolding(Long id, HoldingRequest req, Long memberId) {
InvestHolding h = mustHolding(id, memberId);
h.setName(req.getName().trim());
h.setTicker(blankToNull(req.getTicker()));
h.setCurrentPrice(req.getCurrentPrice());
investMapper.updateHolding(h);
return toResponse(h, investMapper.findTradesByHolding(id, memberId));
}
@Transactional
public void deleteHolding(Long id, Long memberId) {
mustHolding(id, memberId);
investMapper.deleteTradesByHolding(id, memberId);
investMapper.deleteHolding(id, memberId);
}
/* ===================== 매매 ===================== */
public List<TradeResponse> listTrades(Long holdingId, Long memberId) {
mustHolding(holdingId, memberId);
return investMapper.findTradesByHolding(holdingId, memberId).stream()
.map(TradeResponse::from)
.toList();
}
@Transactional
public TradeResponse addTrade(Long holdingId, TradeRequest req, Long memberId) {
mustHolding(holdingId, memberId);
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
if ("SELL".equals(type)) {
Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId));
if (req.getQuantity() > c.quantity()) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"매도 수량이 보유수량(" + c.quantity() + ")을 초과합니다.");
}
}
InvestTrade t = InvestTrade.builder()
.memberId(memberId)
.holdingId(holdingId)
.tradeType(type)
.tradeDate(req.getTradeDate())
.quantity(req.getQuantity())
.price(req.getPrice())
.fee(req.getFee() != null ? req.getFee() : 0L)
.build();
investMapper.insertTrade(t);
return TradeResponse.from(t);
}
@Transactional
public void deleteTrade(Long id, Long memberId) {
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
}
investMapper.deleteTrade(id, memberId);
}
/* ===================== 지갑 단위 평가 (AccountService 연동) ===================== */
/** 투자(INVEST) 지갑별 {예수금 증감(cashDelta), 주식 평가금액(stockEval)} */
public Map<Long, WalletInvest> valuationByWallet(Long memberId) {
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
Map<Long, WalletInvest> result = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
List<InvestTrade> trades = tradesByHolding.getOrDefault(h.getId(), List.of());
Calc c = calc(trades);
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice();
WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest());
wi.cashDelta += cashDelta(trades);
wi.stockEval += c.quantity() * evalPrice;
}
return result;
}
/** 투자 지갑별 가치 holder */
public static class WalletInvest {
public long cashDelta; // 매매로 인한 예수금 증감 (Σ매도대금 − Σ매수금액)
public long stockEval; // 주식 평가금액 (Σ수량×평가단가)
}
/* ===================== 내부 ===================== */
private Map<Long, List<InvestTrade>> tradesByHolding(Long memberId) {
Map<Long, List<InvestTrade>> map = new HashMap<>();
for (InvestTrade t : investMapper.findTradesByMember(memberId)) {
map.computeIfAbsent(t.getHoldingId(), k -> new java.util.ArrayList<>()).add(t);
}
return map;
}
/** 매매이력(시간순)으로 보유수량·원가·실현손익 산출 */
private Calc calc(List<InvestTrade> trades) {
long qty = 0, costBasis = 0, realized = 0;
for (InvestTrade t : trades) {
long amount = t.getQuantity() * t.getPrice();
long fee = t.getFee() != null ? t.getFee() : 0L;
if ("BUY".equals(t.getTradeType())) {
qty += t.getQuantity();
costBasis += amount + fee;
} else { // SELL
long costRemoved = (qty > 0 && t.getQuantity() < qty)
? Math.round(costBasis * (double) t.getQuantity() / qty)
: costBasis; // 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지)
long proceeds = amount - fee;
realized += proceeds - costRemoved;
costBasis -= costRemoved;
qty -= t.getQuantity();
if (qty <= 0) {
qty = 0;
costBasis = 0;
}
}
}
return new Calc(qty, costBasis, realized);
}
private record Calc(long quantity, long costBasis, long realized) {
long avgPrice() {
return quantity > 0 ? Math.round(costBasis / (double) quantity) : 0;
}
}
/** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */
private long cashDelta(List<InvestTrade> trades) {
long cashDelta = 0;
for (InvestTrade t : trades) {
long amount = t.getQuantity() * t.getPrice();
long fee = t.getFee() != null ? t.getFee() : 0L;
cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee);
}
return cashDelta;
}
private HoldingResponse toResponse(InvestHolding h, List<InvestTrade> trades) {
Calc c = calc(trades);
long avgPrice = c.avgPrice();
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice;
long evalValue = c.quantity() * evalPrice;
long evalGain = evalValue - c.costBasis();
Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null;
return HoldingResponse.builder()
.id(h.getId())
.walletId(h.getWalletId())
.name(h.getName())
.ticker(h.getTicker())
.currentPrice(h.getCurrentPrice())
.quantity(c.quantity())
.avgPrice(avgPrice)
.costBasis(c.costBasis())
.evalValue(evalValue)
.evalGain(evalGain)
.returnPct(returnPct)
.realizedPL(c.realized)
.build();
}
private void requireInvestWallet(Long walletId, Long memberId) {
Wallet w = walletMapper.findByIdAndMember(walletId, memberId);
if (w == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자계좌가 올바르지 않습니다.");
}
if (!"INVEST".equals(w.getType())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자(INVEST) 계좌만 종목을 보유할 수 있습니다.");
}
}
private InvestHolding mustHolding(Long id, Long memberId) {
InvestHolding h = investMapper.findHoldingByIdAndMember(id, memberId);
if (h == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다.");
}
return h;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}