feat(budget): 1회성 예산 API 추가
Deploy / deploy (push) Failing after 11m13s

- BudgetOnetime 도메인/DTO/Mapper/Service 신규
- BudgetController: GET/POST/PUT/DELETE /api/account/budgets/onetime 엔드포인트 추가
- account.sql: budget_onetime 테이블 CREATE TABLE IF NOT EXISTS 추가
- WalletRequest: MINUS 유형 허용 추가
- CategoryService: importDefaults, validateParent, ensureCategory 추가

※ 최초 배포 시 서버 .env에 SQL_INIT_MODE=always 추가 후 재시작 필요 (budget_onetime 테이블 생성)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-05 16:15:15 +09:00
parent 24858f23e8
commit a0509099f7
10 changed files with 320 additions and 2 deletions
@@ -1,11 +1,14 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.BudgetOnetimeRequest;
import com.sb.web.account.dto.BudgetOnetimeResponse;
import com.sb.web.account.dto.BudgetPeriodStat;
import com.sb.web.account.dto.BudgetRequest;
import com.sb.web.account.dto.BudgetResponse;
import com.sb.web.account.dto.BudgetStatus;
import com.sb.web.account.dto.ExpectedIncomeRequest;
import com.sb.web.account.service.AccountSettingService;
import com.sb.web.account.service.BudgetOnetimeService;
import com.sb.web.account.service.BudgetService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
@@ -26,6 +29,7 @@ import java.util.List;
public class BudgetController {
private final BudgetService budgetService;
private final BudgetOnetimeService onetimeService;
private final AccountSettingService settingService;
/** 월 예상 수입 조회 (연·월별) */
@@ -110,4 +114,35 @@ public class BudgetController {
budgetService.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
// ===== 1회성 예산 =====
@GetMapping("/onetime")
public List<BudgetOnetimeResponse> onetimeList(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return onetimeService.list(current.getId());
}
@PostMapping("/onetime")
public ResponseEntity<BudgetOnetimeResponse> onetimeCreate(
@Valid @RequestBody BudgetOnetimeRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId()));
}
@PutMapping("/onetime/{id}")
public BudgetOnetimeResponse onetimeUpdate(
@PathVariable Long id,
@Valid @RequestBody BudgetOnetimeRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return onetimeService.update(id, req, current.getId());
}
@DeleteMapping("/onetime/{id}")
public ResponseEntity<Void> onetimeDelete(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
onetimeService.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,29 @@
package com.sb.web.account.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정.
* category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BudgetOnetime {
private Long id;
private Long memberId;
private String title;
private String category; // null = 전체
private Long amount;
private LocalDate startDate;
private LocalDate endDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,30 @@
package com.sb.web.account.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
@Data
public class BudgetOnetimeRequest {
@NotBlank(message = "제목을 입력하세요.")
@Size(max = 100, message = "제목은 100자 이내여야 합니다.")
private String title;
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
private String category; // null 또는 빈값 = 전체 지출
@NotNull(message = "금액을 입력하세요.")
@Positive(message = "금액은 0보다 커야 합니다.")
private Long amount;
@NotNull(message = "시작일을 입력하세요.")
private LocalDate startDate;
@NotNull(message = "종료일을 입력하세요.")
private LocalDate endDate;
}
@@ -0,0 +1,41 @@
package com.sb.web.account.dto;
import com.sb.web.account.domain.BudgetOnetime;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
@Data
@Builder
public class BudgetOnetimeResponse {
private Long id;
private String title;
private String category; // null = 전체
private Long amount;
private LocalDate startDate;
private LocalDate endDate;
private long spent;
private long remaining;
private String status; // UPCOMING / ACTIVE / ENDED
public static BudgetOnetimeResponse from(BudgetOnetime b, long spent) {
LocalDate today = LocalDate.now();
String status;
if (today.isBefore(b.getStartDate())) status = "UPCOMING";
else if (today.isAfter(b.getEndDate())) status = "ENDED";
else status = "ACTIVE";
return BudgetOnetimeResponse.builder()
.id(b.getId())
.title(b.getTitle())
.category(b.getCategory())
.amount(b.getAmount())
.startDate(b.getStartDate())
.endDate(b.getEndDate())
.spent(spent)
.remaining(b.getAmount() - spent)
.status(status)
.build();
}
}
@@ -17,7 +17,7 @@ import java.time.LocalDate;
public class WalletRequest {
@NotBlank(message = "유형을 선택하세요.")
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
private String type;
@NotBlank(message = "이름을 입력하세요.")
@@ -0,0 +1,27 @@
package com.sb.web.account.mapper;
import com.sb.web.account.domain.BudgetOnetime;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface BudgetOnetimeMapper {
List<BudgetOnetime> findByMember(@Param("memberId") Long memberId);
BudgetOnetime findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
long sumExpenseInRange(@Param("memberId") Long memberId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("category") String category);
void insert(BudgetOnetime b);
void update(BudgetOnetime b);
void delete(@Param("id") Long id, @Param("memberId") Long memberId);
}
@@ -0,0 +1,82 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.BudgetOnetime;
import com.sb.web.account.dto.BudgetOnetimeRequest;
import com.sb.web.account.dto.BudgetOnetimeResponse;
import com.sb.web.account.mapper.BudgetOnetimeMapper;
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.List;
@Service
@RequiredArgsConstructor
public class BudgetOnetimeService {
private final BudgetOnetimeMapper mapper;
public List<BudgetOnetimeResponse> list(Long memberId) {
return mapper.findByMember(memberId).stream()
.map(b -> {
long spent = mapper.sumExpenseInRange(
memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
})
.toList();
}
@Transactional
public BudgetOnetimeResponse create(BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = BudgetOnetime.builder()
.memberId(memberId)
.title(req.getTitle().trim())
.category(blankToNull(req.getCategory()))
.amount(req.getAmount())
.startDate(req.getStartDate())
.endDate(req.getEndDate())
.build();
mapper.insert(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public BudgetOnetimeResponse update(Long id, BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = mustFind(id, memberId);
b.setTitle(req.getTitle().trim());
b.setCategory(blankToNull(req.getCategory()));
b.setAmount(req.getAmount());
b.setStartDate(req.getStartDate());
b.setEndDate(req.getEndDate());
mapper.update(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
mapper.delete(id, memberId);
}
private void validate(BudgetOnetimeRequest req) {
if (req.getEndDate().isBefore(req.getStartDate())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠를 수 없습니다.");
}
}
private BudgetOnetime mustFind(Long id, Long memberId) {
BudgetOnetime b = mapper.findByIdAndMember(id, memberId);
if (b == null) throw new ApiException(HttpStatus.NOT_FOUND, "1회성 예산을 찾을 수 없습니다.");
return b;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}
@@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
*/
+15
View File
@@ -197,6 +197,21 @@ CREATE TABLE IF NOT EXISTS budget_income (
PRIMARY KEY (member_id, `year`, `month`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1회성 예산 (기간·제목별 단발 예산)
CREATE TABLE IF NOT EXISTS budget_onetime (
id BIGINT NOT NULL AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
title VARCHAR(100) NOT NULL COMMENT '제목 (예: 7월 여행)',
category VARCHAR(50) NULL COMMENT '카테고리 (NULL=전체 지출)',
amount BIGINT NOT NULL COMMENT '총 예산',
start_date DATE NOT NULL COMMENT '기간 시작일',
end_date DATE NOT NULL COMMENT '기간 종료일',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_bot_member_date (member_id, start_date, end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 투자 보유 종목 (INVEST 지갑별 · 사용자별)
CREATE TABLE IF NOT EXISTS invest_holding (
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.account.mapper.BudgetOnetimeMapper">
<resultMap id="rm" type="com.sb.web.account.domain.BudgetOnetime">
<id property="id" column="id"/>
<result property="memberId" column="member_id"/>
<result property="title" column="title"/>
<result property="category" column="category"/>
<result property="amount" column="amount"/>
<result property="startDate" column="start_date"/>
<result property="endDate" column="end_date"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findByMember" resultMap="rm">
SELECT id, member_id, title, category, amount, start_date, end_date, created_at, updated_at
FROM budget_onetime
WHERE member_id = #{memberId}
ORDER BY start_date DESC
</select>
<select id="findByIdAndMember" resultMap="rm">
SELECT id, member_id, title, category, amount, start_date, end_date, created_at, updated_at
FROM budget_onetime
WHERE id = #{id} AND member_id = #{memberId}
</select>
<!-- 기간 내 지출 합산 (category=null 이면 전체, 값 있으면 해당 카테고리만) -->
<select id="sumExpenseInRange" resultType="long">
SELECT COALESCE(SUM(amount), 0)
FROM account_entry
WHERE member_id = #{memberId}
AND type = 'EXPENSE'
AND entry_date BETWEEN #{startDate} AND #{endDate}
<if test="category != null and category != ''">
AND category = #{category}
</if>
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.BudgetOnetime"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO budget_onetime (member_id, title, category, amount, start_date, end_date, created_at, updated_at)
VALUES (#{memberId}, #{title}, #{category}, #{amount}, #{startDate}, #{endDate}, NOW(), NOW())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.BudgetOnetime">
UPDATE budget_onetime
SET title = #{title}, category = #{category}, amount = #{amount},
start_date = #{startDate}, end_date = #{endDate}, updated_at = NOW()
WHERE id = #{id} AND member_id = #{memberId}
</update>
<delete id="delete">
DELETE FROM budget_onetime WHERE id = #{id} AND member_id = #{memberId}
</delete>
</mapper>