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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user