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