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,178 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.AccountEntry;
import com.sb.web.account.domain.Recurring;
import com.sb.web.account.dto.RecurringRequest;
import com.sb.web.account.dto.RecurringResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.RecurringMapper;
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.LocalDate;
import java.time.YearMonth;
import java.util.List;
/**
* 정기/반복 거래 서비스. CRUD + 밀린 회차 자동 생성(중복 없이).
*/
@Service
@RequiredArgsConstructor
public class RecurringService {
private final RecurringMapper recurringMapper;
private final AccountEntryMapper entryMapper;
public List<RecurringResponse> list(Long memberId) {
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
}
@Transactional
public RecurringResponse create(RecurringRequest req, Long memberId) {
Recurring r = toEntity(req);
r.setMemberId(memberId);
recurringMapper.insert(r);
return toResponse(recurringMapper.findByIdAndMember(r.getId(), memberId));
}
@Transactional
public RecurringResponse update(Long id, RecurringRequest req, Long memberId) {
mustFind(id, memberId);
Recurring r = toEntity(req);
r.setId(id);
r.setMemberId(memberId);
recurringMapper.update(r);
return toResponse(recurringMapper.findByIdAndMember(id, memberId));
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
recurringMapper.delete(id, memberId);
}
/** 활성 규칙의 밀린 회차를 오늘까지 생성 (중복 없음). 생성 건수 반환 */
@Transactional
public int run(Long memberId) {
LocalDate today = LocalDate.now();
int count = 0;
for (Recurring r : recurringMapper.findActiveByMember(memberId)) {
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
LocalDate next = nextOccurrence(r, cursor);
LocalDate generatedUpTo = null;
int guard = 0;
while (next != null && !next.isAfter(today)
&& (r.getEndDate() == null || !next.isAfter(r.getEndDate()))
&& guard++ < 1000) {
entryMapper.insert(buildEntry(r, next, memberId));
generatedUpTo = next;
count++;
next = nextOccurrence(r, next);
}
if (generatedUpTo != null) {
recurringMapper.updateLastRun(r.getId(), generatedUpTo);
}
}
return count;
}
/* ===================== 내부 ===================== */
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
boolean transfer = "TRANSFER".equals(r.getType());
return AccountEntry.builder()
.memberId(memberId)
.entryDate(date)
.type(r.getType())
.category(transfer ? null : r.getCategory())
.amount(r.getAmount())
.memo(r.getMemo())
.walletId(r.getWalletId())
.toWalletId(transfer ? r.getToWalletId() : null)
.build();
}
/** from 이후 첫 발생일 (없으면 null) */
private LocalDate nextOccurrence(Recurring r, LocalDate from) {
return switch (r.getFrequency()) {
case "DAILY" -> from.plusDays(1);
case "WEEKLY" -> {
int dow = r.getDayOfWeek() != null ? r.getDayOfWeek() : 1;
LocalDate d = from.plusDays(1);
while (d.getDayOfWeek().getValue() != dow) d = d.plusDays(1);
yield d;
}
case "MONTHLY" -> {
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
YearMonth ym = YearMonth.from(from);
LocalDate c = clamp(ym, dom);
if (!c.isAfter(from)) c = clamp(ym.plusMonths(1), dom);
yield c;
}
case "YEARLY" -> {
int moy = r.getMonthOfYear() != null ? r.getMonthOfYear() : 1;
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
LocalDate c = clamp(YearMonth.of(from.getYear(), moy), dom);
if (!c.isAfter(from)) c = clamp(YearMonth.of(from.getYear() + 1, moy), dom);
yield c;
}
default -> null;
};
}
private LocalDate clamp(YearMonth ym, int dom) {
return ym.atDay(Math.min(dom, ym.lengthOfMonth()));
}
private Recurring toEntity(RecurringRequest req) {
return Recurring.builder()
.title(req.getTitle().trim())
.type(req.getType())
.amount(req.getAmount())
.category("TRANSFER".equals(req.getType()) ? null : blankToNull(req.getCategory()))
.memo(blankToNull(req.getMemo()))
.walletId(req.getWalletId())
.toWalletId("TRANSFER".equals(req.getType()) ? req.getToWalletId() : null)
.frequency(req.getFrequency())
.dayOfMonth(req.getDayOfMonth())
.dayOfWeek(req.getDayOfWeek())
.monthOfYear(req.getMonthOfYear())
.startDate(req.getStartDate())
.endDate(req.getEndDate())
.active(req.isActive())
.build();
}
private RecurringResponse toResponse(Recurring r) {
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
LocalDate next = nextOccurrence(r, cursor);
if (next != null && r.getEndDate() != null && next.isAfter(r.getEndDate())) {
next = null;
}
return RecurringResponse.builder()
.id(r.getId()).title(r.getTitle()).type(r.getType()).amount(r.getAmount())
.category(r.getCategory()).memo(r.getMemo())
.walletId(r.getWalletId()).walletName(r.getWalletName())
.toWalletId(r.getToWalletId()).toWalletName(r.getToWalletName())
.frequency(r.getFrequency()).dayOfMonth(r.getDayOfMonth())
.dayOfWeek(r.getDayOfWeek()).monthOfYear(r.getMonthOfYear())
.startDate(r.getStartDate()).endDate(r.getEndDate()).lastRunDate(r.getLastRunDate())
.nextDate(next).active(Boolean.TRUE.equals(r.getActive()))
.build();
}
private Recurring mustFind(Long id, Long memberId) {
Recurring r = recurringMapper.findByIdAndMember(id, memberId);
if (r == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "정기 거래를 찾을 수 없습니다.");
}
return r;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}