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:
+2
-2
@@ -1,11 +1,11 @@
|
||||
package com.example.sb_bt;
|
||||
package com.sb.web;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.example.sb_bt.mapper")
|
||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper"})
|
||||
public class SbBtApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
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.service.AccountService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
|
||||
* GET /entries?year=&month= 내역 목록(본인)
|
||||
* GET /summary?year=&month= 요약(수입/지출/잔액)
|
||||
* POST /entries 등록
|
||||
* PUT /entries/{id} 수정(본인)
|
||||
* DELETE /entries/{id} 삭제(본인)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account")
|
||||
@RequiredArgsConstructor
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
|
||||
/* ===== 계좌/카드 (사용자별) ===== */
|
||||
|
||||
@GetMapping("/wallets")
|
||||
public List<WalletResponse> wallets(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listWallets(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/wallets")
|
||||
public ResponseEntity<WalletResponse> createWallet(
|
||||
@Valid @RequestBody WalletRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createWallet(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/wallets/{id}")
|
||||
public WalletResponse updateWallet(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody WalletRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.updateWallet(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/wallets/{id}")
|
||||
public ResponseEntity<Void> deleteWallet(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.deleteWallet(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/networth")
|
||||
public NetWorthResponse netWorth(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.netWorth(current.getId());
|
||||
}
|
||||
|
||||
/** 특정 계좌의 연관 내역 */
|
||||
@GetMapping("/wallets/{id}/entries")
|
||||
public List<AccountEntryResponse> walletEntries(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listEntriesByWallet(id, current.getId());
|
||||
}
|
||||
|
||||
/** 대출/카드 상환·납부 (원금=이체, 이자=지출 자동 분리) */
|
||||
@PostMapping("/repayment")
|
||||
public ResponseEntity<Void> repay(
|
||||
@Valid @RequestBody RepaymentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.repay(req, current.getId());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).build();
|
||||
}
|
||||
|
||||
/* ===== 가계부 태그 (사용자별) ===== */
|
||||
|
||||
@GetMapping("/tags")
|
||||
public List<AccountTagResponse> tags(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listTags(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/tags")
|
||||
public ResponseEntity<AccountTagResponse> createTag(
|
||||
@Valid @RequestBody AccountTagRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createTag(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/tags/{id}")
|
||||
public AccountTagResponse updateTag(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody AccountTagRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.updateTag(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/tags/{id}")
|
||||
public ResponseEntity<Void> deleteTag(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.deleteTag(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/entries")
|
||||
public List<AccountEntryResponse> list(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) Long walletId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long tagId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.list(current.getId(), year, month, type, category, walletId, keyword, tagId);
|
||||
}
|
||||
|
||||
/** 분류(카테고리)별 합계 — 파이차트용 */
|
||||
@GetMapping("/category-stats")
|
||||
public List<CategoryStat> categoryStats(
|
||||
@RequestParam(defaultValue = "EXPENSE") String type,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.categoryStats(current.getId(), type, year, month);
|
||||
}
|
||||
|
||||
/** 월별 순자산 추이 */
|
||||
@GetMapping("/networth/trend")
|
||||
public List<NetWorthPoint> netWorthTrend(
|
||||
@RequestParam(required = false) Integer months,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.netWorthTrend(current.getId(), months);
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public List<PeriodStat> stats(
|
||||
@RequestParam(defaultValue = "MONTH") String unit,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.stats(current.getId(), unit, year, month);
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public AccountSummary summary(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.summary(current.getId(), year, month);
|
||||
}
|
||||
|
||||
@PostMapping("/entries")
|
||||
public ResponseEntity<AccountEntryResponse> create(
|
||||
@Valid @RequestBody AccountEntryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/entries/{id}")
|
||||
public AccountEntryResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody AccountEntryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/entries/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
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.service.BudgetService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 예산 API. (/api/account/budgets — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/budgets")
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetController {
|
||||
|
||||
private final BudgetService budgetService;
|
||||
|
||||
@GetMapping
|
||||
public List<BudgetResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.list(current.getId());
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public List<BudgetStatus> status(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.status(current.getId(), year, month);
|
||||
}
|
||||
|
||||
/** 기간(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프) */
|
||||
@GetMapping("/period")
|
||||
public List<BudgetPeriodStat> period(
|
||||
@RequestParam(defaultValue = "MONTH") String unit,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.periodStats(current.getId(), unit, year, month);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<BudgetResponse> create(
|
||||
@Valid @RequestBody BudgetRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public BudgetResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody BudgetRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
budgetService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.service.CategoryService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) API. (/api/account/categories — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CategoryResponse> create(
|
||||
@Valid @RequestBody CategoryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(categoryService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CategoryResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody CategoryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
categoryService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 기존 내역의 분류를 목록으로 가져오기 */
|
||||
@PostMapping("/import")
|
||||
public List<CategoryResponse> importFromEntries(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.importFromEntries(current.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
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.service.InvestService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 투자 포트폴리오 API (C). 로그인 필요, 본인 데이터만.
|
||||
* GET /api/account/invest/holdings?walletId= 보유종목(집계 포함)
|
||||
* POST /api/account/invest/holdings 종목 추가
|
||||
* PUT /api/account/invest/holdings/{id} 종목 수정(현재가 등)
|
||||
* DELETE /api/account/invest/holdings/{id} 종목 삭제(매매이력 포함)
|
||||
* GET /api/account/invest/holdings/{id}/trades 매매이력
|
||||
* POST /api/account/invest/holdings/{id}/trades 매수/매도
|
||||
* DELETE /api/account/invest/trades/{id} 매매 삭제
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/invest")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestController {
|
||||
|
||||
private final InvestService investService;
|
||||
|
||||
@GetMapping("/holdings")
|
||||
public List<HoldingResponse> holdings(
|
||||
@RequestParam Long walletId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.listHoldings(current.getId(), walletId);
|
||||
}
|
||||
|
||||
@PostMapping("/holdings")
|
||||
public ResponseEntity<HoldingResponse> createHolding(
|
||||
@Valid @RequestBody HoldingRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/holdings/{id}")
|
||||
public HoldingResponse updateHolding(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody HoldingRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.updateHolding(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/holdings/{id}")
|
||||
public ResponseEntity<Void> deleteHolding(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.deleteHolding(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/holdings/{id}/trades")
|
||||
public List<TradeResponse> trades(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.listTrades(id, current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/holdings/{id}/trades")
|
||||
public ResponseEntity<TradeResponse> addTrade(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody TradeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.addTrade(id, req, current.getId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/trades/{id}")
|
||||
public ResponseEntity<Void> deleteTrade(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.deleteTrade(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.RecurringRequest;
|
||||
import com.sb.web.account.dto.RecurringResponse;
|
||||
import com.sb.web.account.service.RecurringService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 정기/반복 거래 API. (/api/account/recurrings — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/recurrings")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringController {
|
||||
|
||||
private final RecurringService recurringService;
|
||||
|
||||
@GetMapping
|
||||
public List<RecurringResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return recurringService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<RecurringResponse> create(
|
||||
@Valid @RequestBody RecurringRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(recurringService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public RecurringResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody RecurringRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return recurringService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
recurringService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 밀린 회차 자동 생성 → 생성 건수 반환 */
|
||||
@PostMapping("/run")
|
||||
public Map<String, Integer> run(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return Map.of("generated", recurringService.run(current.getId()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) — 사용자별, 수입(INCOME)/지출(EXPENSE).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 가계부 항목. account_entry 테이블과 매핑. (소유자 member_id 격리)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountEntry implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private LocalDate entryDate;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String category;
|
||||
private Long amount; // 원(양수)
|
||||
private String memo;
|
||||
private Long walletId; // 발생/출금 계좌(wallet.id)
|
||||
private String walletName; // 조인 표시용
|
||||
private Long toWalletId; // 이체 입금 계좌
|
||||
private String toWalletName;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 가계부 태그 (사용자별). account_tag 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountTag implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String name;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 예산 (사용자별, 카테고리별). budget 테이블과 매핑.
|
||||
* fixed=true: base_unit/base_amount 로 일수기준 환산. false: daily/weekly/monthly/yearly 직접.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Budget implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String category;
|
||||
private Boolean fixed;
|
||||
private String baseUnit; // DAY / WEEK / MONTH
|
||||
private Long baseAmount;
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 투자 보유 종목 (INVEST 지갑별, 사용자별). invest_holding 테이블과 매핑.
|
||||
* 수량/평단/평가손익은 매매이력(invest_trade)으로 서비스에서 산출한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvestHolding {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long walletId;
|
||||
private String name; // 종목명
|
||||
private String ticker; // 종목코드(선택)
|
||||
private Long currentPrice; // 현재가(원, 수동)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 투자 매매 이력(매수/매도). invest_trade 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvestTrade {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long holdingId;
|
||||
private String tradeType; // BUY / SELL
|
||||
private LocalDate tradeDate;
|
||||
private Long quantity; // 수량(주)
|
||||
private Long price; // 단가(원)
|
||||
private Long fee; // 수수료/세금(원)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 정기/반복 거래 규칙. recurring 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Recurring implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String title;
|
||||
private String type; // INCOME / EXPENSE / TRANSFER
|
||||
private Long amount;
|
||||
private String category;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName; // 조인 표시용
|
||||
private Long toWalletId;
|
||||
private String toWalletName; // 조인 표시용
|
||||
private String frequency; // DAILY / WEEKLY / MONTHLY / YEARLY
|
||||
private Integer dayOfMonth;
|
||||
private Integer dayOfWeek; // 1=월 ~ 7=일
|
||||
private Integer monthOfYear;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDate lastRunDate;
|
||||
private Boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출/투자 (사용자별). wallet 테이블과 매핑.
|
||||
* type=BANK(은행계좌) / CARD(카드) / LOAN(대출) / INVEST(투자).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Wallet implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String type; // BANK / CARD
|
||||
private String name; // 별칭
|
||||
private String issuer; // 은행명 / 카드사
|
||||
private String accountNumber; // 계좌번호 (BANK)
|
||||
private String cardType; // CREDIT / CHECK (CARD)
|
||||
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
|
||||
private LocalDate openingDate;
|
||||
private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 항목 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class AccountEntryRequest {
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate entryDate;
|
||||
|
||||
@NotBlank(message = "구분을 선택하세요.")
|
||||
@Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@Size(max = 50, message = "분류는 50자 이내여야 합니다.")
|
||||
private String category;
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@PositiveOrZero(message = "금액은 0 이상이어야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
||||
private String memo;
|
||||
|
||||
/** 발생/출금 계좌 ID (본인 wallet, 선택. 이체 시 출금 계좌) */
|
||||
private Long walletId;
|
||||
|
||||
/** 이체 입금 계좌 ID (TRANSFER 시 필수) */
|
||||
private Long toWalletId;
|
||||
|
||||
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 항목 응답 (소유자 ID 제외, 태그 이름 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountEntryResponse {
|
||||
|
||||
private Long id;
|
||||
private LocalDate entryDate;
|
||||
private String type;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName;
|
||||
private Long toWalletId;
|
||||
private String toWalletName;
|
||||
private List<String> tags;
|
||||
|
||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||
return AccountEntryResponse.builder()
|
||||
.id(e.getId())
|
||||
.entryDate(e.getEntryDate())
|
||||
.type(e.getType())
|
||||
.category(e.getCategory())
|
||||
.amount(e.getAmount())
|
||||
.memo(e.getMemo())
|
||||
.walletId(e.getWalletId())
|
||||
.walletName(e.getWalletName())
|
||||
.toWalletId(e.getToWalletId())
|
||||
.toWalletName(e.getToWalletName())
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 요약 (수입/지출/잔액).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountSummary {
|
||||
|
||||
private long totalIncome;
|
||||
private long totalExpense;
|
||||
private long balance;
|
||||
|
||||
public static AccountSummary of(long income, long expense) {
|
||||
return AccountSummary.builder()
|
||||
.totalIncome(income)
|
||||
.totalExpense(expense)
|
||||
.balance(income - expense)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 태그 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class AccountTagRequest {
|
||||
|
||||
@NotBlank(message = "태그 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountTag;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 태그 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountTagResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
|
||||
public static AccountTagResponse from(AccountTag t) {
|
||||
return AccountTagResponse.builder().id(t.getId()).name(t.getName()).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 기간 버킷별 예산 대비 지출.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetPeriodStat {
|
||||
|
||||
private String bucket;
|
||||
private long budget;
|
||||
private long expense;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 예산 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class BudgetRequest {
|
||||
|
||||
@NotBlank(message = "카테고리를 입력하세요.")
|
||||
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
|
||||
private String category;
|
||||
|
||||
private boolean fixed;
|
||||
|
||||
// 고정(fixed=true)
|
||||
@Pattern(regexp = "DAY|WEEK|MONTH|", message = "기준 단위가 올바르지 않습니다.")
|
||||
private String baseUnit;
|
||||
private Long baseAmount;
|
||||
|
||||
// 비고정(fixed=false)
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 예산 응답 (편집용).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetResponse {
|
||||
|
||||
private Long id;
|
||||
private String category;
|
||||
private boolean fixed;
|
||||
private String baseUnit;
|
||||
private Long baseAmount;
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
|
||||
public static BudgetResponse from(Budget b) {
|
||||
return BudgetResponse.builder()
|
||||
.id(b.getId())
|
||||
.category(b.getCategory())
|
||||
.fixed(Boolean.TRUE.equals(b.getFixed()))
|
||||
.baseUnit(b.getBaseUnit())
|
||||
.baseAmount(b.getBaseAmount())
|
||||
.daily(b.getDaily())
|
||||
.weekly(b.getWeekly())
|
||||
.monthly(b.getMonthly())
|
||||
.yearly(b.getYearly())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 특정 월의 예산 대비 지출 현황.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetStatus {
|
||||
|
||||
private Long id;
|
||||
private String category;
|
||||
private boolean fixed;
|
||||
private long monthlyBudget; // 이 달의 예산(고정은 실제 일수 반영)
|
||||
private long spent; // 이 달 지출
|
||||
private long remaining; // 잔여
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class CategoryRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "INCOME|EXPENSE", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "분류 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CategoryResponse {
|
||||
|
||||
private Long id;
|
||||
private String type;
|
||||
private String name;
|
||||
|
||||
public static CategoryResponse from(AccountCategory c) {
|
||||
return CategoryResponse.builder().id(c.getId()).type(c.getType()).name(c.getName()).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류(카테고리)별 합계 통계. (파이차트용)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CategoryStat {
|
||||
|
||||
private String category; // 분류명 (없으면 "미분류")
|
||||
private long total; // 합계 금액
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 보유 종목 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class HoldingRequest {
|
||||
|
||||
@NotNull(message = "투자계좌를 선택하세요.")
|
||||
private Long walletId;
|
||||
|
||||
@NotBlank(message = "종목명을 입력하세요.")
|
||||
@Size(max = 100, message = "종목명은 100자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@Size(max = 20, message = "종목코드는 20자 이내여야 합니다.")
|
||||
private String ticker;
|
||||
|
||||
/** 현재가(원, 수동 갱신). 미입력 시 평단 기준 평가 */
|
||||
@PositiveOrZero(message = "현재가는 0 이상이어야 합니다.")
|
||||
private Long currentPrice;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 보유 종목 응답 (매매이력으로 산출한 집계 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class HoldingResponse {
|
||||
|
||||
private Long id;
|
||||
private Long walletId;
|
||||
private String name;
|
||||
private String ticker;
|
||||
private Long currentPrice; // 현재가(수동, null 가능)
|
||||
|
||||
private Long quantity; // 보유수량 (매수−매도)
|
||||
private Long avgPrice; // 평균매입단가 (보유분, 반올림 표시값)
|
||||
private Long costBasis; // 보유분 매입원가 합(수수료 포함)
|
||||
private Long evalValue; // 평가금액 = 수량 × (현재가 ?? 평단)
|
||||
private Long evalGain; // 평가손익(미실현) = 평가금액 − 매입원가
|
||||
private Double returnPct; // 수익률(%) = 평가손익 / 매입원가
|
||||
private Long realizedPL; // 실현손익(매도 누적)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 월별 순자산 추이 한 점. (순자산 = 모든 계좌 잔액 합, 부채는 음수)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NetWorthPoint {
|
||||
|
||||
private String month; // "yyyy-MM"
|
||||
private long netWorth; // 해당 월말 기준 순자산
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 순자산 요약. totalAssets(총자산) - totalLiabilities(총부채) = netWorth.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NetWorthResponse {
|
||||
|
||||
private long totalAssets;
|
||||
private long totalLiabilities;
|
||||
private long netWorth;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 기간 버킷별 수입/지출 통계.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PeriodStat {
|
||||
|
||||
private String bucket; // 일/주/월/년 라벨 (예: "5", "1", "2026")
|
||||
private long income;
|
||||
private long expense;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 정기 거래 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class RecurringRequest {
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
@Size(max = 100, message = "이름은 100자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@Size(max = 50)
|
||||
private String category;
|
||||
|
||||
@Size(max = 255)
|
||||
private String memo;
|
||||
|
||||
private Long walletId;
|
||||
private Long toWalletId;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "DAILY|WEEKLY|MONTHLY|YEARLY", message = "주기가 올바르지 않습니다.")
|
||||
private String frequency;
|
||||
|
||||
private Integer dayOfMonth; // MONTHLY/YEARLY
|
||||
private Integer dayOfWeek; // WEEKLY (1~7)
|
||||
private Integer monthOfYear; // YEARLY (1~12)
|
||||
|
||||
@NotNull(message = "시작일을 입력하세요.")
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
|
||||
private boolean active = true;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Recurring;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 정기 거래 응답 (다음 예정일 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RecurringResponse {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String type;
|
||||
private Long amount;
|
||||
private String category;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName;
|
||||
private Long toWalletId;
|
||||
private String toWalletName;
|
||||
private String frequency;
|
||||
private Integer dayOfMonth;
|
||||
private Integer dayOfWeek;
|
||||
private Integer monthOfYear;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDate lastRunDate;
|
||||
private LocalDate nextDate; // 다음 예정일(계산)
|
||||
private boolean active;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 대출/카드 상환·납부 요청. 원금은 이체, 이자는 지출로 분리 생성된다.
|
||||
*/
|
||||
@Data
|
||||
public class RepaymentRequest {
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate entryDate;
|
||||
|
||||
@NotNull(message = "출금 계좌를 선택하세요.")
|
||||
private Long fromWalletId;
|
||||
|
||||
@NotNull(message = "대상(대출/카드)을 선택하세요.")
|
||||
private Long targetWalletId;
|
||||
|
||||
@PositiveOrZero(message = "원금은 0 이상이어야 합니다.")
|
||||
private Long principal;
|
||||
|
||||
@PositiveOrZero(message = "이자는 0 이상이어야 합니다.")
|
||||
private Long interest;
|
||||
|
||||
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
||||
private String memo;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 매매(매수/매도) 등록 요청.
|
||||
*/
|
||||
@Data
|
||||
public class TradeRequest {
|
||||
|
||||
@Pattern(regexp = "BUY|SELL", message = "매매 구분이 올바르지 않습니다.")
|
||||
private String tradeType;
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate tradeDate;
|
||||
|
||||
@Positive(message = "수량은 1 이상이어야 합니다.")
|
||||
private Long quantity;
|
||||
|
||||
@PositiveOrZero(message = "단가는 0 이상이어야 합니다.")
|
||||
private Long price;
|
||||
|
||||
/** 수수료/세금(원). 미입력 시 0 */
|
||||
@PositiveOrZero(message = "수수료는 0 이상이어야 합니다.")
|
||||
private Long fee;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.InvestTrade;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 매매 이력 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TradeResponse {
|
||||
|
||||
private Long id;
|
||||
private Long holdingId;
|
||||
private String tradeType;
|
||||
private LocalDate tradeDate;
|
||||
private Long quantity;
|
||||
private Long price;
|
||||
private Long fee;
|
||||
private Long amount; // 거래금액 = 수량 × 단가 (수수료 제외)
|
||||
|
||||
public static TradeResponse from(InvestTrade t) {
|
||||
return TradeResponse.builder()
|
||||
.id(t.getId())
|
||||
.holdingId(t.getHoldingId())
|
||||
.tradeType(t.getTradeType())
|
||||
.tradeDate(t.getTradeDate())
|
||||
.quantity(t.getQuantity())
|
||||
.price(t.getPrice())
|
||||
.fee(t.getFee())
|
||||
.amount(t.getQuantity() * t.getPrice())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class WalletRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "BANK|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@Size(max = 50, message = "50자 이내여야 합니다.")
|
||||
private String issuer; // 은행명 / 카드사
|
||||
|
||||
@Size(max = 50, message = "50자 이내여야 합니다.")
|
||||
private String accountNumber; // 계좌번호 (BANK)
|
||||
|
||||
@Pattern(regexp = "CREDIT|CHECK|", message = "카드 종류가 올바르지 않습니다.")
|
||||
private String cardType; // CREDIT / CHECK (CARD)
|
||||
|
||||
/** 초기 잔액 (부호있음: 자산+, 부채-). 미입력 시 0 */
|
||||
private Long openingBalance;
|
||||
|
||||
private LocalDate openingDate;
|
||||
|
||||
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
|
||||
private Long currentValue;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 응답. balance 는 현재 잔액(서비스에서 계산해 채움).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class WalletResponse {
|
||||
|
||||
private Long id;
|
||||
private String type;
|
||||
private String name;
|
||||
private String issuer;
|
||||
private String accountNumber;
|
||||
private String cardType;
|
||||
private Long openingBalance;
|
||||
private LocalDate openingDate;
|
||||
private Long balance; // 현재 잔액(부호있음). INVEST는 총평가(예수금+주식)
|
||||
|
||||
// 투자(INVEST) 전용 — 서비스에서 포트폴리오로 산출
|
||||
private Long investedAmount; // 순입금(투입원금) = 입금−출금
|
||||
private Long deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수
|
||||
private Long stockValue; // 주식 평가금액
|
||||
private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금
|
||||
|
||||
public static WalletResponse from(Wallet w, long balance) {
|
||||
return WalletResponse.builder()
|
||||
.id(w.getId())
|
||||
.type(w.getType())
|
||||
.name(w.getName())
|
||||
.issuer(w.getIssuer())
|
||||
.accountNumber(w.getAccountNumber())
|
||||
.cardType(w.getCardType())
|
||||
.openingBalance(w.getOpeningBalance())
|
||||
.openingDate(w.getOpeningDate())
|
||||
.balance(balance)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 가계부 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AccountEntryMapper {
|
||||
|
||||
List<AccountEntry> findByMember(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month,
|
||||
@Param("type") String type,
|
||||
@Param("category") String category,
|
||||
@Param("walletId") Long walletId,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("tagId") Long tagId);
|
||||
|
||||
/** 특정 계좌(wallet)와 연관된 내역 (wallet_id 또는 to_wallet_id 가 해당 계좌) */
|
||||
List<AccountEntry> findByWalletAndMember(@Param("memberId") Long memberId,
|
||||
@Param("walletId") Long walletId);
|
||||
|
||||
AccountEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 구분(type)별 합계 — {type, total} 행 반환 */
|
||||
List<Map<String, Object>> sumByType(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 카테고리별 지출 합계 — {category, total} 행 반환 (예산 대비용) */
|
||||
List<Map<String, Object>> sumExpenseByCategory(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 구분(type)별 분류 합계 — {category, total} 행 반환 (파이차트용) */
|
||||
List<Map<String, Object>> sumByCategory(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */
|
||||
List<Map<String, Object>> monthlyWalletFlow(@Param("memberId") Long memberId);
|
||||
|
||||
/** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */
|
||||
List<Map<String, Object>> statsByPeriod(@Param("memberId") Long memberId,
|
||||
@Param("unit") String unit,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
int insert(AccountEntry entry);
|
||||
|
||||
int update(AccountEntry entry);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
|
||||
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
/** 분류 이름 변경 시 기존 내역 일괄 갱신 */
|
||||
int renameCategory(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("oldName") String oldName,
|
||||
@Param("newName") String newName);
|
||||
|
||||
/* ----- 항목-태그 ----- */
|
||||
int insertEntryTag(@Param("entryId") Long entryId, @Param("tagId") Long tagId);
|
||||
|
||||
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
|
||||
|
||||
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountTag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 태그 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AccountTagMapper {
|
||||
|
||||
List<AccountTag> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
AccountTag findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByName(@Param("memberId") Long memberId,
|
||||
@Param("name") String name,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(AccountTag tag);
|
||||
|
||||
int update(AccountTag tag);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */
|
||||
List<Long> findExistingIds(@Param("memberId") Long memberId, @Param("ids") List<Long> ids);
|
||||
|
||||
/** 태그 삭제 시 항목 연결도 제거 */
|
||||
int deleteEntryLinksByTagId(@Param("tagId") Long tagId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 예산 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface BudgetMapper {
|
||||
|
||||
List<Budget> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByCategory(@Param("memberId") Long memberId,
|
||||
@Param("category") String category,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(Budget budget);
|
||||
|
||||
int update(Budget budget);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 분류 이름 변경 시 예산 일괄 갱신 */
|
||||
int renameCategory(@Param("memberId") Long memberId,
|
||||
@Param("oldName") String oldName,
|
||||
@Param("newName") String newName);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 분류 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface CategoryMapper {
|
||||
|
||||
List<AccountCategory> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByName(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("name") String name,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(AccountCategory category);
|
||||
|
||||
int insertIgnore(AccountCategory category); // 기존 분류 불러오기용
|
||||
|
||||
int update(AccountCategory category);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.InvestHolding;
|
||||
import com.sb.web.account.domain.InvestTrade;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 투자 보유종목/매매이력 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface InvestMapper {
|
||||
|
||||
/* ===== 보유 종목 ===== */
|
||||
List<InvestHolding> findHoldingsByMember(@Param("memberId") Long memberId);
|
||||
|
||||
List<InvestHolding> findHoldingsByWallet(@Param("memberId") Long memberId,
|
||||
@Param("walletId") Long walletId);
|
||||
|
||||
InvestHolding findHoldingByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insertHolding(InvestHolding holding);
|
||||
|
||||
int updateHolding(InvestHolding holding);
|
||||
|
||||
int deleteHolding(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/* ===== 매매 이력 ===== */
|
||||
/** 회원의 전체 매매(집계용) — 종목·거래일·id 순 */
|
||||
List<InvestTrade> findTradesByMember(@Param("memberId") Long memberId);
|
||||
|
||||
List<InvestTrade> findTradesByHolding(@Param("holdingId") Long holdingId,
|
||||
@Param("memberId") Long memberId);
|
||||
|
||||
InvestTrade findTradeByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insertTrade(InvestTrade trade);
|
||||
|
||||
int deleteTrade(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int deleteTradesByHolding(@Param("holdingId") Long holdingId, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Recurring;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 정기 거래 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface RecurringMapper {
|
||||
|
||||
List<Recurring> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 활성 규칙 (자동 생성용) */
|
||||
List<Recurring> findActiveByMember(@Param("memberId") Long memberId);
|
||||
|
||||
Recurring findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insert(Recurring r);
|
||||
|
||||
int update(Recurring r);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int updateLastRun(@Param("id") Long id, @Param("lastRunDate") LocalDate lastRunDate);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface WalletMapper {
|
||||
|
||||
List<Wallet> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 계좌별 현재 잔액 {id, balance} */
|
||||
List<Map<String, Object>> findBalances(@Param("memberId") Long memberId);
|
||||
|
||||
Wallet findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insert(Wallet wallet);
|
||||
|
||||
int update(Wallet wallet);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
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.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.BudgetMapper;
|
||||
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.Year;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* 예산 서비스. 사용자별 카테고리 예산 CRUD + 월 예산(실제 일수 반영) 대비 지출 현황.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetService {
|
||||
|
||||
private final BudgetMapper budgetMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
|
||||
public List<BudgetResponse> list(Long memberId) {
|
||||
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse create(BudgetRequest req, Long memberId) {
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setMemberId(memberId);
|
||||
budgetMapper.insert(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setId(id);
|
||||
budget.setMemberId(memberId);
|
||||
budgetMapper.update(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
budgetMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 해당 월의 예산 대비 지출 현황 (고정 예산은 그 달 실제 일수 반영) */
|
||||
public List<BudgetStatus> status(Long memberId, int year, int month) {
|
||||
int daysInMonth = YearMonth.of(year, month).lengthOfMonth();
|
||||
|
||||
Map<String, Long> spentByCategory = new HashMap<>();
|
||||
for (Map<String, Object> row : entryMapper.sumExpenseByCategory(memberId, year, month)) {
|
||||
Object cat = row.get("category");
|
||||
long total = ((Number) row.get("total")).longValue();
|
||||
spentByCategory.put(cat == null ? "" : cat.toString(), total);
|
||||
}
|
||||
|
||||
List<BudgetStatus> result = new ArrayList<>();
|
||||
for (Budget b : budgetMapper.findByMember(memberId)) {
|
||||
long monthlyBudget = monthlyBudget(b, daysInMonth);
|
||||
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
|
||||
result.add(BudgetStatus.builder()
|
||||
.id(b.getId())
|
||||
.category(b.getCategory())
|
||||
.fixed(Boolean.TRUE.equals(b.getFixed()))
|
||||
.monthlyBudget(monthlyBudget)
|
||||
.spent(spent)
|
||||
.remaining(monthlyBudget - spent)
|
||||
.build());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 기간 버킷(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프용) */
|
||||
public List<BudgetPeriodStat> periodStats(Long memberId, String unitRaw, Integer year, Integer month) {
|
||||
String unit = switch (unitRaw == null ? "" : unitRaw.toUpperCase()) {
|
||||
case "DAY", "WEEK", "YEAR" -> unitRaw.toUpperCase();
|
||||
default -> "MONTH";
|
||||
};
|
||||
int y = year != null ? year : Year.now().getValue();
|
||||
int m = month != null ? month : 1;
|
||||
|
||||
List<Budget> budgets = budgetMapper.findByMember(memberId);
|
||||
Map<String, Long> expense = new HashMap<>();
|
||||
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
|
||||
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
|
||||
((Number) row.get("expense")).longValue());
|
||||
}
|
||||
|
||||
List<BudgetPeriodStat> result = new ArrayList<>();
|
||||
switch (unit) {
|
||||
case "DAY" -> {
|
||||
int days = YearMonth.of(y, m).lengthOfMonth();
|
||||
long dayBudget = sum(budgets, b -> dayBudget(b, y, m));
|
||||
for (int d = 1; d <= days; d++) result.add(stat(String.valueOf(d), dayBudget, expense));
|
||||
}
|
||||
case "WEEK" -> {
|
||||
int days = YearMonth.of(y, m).lengthOfMonth();
|
||||
int weeks = (int) Math.ceil(days / 7.0);
|
||||
long weekBudget = sum(budgets, b -> weekBudget(b, y, m));
|
||||
for (int w = 1; w <= weeks; w++) result.add(stat(String.valueOf(w), weekBudget, expense));
|
||||
}
|
||||
case "YEAR" -> {
|
||||
TreeSet<Integer> years = new TreeSet<>();
|
||||
for (String k : expense.keySet()) years.add(Integer.parseInt(k));
|
||||
if (years.isEmpty()) years.add(y);
|
||||
for (int yr : years) {
|
||||
long yb = sum(budgets, b -> yearBudget(b, yr));
|
||||
result.add(stat(String.valueOf(yr), yb, expense));
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
for (int mm = 1; mm <= 12; mm++) {
|
||||
final int month0 = mm;
|
||||
long mb = sum(budgets, b -> monthlyBudget(b, YearMonth.of(y, month0).lengthOfMonth()));
|
||||
result.add(stat(String.valueOf(mm), mb, expense));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private interface BudgetFn {
|
||||
long apply(Budget b);
|
||||
}
|
||||
|
||||
private long sum(List<Budget> budgets, BudgetFn fn) {
|
||||
long total = 0;
|
||||
for (Budget b : budgets) total += fn.apply(b);
|
||||
return total;
|
||||
}
|
||||
|
||||
private BudgetPeriodStat stat(String bucket, long budget, Map<String, Long> expense) {
|
||||
return BudgetPeriodStat.builder().bucket(bucket).budget(budget).expense(expense.getOrDefault(bucket, 0L)).build();
|
||||
}
|
||||
|
||||
private double dailyRate(Budget b) {
|
||||
long base = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
|
||||
return (double) base / baseDays(b.getBaseUnit());
|
||||
}
|
||||
|
||||
private long dayBudget(Budget b, int year, int month) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b));
|
||||
if (b.getDaily() != null) return b.getDaily();
|
||||
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth());
|
||||
if (b.getWeekly() != null) return Math.round(b.getWeekly() / 7.0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long weekBudget(Budget b, int year, int month) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * 7);
|
||||
if (b.getWeekly() != null) return b.getWeekly();
|
||||
if (b.getDaily() != null) return b.getDaily() * 7;
|
||||
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth() * 7);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long yearBudget(Budget b, int year) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * Year.of(year).length());
|
||||
if (b.getYearly() != null) return b.getYearly();
|
||||
if (b.getMonthly() != null) return b.getMonthly() * 12;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 이 달 예산: 고정이면 일예산×해당월 일수, 비고정이면 monthly 값 */
|
||||
private long monthlyBudget(Budget b, int daysInMonth) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) {
|
||||
long baseAmount = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
|
||||
double dailyRate = (double) baseAmount / baseDays(b.getBaseUnit());
|
||||
return Math.round(dailyRate * daysInMonth);
|
||||
}
|
||||
return b.getMonthly() != null ? b.getMonthly() : 0L;
|
||||
}
|
||||
|
||||
private int baseDays(String unit) {
|
||||
if ("WEEK".equals(unit)) return 7;
|
||||
if ("MONTH".equals(unit)) return 30;
|
||||
return 1; // DAY
|
||||
}
|
||||
|
||||
private Budget toBudget(BudgetRequest req, String category) {
|
||||
if (req.isFixed()) {
|
||||
return Budget.builder()
|
||||
.category(category)
|
||||
.fixed(true)
|
||||
.baseUnit(req.getBaseUnit())
|
||||
.baseAmount(req.getBaseAmount())
|
||||
.build();
|
||||
}
|
||||
return Budget.builder()
|
||||
.category(category)
|
||||
.fixed(false)
|
||||
.daily(req.getDaily())
|
||||
.weekly(req.getWeekly())
|
||||
.monthly(req.getMonthly())
|
||||
.yearly(req.getYearly())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Budget mustFind(Long id, Long memberId) {
|
||||
Budget b = budgetMapper.findByIdAndMember(id, memberId);
|
||||
if (b == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "예산을 찾을 수 없습니다.");
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.BudgetMapper;
|
||||
import com.sb.web.account.mapper.CategoryMapper;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
private final BudgetMapper budgetMapper;
|
||||
|
||||
public List<CategoryResponse> list(Long memberId) {
|
||||
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse create(CategoryRequest req, Long memberId) {
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
AccountCategory c = AccountCategory.builder()
|
||||
.memberId(memberId).type(req.getType()).name(name).sortOrder(0).build();
|
||||
categoryMapper.insert(c);
|
||||
return CategoryResponse.from(c);
|
||||
}
|
||||
|
||||
/** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */
|
||||
@Transactional
|
||||
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
||||
AccountCategory c = mustFind(id, memberId);
|
||||
String newName = req.getName().trim();
|
||||
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
String oldName = c.getName();
|
||||
c.setName(newName);
|
||||
categoryMapper.update(c);
|
||||
if (!oldName.equals(newName)) {
|
||||
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
||||
if ("EXPENSE".equals(c.getType())) {
|
||||
budgetMapper.renameCategory(memberId, oldName, newName);
|
||||
}
|
||||
}
|
||||
return CategoryResponse.from(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
categoryMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */
|
||||
@Transactional
|
||||
public List<CategoryResponse> importFromEntries(Long memberId) {
|
||||
for (String type : new String[]{"INCOME", "EXPENSE"}) {
|
||||
for (String name : entryMapper.findDistinctCategories(memberId, type)) {
|
||||
categoryMapper.insertIgnore(AccountCategory.builder()
|
||||
.memberId(memberId).type(type).name(name).sortOrder(0).build());
|
||||
}
|
||||
}
|
||||
return list(memberId);
|
||||
}
|
||||
|
||||
private AccountCategory mustFind(Long id, Long memberId) {
|
||||
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "분류를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.sb.web.admin.controller;
|
||||
|
||||
import com.sb.web.board.dto.BoardSettingRequest;
|
||||
import com.sb.web.board.dto.BoardSettingResponse;
|
||||
import com.sb.web.board.dto.TagCategoryRequest;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.TagRequest;
|
||||
import com.sb.web.board.dto.TagResponse;
|
||||
import com.sb.web.board.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 관리자 태그 관리 API. (/api/admin/** — AdminInterceptor 로 ADMIN 권한 필요)
|
||||
* GET /tag-categories 카테고리+태그 목록
|
||||
* POST /tag-categories 카테고리 생성
|
||||
* PUT /tag-categories/{id} 카테고리 수정
|
||||
* DELETE /tag-categories/{id} 카테고리 삭제(소속 태그 포함)
|
||||
* POST /tags 태그 생성
|
||||
* PUT /tags/{id} 태그 수정
|
||||
* DELETE /tags/{id} 태그 삭제
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminTagController {
|
||||
|
||||
private final TagService tagService;
|
||||
|
||||
@GetMapping("/tag-categories")
|
||||
public List<TagCategoryResponse> categories() {
|
||||
return tagService.listGrouped();
|
||||
}
|
||||
|
||||
@PostMapping("/tag-categories")
|
||||
public ResponseEntity<TagCategoryResponse> createCategory(@Valid @RequestBody TagCategoryRequest req) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(tagService.createCategory(req));
|
||||
}
|
||||
|
||||
@PutMapping("/tag-categories/{id}")
|
||||
public TagCategoryResponse updateCategory(@PathVariable Long id, @Valid @RequestBody TagCategoryRequest req) {
|
||||
return tagService.updateCategory(id, req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/tag-categories/{id}")
|
||||
public ResponseEntity<Void> deleteCategory(@PathVariable Long id) {
|
||||
tagService.deleteCategory(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/tags")
|
||||
public ResponseEntity<TagResponse> createTag(@Valid @RequestBody TagRequest req) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(tagService.createTag(req));
|
||||
}
|
||||
|
||||
@PutMapping("/tags/{id}")
|
||||
public TagResponse updateTag(@PathVariable Long id, @Valid @RequestBody TagRequest req) {
|
||||
return tagService.updateTag(id, req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/tags/{id}")
|
||||
public ResponseEntity<Void> deleteTag(@PathVariable Long id) {
|
||||
tagService.deleteTag(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/* ===== 게시판 설정 (사용할 태그 카테고리) ===== */
|
||||
|
||||
@GetMapping("/board-setting")
|
||||
public BoardSettingResponse boardSetting() {
|
||||
return tagService.getBoardSetting();
|
||||
}
|
||||
|
||||
@PutMapping("/board-setting")
|
||||
public BoardSettingResponse updateBoardSetting(@RequestBody BoardSettingRequest req) {
|
||||
return tagService.setBoardSetting(req.getTagCategoryId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.sb.web.auth.controller;
|
||||
|
||||
import com.sb.web.auth.dto.LoginRequest;
|
||||
import com.sb.web.auth.dto.LoginResponse;
|
||||
import com.sb.web.auth.dto.MemberResponse;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.dto.SignupRequest;
|
||||
import com.sb.web.auth.service.AuthService;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 인증 API.
|
||||
* POST /api/auth/signup 회원가입 (비보호)
|
||||
* POST /api/auth/login 로그인 (비보호)
|
||||
* POST /api/auth/logout 로그아웃 (보호)
|
||||
* GET /api/auth/me 내 정보 (보호)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/signup")
|
||||
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody SignupRequest req) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public LoginResponse login(@Valid @RequestBody LoginRequest req) {
|
||||
return authService.login(req);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
||||
authService.logout(AuthInterceptor.resolveToken(request));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public SessionUser me(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.auth.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 회원 도메인. member 테이블과 매핑.
|
||||
* 로컬(아이디/비밀번호) + 소셜(네이버 등) 로그인을 함께 수용한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Member implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String loginId; // 로컬 로그인 아이디 (소셜 전용은 null)
|
||||
private String password; // BCrypt 해시 (소셜 전용은 null)
|
||||
private String name;
|
||||
private String email;
|
||||
private String provider; // LOCAL / NAVER
|
||||
private String providerId; // 소셜 제공자 측 고유 ID
|
||||
private String role; // USER / ADMIN
|
||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 로컬 로그인 요청 (아이디/비밀번호).
|
||||
*/
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "아이디를 입력하세요.")
|
||||
private String loginId;
|
||||
|
||||
@NotBlank(message = "비밀번호를 입력하세요.")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 로그인 성공 응답. 프론트는 token 을 localStorage 에 저장하고
|
||||
* 이후 요청 시 Authorization: Bearer {token} 으로 전송한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class LoginResponse {
|
||||
|
||||
private String token;
|
||||
private long expiresInSeconds;
|
||||
private MemberResponse member;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 클라이언트로 내려보내는 회원 정보 (비밀번호 등 민감 정보 제외).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class MemberResponse {
|
||||
|
||||
private Long id;
|
||||
private String loginId;
|
||||
private String name;
|
||||
private String email;
|
||||
private String provider;
|
||||
private String role;
|
||||
|
||||
public static MemberResponse from(Member m) {
|
||||
return MemberResponse.builder()
|
||||
.id(m.getId())
|
||||
.loginId(m.getLoginId())
|
||||
.name(m.getName())
|
||||
.email(m.getEmail())
|
||||
.provider(m.getProvider())
|
||||
.role(m.getRole())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Redis 세션에 저장되는 인증 주체 정보 (경량).
|
||||
* 인터셉터 통과 후 컨트롤러에서 현재 로그인 사용자로 사용한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionUser implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String loginId;
|
||||
private String name;
|
||||
private String role;
|
||||
private String provider;
|
||||
|
||||
public static SessionUser from(Member m) {
|
||||
return SessionUser.builder()
|
||||
.id(m.getId())
|
||||
.loginId(m.getLoginId())
|
||||
.name(m.getName())
|
||||
.role(m.getRole())
|
||||
.provider(m.getProvider())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 로컬 회원가입 요청.
|
||||
*/
|
||||
@Data
|
||||
public class SignupRequest {
|
||||
|
||||
@NotBlank(message = "아이디를 입력하세요.")
|
||||
@Size(min = 4, max = 50, message = "아이디는 4~50자여야 합니다.")
|
||||
private String loginId;
|
||||
|
||||
@NotBlank(message = "비밀번호를 입력하세요.")
|
||||
@Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
private String name;
|
||||
|
||||
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sb.web.auth.mapper;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 회원 MyBatis 매퍼. SQL 은 resources/mapper/MemberMapper.xml 에 정의.
|
||||
*/
|
||||
@Mapper
|
||||
public interface MemberMapper {
|
||||
|
||||
Member findById(@Param("id") Long id);
|
||||
|
||||
Member findByLoginId(@Param("loginId") String loginId);
|
||||
|
||||
Member findByProvider(@Param("provider") String provider,
|
||||
@Param("providerId") String providerId);
|
||||
|
||||
int countByLoginId(@Param("loginId") String loginId);
|
||||
|
||||
int insert(Member member);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.sb.web.auth.service;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import com.sb.web.auth.dto.LoginRequest;
|
||||
import com.sb.web.auth.dto.LoginResponse;
|
||||
import com.sb.web.auth.dto.MemberResponse;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.dto.SignupRequest;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 인증 서비스.
|
||||
* - 회원가입: BCrypt 해시 저장
|
||||
* - 로그인: 비밀번호 검증 후 세션 토큰 발급 → Redis 저장
|
||||
* - 세션: Redis 에서 토큰으로 조회(슬라이딩 만료), 로그아웃 시 삭제
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final MemberMapper memberMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String SESSION_PREFIX = "session:";
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60);
|
||||
|
||||
@Transactional
|
||||
public MemberResponse signup(SignupRequest req) {
|
||||
if (memberMapper.countByLoginId(req.getLoginId()) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
||||
}
|
||||
Member member = Member.builder()
|
||||
.loginId(req.getLoginId())
|
||||
.password(passwordEncoder.encode(req.getPassword()))
|
||||
.name(req.getName())
|
||||
.email(req.getEmail())
|
||||
.provider("LOCAL")
|
||||
.role("USER")
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
memberMapper.insert(member);
|
||||
log.info("[signup] new member: {}", member.getLoginId());
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
public LoginResponse login(LoginRequest req) {
|
||||
Member member = memberMapper.findByLoginId(req.getLoginId());
|
||||
if (member == null
|
||||
|| member.getPassword() == null
|
||||
|| !passwordEncoder.matches(req.getPassword(), member.getPassword())) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
if (!"ACTIVE".equals(member.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
redisTemplate.opsForValue().set(SESSION_PREFIX + token, SessionUser.from(member), SESSION_TTL);
|
||||
log.info("[login] {} (token issued)", member.getLoginId());
|
||||
|
||||
return LoginResponse.builder()
|
||||
.token(token)
|
||||
.expiresInSeconds(SESSION_TTL.getSeconds())
|
||||
.member(MemberResponse.from(member))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||
*/
|
||||
public SessionUser getSession(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String key = SESSION_PREFIX + token;
|
||||
Object cached = redisTemplate.opsForValue().get(key);
|
||||
if (cached instanceof SessionUser user) {
|
||||
redisTemplate.expire(key, SESSION_TTL);
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void logout(String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
redisTemplate.delete(SESSION_PREFIX + token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sb.web.auth.web;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 관리자 전용 경로 보호. AuthInterceptor 가 먼저 실행되어 세팅한 SessionUser 의 role 을 검사한다.
|
||||
*/
|
||||
@Component
|
||||
public class AdminInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
Object attr = request.getAttribute(AuthInterceptor.CURRENT_MEMBER);
|
||||
if (attr instanceof SessionUser user && "ADMIN".equals(user.getRole())) {
|
||||
return true;
|
||||
}
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.getWriter().write("{\"status\":403,\"message\":\"관리자 권한이 필요합니다.\"}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.sb.web.auth.web;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.service.AuthService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 보호 경로 접근 시 Authorization: Bearer {token} 을 검증한다.
|
||||
* 유효한 세션이면 SessionUser 를 요청 속성으로 전달, 아니면 401 JSON 응답.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
public static final String CURRENT_MEMBER = "currentMember";
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
// CORS preflight 는 통과
|
||||
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
SessionUser user = authService.getSession(resolveToken(request));
|
||||
if (user == null) {
|
||||
writeUnauthorized(response);
|
||||
return false;
|
||||
}
|
||||
request.setAttribute(CURRENT_MEMBER, user);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static String resolveToken(HttpServletRequest request) {
|
||||
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void writeUnauthorized(HttpServletResponse response) throws Exception {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.getWriter().write("{\"status\":401,\"message\":\"로그인이 필요합니다.\"}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.sb.web.board.controller;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import com.sb.web.board.dto.CommentRequest;
|
||||
import com.sb.web.board.dto.CommentResponse;
|
||||
import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.service.BoardService;
|
||||
import com.sb.web.board.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 자유게시판 API. (전체 로그인 필요 — WebConfig 인터셉터로 /api/board/** 보호)
|
||||
* GET /api/board/posts 목록(페이징·태그·검색)
|
||||
* GET /api/board/posts/{id} 상세(조회수 증가)
|
||||
* POST /api/board/posts 작성
|
||||
* PUT /api/board/posts/{id} 수정(작성자/관리자)
|
||||
* DELETE /api/board/posts/{id} 삭제(작성자/관리자)
|
||||
* GET /api/board/tags 전체 태그
|
||||
* POST /api/board/posts/{id}/comments 댓글 작성
|
||||
* DELETE /api/board/comments/{commentId} 댓글 삭제(작성자/관리자)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/board")
|
||||
@RequiredArgsConstructor
|
||||
public class BoardController {
|
||||
|
||||
private final BoardService boardService;
|
||||
private final TagService tagService;
|
||||
|
||||
@GetMapping("/posts")
|
||||
public PageResponse<PostSummary> list(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String searchType) {
|
||||
return boardService.list(page, size, tag, keyword, searchType);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{id}")
|
||||
public PostDetail get(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.get(id, current);
|
||||
}
|
||||
|
||||
@PostMapping("/posts")
|
||||
public ResponseEntity<PostDetail> create(
|
||||
@Valid @RequestBody PostRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(boardService.create(req, current));
|
||||
}
|
||||
|
||||
@PutMapping("/posts/{id}")
|
||||
public PostDetail update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody PostRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.update(id, req, current);
|
||||
}
|
||||
|
||||
@DeleteMapping("/posts/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.delete(id, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 열람 제한 (관리자) */
|
||||
@PostMapping("/posts/{id}/block")
|
||||
public ResponseEntity<Void> block(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.setBlocked(id, true, body != null ? body.get("reason") : null, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 열람 제한 해제 (관리자) */
|
||||
@PostMapping("/posts/{id}/unblock")
|
||||
public ResponseEntity<Void> unblock(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.setBlocked(id, false, null, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/tags")
|
||||
public List<String> tags() {
|
||||
return boardService.allTags();
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */
|
||||
@GetMapping("/tag-groups")
|
||||
public List<TagCategoryResponse> tagGroups() {
|
||||
return tagService.listWritableGroups();
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{id}/comments")
|
||||
public ResponseEntity<CommentResponse> addComment(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody CommentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(boardService.addComment(id, req, current));
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public ResponseEntity<Void> deleteComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.deleteComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 댓글 도메인. comment 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Comment implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long postId;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 게시글 도메인. post 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Post implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 태그 도메인. tag 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Tag implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long categoryId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 태그 카테고리(그룹). tag_category 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TagCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음).
|
||||
*/
|
||||
@Data
|
||||
public class BoardSettingRequest {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BoardSettingResponse {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 댓글 작성 요청.
|
||||
*/
|
||||
@Data
|
||||
public class CommentRequest {
|
||||
|
||||
@NotBlank(message = "댓글 내용을 입력하세요.")
|
||||
@Size(max = 1000, message = "댓글은 1000자 이내여야 합니다.")
|
||||
private String content;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 댓글 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CommentResponse {
|
||||
|
||||
private Long id;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static CommentResponse from(Comment c) {
|
||||
return CommentResponse.builder()
|
||||
.id(c.getId())
|
||||
.authorId(c.getAuthorId())
|
||||
.authorName(c.getAuthorName())
|
||||
.content(c.getContent())
|
||||
.createdAt(c.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 페이지네이션 응답 래퍼.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PageResponse<T> {
|
||||
|
||||
private List<T> content;
|
||||
private int page; // 1-base
|
||||
private int size;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
|
||||
public static <T> PageResponse<T> of(List<T> content, int page, int size, long total) {
|
||||
int totalPages = size > 0 ? (int) Math.ceil((double) total / size) : 0;
|
||||
return PageResponse.<T>builder()
|
||||
.content(content)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalElements(total)
|
||||
.totalPages(totalPages)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Post;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 상세 (태그 + 댓글 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PostDetail {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private List<String> tags;
|
||||
private List<CommentResponse> comments;
|
||||
|
||||
public static PostDetail of(Post p, List<String> tags, List<CommentResponse> comments) {
|
||||
return PostDetail.builder()
|
||||
.id(p.getId())
|
||||
.title(p.getTitle())
|
||||
.content(p.getContent())
|
||||
.authorId(p.getAuthorId())
|
||||
.authorName(p.getAuthorName())
|
||||
.viewCount(p.getViewCount())
|
||||
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
||||
.blockReason(p.getBlockReason())
|
||||
.createdAt(p.getCreatedAt())
|
||||
.updatedAt(p.getUpdatedAt())
|
||||
.tags(tags)
|
||||
.comments(comments)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 비관리자에게 보여줄 열람 제한 상세 (본문·댓글·태그 숨김) */
|
||||
public static PostDetail restricted(Post p) {
|
||||
return PostDetail.builder()
|
||||
.id(p.getId())
|
||||
.title(p.getTitle())
|
||||
.authorName(p.getAuthorName())
|
||||
.viewCount(p.getViewCount())
|
||||
.blocked(true)
|
||||
.createdAt(p.getCreatedAt())
|
||||
.tags(List.of())
|
||||
.comments(List.of())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 작성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class PostRequest {
|
||||
|
||||
@NotBlank(message = "제목을 입력하세요.")
|
||||
@Size(max = 200, message = "제목은 200자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "내용을 입력하세요.")
|
||||
private String content;
|
||||
|
||||
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 목록 항목 (요약).
|
||||
*/
|
||||
@Data
|
||||
public class PostSummary {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Integer commentCount;
|
||||
private Boolean blocked;
|
||||
private LocalDateTime createdAt;
|
||||
private List<String> tags;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class TagCategoryRequest {
|
||||
|
||||
@NotBlank(message = "카테고리 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "카테고리 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 응답 (소속 태그 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TagCategoryResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private List<TagResponse> tags;
|
||||
|
||||
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags) {
|
||||
return TagCategoryResponse.builder()
|
||||
.id(c.getId())
|
||||
.name(c.getName())
|
||||
.sortOrder(c.getSortOrder())
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 생성/수정 요청 (관리자).
|
||||
*/
|
||||
@Data
|
||||
public class TagRequest {
|
||||
|
||||
@NotBlank(message = "태그 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "카테고리를 선택하세요.")
|
||||
private Long categoryId;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TagResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long categoryId;
|
||||
|
||||
public static TagResponse from(Tag t) {
|
||||
return TagResponse.builder()
|
||||
.id(t.getId())
|
||||
.name(t.getName())
|
||||
.categoryId(t.getCategoryId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface BoardSettingMapper {
|
||||
|
||||
/** 게시판에서 사용할 태그 카테고리 ID (없으면 null) */
|
||||
Long findTagCategoryId();
|
||||
|
||||
int updateTagCategoryId(@Param("categoryId") Long categoryId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CommentMapper {
|
||||
|
||||
List<Comment> findByPostId(@Param("postId") Long postId);
|
||||
|
||||
Comment findById(@Param("id") Long id);
|
||||
|
||||
int insert(Comment comment);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deleteByPostId(@Param("postId") Long postId);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Post;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PostMapper {
|
||||
|
||||
List<PostSummary> findPage(@Param("offset") int offset,
|
||||
@Param("size") int size,
|
||||
@Param("tag") String tag,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("searchType") String searchType);
|
||||
|
||||
long countPage(@Param("tag") String tag,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("searchType") String searchType);
|
||||
|
||||
Post findById(@Param("id") Long id);
|
||||
|
||||
int insert(Post post);
|
||||
|
||||
int update(Post post);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int increaseViewCount(@Param("id") Long id);
|
||||
|
||||
int updateBlocked(@Param("id") Long id,
|
||||
@Param("blocked") boolean blocked,
|
||||
@Param("blockReason") String blockReason);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TagCategoryMapper {
|
||||
|
||||
List<TagCategory> findAll();
|
||||
|
||||
TagCategory findById(@Param("id") Long id);
|
||||
|
||||
int countByName(@Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(TagCategory category);
|
||||
|
||||
int update(TagCategory category);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TagMapper {
|
||||
|
||||
/* ----- 게시글 작성/표시 ----- */
|
||||
List<String> findNamesByPostId(@Param("postId") Long postId);
|
||||
|
||||
List<String> findAllNames();
|
||||
|
||||
int insertPostTag(@Param("postId") Long postId, @Param("tagId") Long tagId);
|
||||
|
||||
int deletePostTagsByPostId(@Param("postId") Long postId);
|
||||
|
||||
/** 주어진 ID 중 실제 존재하는 태그 ID만 반환 (선택 검증용) */
|
||||
List<Long> findExistingIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/* ----- 관리자 태그 CRUD ----- */
|
||||
List<Tag> findAll();
|
||||
|
||||
List<Tag> findByCategoryId(@Param("categoryId") Long categoryId);
|
||||
|
||||
Tag findById(@Param("id") Long id);
|
||||
|
||||
int countByName(@Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(Tag tag);
|
||||
|
||||
int update(Tag tag);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deletePostTagsByTagId(@Param("tagId") Long tagId);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.sb.web.board.service;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import com.sb.web.board.domain.Post;
|
||||
import com.sb.web.board.dto.CommentRequest;
|
||||
import com.sb.web.board.dto.CommentResponse;
|
||||
import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.mapper.CommentMapper;
|
||||
import com.sb.web.board.mapper.PostMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 자유게시판 서비스. 글 CRUD, 태그(find-or-create), 조회수, 댓글, 권한 처리.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BoardService {
|
||||
|
||||
private final PostMapper postMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final CommentMapper commentMapper;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
|
||||
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||
|
||||
/* ===================== 게시글 ===================== */
|
||||
|
||||
public PageResponse<PostSummary> list(int page, int size, String tag, String keyword, String searchType) {
|
||||
int p = Math.max(page, 1);
|
||||
int s = Math.min(Math.max(size, 1), 100);
|
||||
int offset = (p - 1) * s;
|
||||
String st = normalizeSearchType(searchType);
|
||||
|
||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||
List<PostSummary> content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st);
|
||||
long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st);
|
||||
return PageResponse.of(content, p, s, total);
|
||||
}
|
||||
|
||||
private String normalizeSearchType(String t) {
|
||||
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
||||
}
|
||||
|
||||
/** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */
|
||||
@Transactional
|
||||
public PostDetail get(Long id, SessionUser viewer) {
|
||||
Post post = mustFindPost(id);
|
||||
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(viewer)) {
|
||||
return PostDetail.restricted(post); // 본문·댓글 숨김, 조회수 증가 없음
|
||||
}
|
||||
if (shouldCountView(id, viewer)) {
|
||||
postMapper.increaseViewCount(id);
|
||||
post.setViewCount(post.getViewCount() + 1);
|
||||
}
|
||||
return assemble(post);
|
||||
}
|
||||
|
||||
/** 게시글 열람 제한/해제 (관리자 전용) */
|
||||
@Transactional
|
||||
public void setBlocked(Long id, boolean blocked, String reason, SessionUser user) {
|
||||
assertAdmin(user);
|
||||
mustFindPost(id);
|
||||
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
|
||||
}
|
||||
|
||||
/** Redis 로 (사용자, 게시글) 단위 첫 열람 여부 판단 → 중복 클릭 시 조회수 증가 방지 */
|
||||
private boolean shouldCountView(Long postId, SessionUser viewer) {
|
||||
if (viewer == null) {
|
||||
return false;
|
||||
}
|
||||
String key = "board:view:" + viewer.getId() + ":" + postId;
|
||||
Boolean firstView = redisTemplate.opsForValue().setIfAbsent(key, "1", VIEW_DEDUP_TTL);
|
||||
return Boolean.TRUE.equals(firstView);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostDetail create(PostRequest req, SessionUser author) {
|
||||
Post post = Post.builder()
|
||||
.title(req.getTitle())
|
||||
.content(req.getContent())
|
||||
.authorId(author.getId())
|
||||
.authorName(author.getName())
|
||||
.build();
|
||||
postMapper.insert(post);
|
||||
applyTags(post.getId(), req.getTagIds());
|
||||
return assemble(mustFindPost(post.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostDetail update(Long id, PostRequest req, SessionUser user) {
|
||||
Post post = mustFindPost(id);
|
||||
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글은 수정할 수 없습니다.");
|
||||
}
|
||||
assertCanModify(post.getAuthorId(), user);
|
||||
|
||||
post.setTitle(req.getTitle());
|
||||
post.setContent(req.getContent());
|
||||
postMapper.update(post);
|
||||
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
applyTags(id, req.getTagIds());
|
||||
return assemble(mustFindPost(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, SessionUser user) {
|
||||
Post post = mustFindPost(id);
|
||||
assertCanModify(post.getAuthorId(), user);
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
commentMapper.deleteByPostId(id);
|
||||
postMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 댓글 ===================== */
|
||||
|
||||
@Transactional
|
||||
public CommentResponse addComment(Long postId, CommentRequest req, SessionUser author) {
|
||||
Post post = mustFindPost(postId);
|
||||
if (Boolean.TRUE.equals(post.getBlocked())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다.");
|
||||
}
|
||||
Comment comment = Comment.builder()
|
||||
.postId(postId)
|
||||
.authorId(author.getId())
|
||||
.authorName(author.getName())
|
||||
.content(req.getContent())
|
||||
.build();
|
||||
commentMapper.insert(comment);
|
||||
return CommentResponse.from(commentMapper.findById(comment.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteComment(Long commentId, SessionUser user) {
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
assertCanModify(comment.getAuthorId(), user);
|
||||
commentMapper.delete(commentId);
|
||||
}
|
||||
|
||||
/* ===================== 태그 ===================== */
|
||||
|
||||
public List<String> allTags() {
|
||||
return tagMapper.findAllNames();
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private PostDetail assemble(Post post) {
|
||||
List<String> tags = tagMapper.findNamesByPostId(post.getId());
|
||||
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
|
||||
.stream().map(CommentResponse::from).toList();
|
||||
return PostDetail.of(post, tags, comments);
|
||||
}
|
||||
|
||||
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
|
||||
private void applyTags(Long postId, List<Long> tagIds) {
|
||||
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(distinct)) {
|
||||
tagMapper.insertPostTag(postId, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
private Post mustFindPost(Long id) {
|
||||
Post post = postMapper.findById(id);
|
||||
if (post == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.");
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
/** 작성자 본인 또는 관리자만 수정/삭제 가능 */
|
||||
private void assertCanModify(Long authorId, SessionUser user) {
|
||||
if (!authorId.equals(user.getId()) && !isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAdmin(SessionUser user) {
|
||||
return user != null && "ADMIN".equals(user.getRole());
|
||||
}
|
||||
|
||||
private void assertAdmin(SessionUser user) {
|
||||
if (!isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.sb.web.board.service;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import com.sb.web.board.dto.TagCategoryRequest;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.BoardSettingResponse;
|
||||
import com.sb.web.board.dto.TagRequest;
|
||||
import com.sb.web.board.dto.TagResponse;
|
||||
import com.sb.web.board.mapper.BoardSettingMapper;
|
||||
import com.sb.web.board.mapper.TagCategoryMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TagService {
|
||||
|
||||
private final TagCategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final BoardSettingMapper boardSettingMapper;
|
||||
|
||||
/** 카테고리별로 묶은 전체 태그 (관리 화면용) */
|
||||
public List<TagCategoryResponse> listGrouped() {
|
||||
List<TagCategoryResponse> result = new ArrayList<>();
|
||||
for (TagCategory c : categoryMapper.findAll()) {
|
||||
result.add(toGroup(c));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 그룹 — 게시판 설정 카테고리로 제한 (없으면 전체) */
|
||||
public List<TagCategoryResponse> listWritableGroups() {
|
||||
Long catId = boardSettingMapper.findTagCategoryId();
|
||||
if (catId == null) {
|
||||
return listGrouped();
|
||||
}
|
||||
TagCategory c = categoryMapper.findById(catId);
|
||||
return c == null ? listGrouped() : List.of(toGroup(c));
|
||||
}
|
||||
|
||||
private TagCategoryResponse toGroup(TagCategory c) {
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
|
||||
.stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
}
|
||||
|
||||
/* ===================== 게시판 설정 ===================== */
|
||||
|
||||
public BoardSettingResponse getBoardSetting() {
|
||||
return BoardSettingResponse.builder()
|
||||
.tagCategoryId(boardSettingMapper.findTagCategoryId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BoardSettingResponse setBoardSetting(Long tagCategoryId) {
|
||||
if (tagCategoryId != null && categoryMapper.findById(tagCategoryId) == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
boardSettingMapper.updateTagCategoryId(tagCategoryId);
|
||||
return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build();
|
||||
}
|
||||
|
||||
/* ===================== 카테고리 ===================== */
|
||||
|
||||
@Transactional
|
||||
public TagCategoryResponse createCategory(TagCategoryRequest req) {
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다.");
|
||||
}
|
||||
TagCategory c = TagCategory.builder()
|
||||
.name(name)
|
||||
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
|
||||
.build();
|
||||
categoryMapper.insert(c);
|
||||
return TagCategoryResponse.of(c, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TagCategoryResponse updateCategory(Long id, TagCategoryRequest req) {
|
||||
TagCategory c = mustFindCategory(id);
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(name, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다.");
|
||||
}
|
||||
c.setName(name);
|
||||
if (req.getSortOrder() != null) {
|
||||
c.setSortOrder(req.getSortOrder());
|
||||
}
|
||||
categoryMapper.update(c);
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
}
|
||||
|
||||
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)까지 함께 삭제 */
|
||||
@Transactional
|
||||
public void deleteCategory(Long id) {
|
||||
mustFindCategory(id);
|
||||
for (Tag t : tagMapper.findByCategoryId(id)) {
|
||||
tagMapper.deletePostTagsByTagId(t.getId());
|
||||
tagMapper.delete(t.getId());
|
||||
}
|
||||
categoryMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 태그 ===================== */
|
||||
|
||||
@Transactional
|
||||
public TagResponse createTag(TagRequest req) {
|
||||
mustFindCategory(req.getCategoryId());
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
Tag t = Tag.builder().name(name).categoryId(req.getCategoryId()).build();
|
||||
tagMapper.insert(t);
|
||||
return TagResponse.from(t);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TagResponse updateTag(Long id, TagRequest req) {
|
||||
Tag t = mustFindTag(id);
|
||||
mustFindCategory(req.getCategoryId());
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(name, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
t.setName(name);
|
||||
t.setCategoryId(req.getCategoryId());
|
||||
tagMapper.update(t);
|
||||
return TagResponse.from(t);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTag(Long id) {
|
||||
mustFindTag(id);
|
||||
tagMapper.deletePostTagsByTagId(id);
|
||||
tagMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private TagCategory mustFindCategory(Long id) {
|
||||
TagCategory c = categoryMapper.findById(id);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private Tag mustFindTag(Long id) {
|
||||
Tag t = tagMapper.findById(id);
|
||||
if (t == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다.");
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.example.sb_bt.config;
|
||||
package com.sb.web.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 비밀번호 해싱용 BCrypt 인코더.
|
||||
* (전체 Spring Security 필터체인은 도입하지 않고 crypto 모듈만 사용)
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -1,6 +1,10 @@
|
||||
package com.example.sb_bt.config;
|
||||
package com.sb.web.common.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
@@ -11,7 +15,10 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
/**
|
||||
* RedisTemplate 구성.
|
||||
* - Key : String 직렬화
|
||||
* - Value : JSON 직렬화 (객체 저장 가능)
|
||||
* - Value : JSON 직렬화 (타입 정보 포함 → 저장한 구체 타입 그대로 복원)
|
||||
*
|
||||
* 값 직렬화에 @class 타입 정보를 포함해야 역직렬화 시 LinkedHashMap 이 아니라
|
||||
* 원래 클래스(SessionUser, User 등)로 복원된다.
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
@@ -21,9 +28,17 @@ public class RedisConfig {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new JavaTimeModule()); // LocalDateTime 등 java.time 지원
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
mapper.activateDefaultTyping(
|
||||
BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class).build(),
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY);
|
||||
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
GenericJackson2JsonRedisSerializer jsonSerializer =
|
||||
new GenericJackson2JsonRedisSerializer(new ObjectMapper());
|
||||
new GenericJackson2JsonRedisSerializer(mapper);
|
||||
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.common.config;
|
||||
|
||||
import com.sb.web.auth.web.AdminInterceptor;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 인증 인터셉터 등록.
|
||||
* 보호가 필요한 엔드포인트에만 적용한다. (로그인/회원가입은 비보호)
|
||||
* 보호 대상이 늘어나면 addPathPatterns 에 추가.
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
private final AdminInterceptor adminInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/board/**", "/api/admin/**", "/api/account/**");
|
||||
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
.addPathPatterns("/api/admin/**");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.example.sb_bt.controller;
|
||||
package com.sb.web.common.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sb.web.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 비즈니스 예외. HTTP 상태와 사용자 메시지를 함께 담는다.
|
||||
*/
|
||||
@Getter
|
||||
public class ApiException extends RuntimeException {
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
public ApiException(HttpStatus status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.sb.web.common.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 공통 예외 처리. 모든 오류를 일관된 JSON 형태로 응답한다.
|
||||
* { "status": <int>, "message": "<설명>" }
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleApi(ApiException e) {
|
||||
return ResponseEntity.status(e.getStatus()).body(body(e.getStatus(), e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(fe -> fe.getDefaultMessage())
|
||||
.findFirst()
|
||||
.orElse("입력값이 올바르지 않습니다.");
|
||||
return ResponseEntity.badRequest().body(body(HttpStatus.BAD_REQUEST, message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception e) {
|
||||
log.error("[unhandled] {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(body(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
private Map<String, Object> body(HttpStatus status, String message) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("status", status.value());
|
||||
m.put("message", message);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package com.example.sb_bt.controller;
|
||||
package com.sb.web.user.controller;
|
||||
|
||||
import com.example.sb_bt.domain.User;
|
||||
import com.example.sb_bt.service.UserService;
|
||||
import com.sb.web.user.domain.User;
|
||||
import com.sb.web.user.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.example.sb_bt.domain;
|
||||
package com.sb.web.user.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
package com.example.sb_bt.mapper;
|
||||
package com.sb.web.user.mapper;
|
||||
|
||||
import com.example.sb_bt.domain.User;
|
||||
import com.sb.web.user.domain.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package com.example.sb_bt.service;
|
||||
package com.sb.web.user.service;
|
||||
|
||||
import com.example.sb_bt.domain.User;
|
||||
import com.example.sb_bt.mapper.UserMapper;
|
||||
import com.sb.web.user.domain.User;
|
||||
import com.sb.web.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
Reference in New Issue
Block a user