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:
co-authored by
Claude Opus 4.8
parent
9243a41385
commit
2a2f81dc32
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user