Compare commits
42 Commits
8bfe596f4e
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 947c7a1f68 | |||
| 24e235404b | |||
| 10f51976d9 | |||
| b969ba6104 | |||
| ddcd6f4335 | |||
| ba75613e1c | |||
| a47b8405f4 | |||
| 8142b8b518 | |||
| be15f5b85d | |||
| a4b6c3fd2d | |||
| d5366e8e72 | |||
| 1e93280140 | |||
| d1c13e7cc1 | |||
| 629ab1f811 | |||
| b94f162f7e | |||
| caaad65ca2 | |||
| b4344f5270 | |||
| ef9dace1df | |||
| 98f5f51112 | |||
| 6256d3e63d | |||
| 93e06c9475 | |||
| f130d53f62 | |||
| 3b46d2ba09 | |||
| bf6fb97ed8 | |||
| 8d8d26ec3a | |||
| b48d8c667c | |||
| 09efd65fa9 | |||
| a1697a7a22 | |||
| 63b3ffea24 | |||
| 5f21cc4474 | |||
| 3fc233d697 | |||
| 7de643022b | |||
| 6eef6cbd29 | |||
| 553855b31c | |||
| a0fc546ad8 | |||
| 120e042971 | |||
| 31590343cf | |||
| 55c47a43e6 | |||
| f9abd0c989 | |||
| 2da43b2f77 | |||
| eded44a476 | |||
| a9ff2387c8 |
@@ -1,8 +1,9 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# main 은 Deploy(clean build=테스트 포함)가 게이트 역할을 하므로 CI 중복 실행 제외(배포 시간 단축).
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ HELP.md
|
|||||||
.gradle
|
.gradle
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지)
|
||||||
|
scripts/seed-test-*.sql
|
||||||
|
scripts/cleanup-test-*.sql
|
||||||
|
|
||||||
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
||||||
.env
|
.env
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"})
|
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper", "com.sb.web.billing.mapper"})
|
||||||
public class SbBtApplication {
|
public class SbBtApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.sb.web.account.controller;
|
|||||||
import com.sb.web.account.dto.AccountEntryRequest;
|
import com.sb.web.account.dto.AccountEntryRequest;
|
||||||
import com.sb.web.account.dto.AccountEntryResponse;
|
import com.sb.web.account.dto.AccountEntryResponse;
|
||||||
import com.sb.web.account.dto.AccountSummary;
|
import com.sb.web.account.dto.AccountSummary;
|
||||||
|
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||||
import com.sb.web.account.dto.AccountTagRequest;
|
import com.sb.web.account.dto.AccountTagRequest;
|
||||||
import com.sb.web.account.dto.AccountTagResponse;
|
import com.sb.web.account.dto.AccountTagResponse;
|
||||||
import com.sb.web.account.dto.CategoryStat;
|
import com.sb.web.account.dto.CategoryStat;
|
||||||
@@ -167,11 +168,16 @@ public class AccountController {
|
|||||||
return accountService.categoryStats(current.getId(), type, year, month);
|
return accountService.categoryStats(current.getId(), type, year, month);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 월별 순자산 추이 */
|
/** 순자산 추이 (unit=WEEK 면 주별, weeks 개수 / 그 외 월별, months 개수) */
|
||||||
@GetMapping("/networth/trend")
|
@GetMapping("/networth/trend")
|
||||||
public List<NetWorthPoint> netWorthTrend(
|
public List<NetWorthPoint> netWorthTrend(
|
||||||
@RequestParam(required = false) Integer months,
|
@RequestParam(required = false) Integer months,
|
||||||
|
@RequestParam(required = false) Integer weeks,
|
||||||
|
@RequestParam(defaultValue = "MONTH") String unit,
|
||||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
if ("WEEK".equalsIgnoreCase(unit)) {
|
||||||
|
return accountService.netWorthTrendWeekly(current.getId(), weeks);
|
||||||
|
}
|
||||||
return accountService.netWorthTrend(current.getId(), months);
|
return accountService.netWorthTrend(current.getId(), months);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +242,14 @@ public class AccountController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움. */
|
||||||
|
@PostMapping("/entries/parse")
|
||||||
|
public ParsedEntryResponse parseText(
|
||||||
|
@RequestBody NotificationRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return accountService.parseText(req);
|
||||||
|
}
|
||||||
|
|
||||||
/** 미확인 내역 확정 (분류/계좌 보정) */
|
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||||
@PostMapping("/entries/{id}/confirm")
|
@PostMapping("/entries/{id}/confirm")
|
||||||
public AccountEntryResponse confirm(
|
public AccountEntryResponse confirm(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.sb.web.account.controller;
|
||||||
|
|
||||||
|
import com.sb.web.account.dto.RestoreRequest;
|
||||||
|
import com.sb.web.account.service.BackupService;
|
||||||
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 가져오기(복구) API. (로그인 필요 — /api/account/** 보호)
|
||||||
|
* POST /api/account/restore 엑셀 백업으로 전체 데이터 복구(기존 데이터 덮어씀)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/account")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BackupController {
|
||||||
|
|
||||||
|
private final BackupService backupService;
|
||||||
|
|
||||||
|
@PostMapping("/restore")
|
||||||
|
public ResponseEntity<Void> restore(
|
||||||
|
@RequestBody RestoreRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
backupService.restore(current.getId(), req);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,8 +50,22 @@ public class BudgetController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<BudgetResponse> list(
|
public List<BudgetResponse> list(
|
||||||
|
@RequestParam int year,
|
||||||
|
@RequestParam int month,
|
||||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
return budgetService.list(current.getId());
|
return budgetService.list(current.getId(), year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다른 월의 예산을 이 달로 복사(전월 복사 등). 같은 분류는 덮어씀 */
|
||||||
|
@PostMapping("/copy")
|
||||||
|
public List<BudgetResponse> copy(
|
||||||
|
@RequestParam int fromYear,
|
||||||
|
@RequestParam int fromMonth,
|
||||||
|
@RequestParam int toYear,
|
||||||
|
@RequestParam int toMonth,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
budgetService.copy(current.getId(), fromYear, fromMonth, toYear, toMonth);
|
||||||
|
return budgetService.list(current.getId(), toYear, toMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@@ -74,9 +88,11 @@ public class BudgetController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<BudgetResponse> create(
|
public ResponseEntity<BudgetResponse> create(
|
||||||
|
@RequestParam int year,
|
||||||
|
@RequestParam int month,
|
||||||
@Valid @RequestBody BudgetRequest req,
|
@Valid @RequestBody BudgetRequest req,
|
||||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId()));
|
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId(), year, month));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ public class CategoryController {
|
|||||||
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
|
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 기존 내역의 분류를 목록으로 가져오기 */
|
/** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
public List<CategoryResponse> importFromEntries(
|
public List<CategoryResponse> importDefaults(
|
||||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
return categoryService.importFromEntries(current.getId());
|
return categoryService.importDefaults(current.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.sb.web.account.controller;
|
||||||
|
|
||||||
|
import com.sb.web.account.service.FxService;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환율 조회 API. 외화 결제 입력 시 통화→원화 환율 자동 채움용.
|
||||||
|
* GET /api/fx/rate?from=USD&to=KRW → { from, to, rate }
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/fx")
|
||||||
|
public class FxController {
|
||||||
|
|
||||||
|
private final FxService fxService;
|
||||||
|
|
||||||
|
public FxController(FxService fxService) {
|
||||||
|
this.fxService = fxService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rate")
|
||||||
|
public Map<String, Object> rate(@RequestParam String from,
|
||||||
|
@RequestParam(defaultValue = "KRW") String to) {
|
||||||
|
BigDecimal rate = fxService.getRate(from, to);
|
||||||
|
Map<String, Object> res = new HashMap<>();
|
||||||
|
res.put("from", from == null ? null : from.toUpperCase());
|
||||||
|
res.put("to", to == null ? null : to.toUpperCase());
|
||||||
|
res.put("rate", rate); // 조회 실패 시 null
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import java.util.List;
|
|||||||
* DELETE /api/account/invest/holdings/{id} 종목 삭제(매매이력 포함)
|
* DELETE /api/account/invest/holdings/{id} 종목 삭제(매매이력 포함)
|
||||||
* GET /api/account/invest/holdings/{id}/trades 매매이력
|
* GET /api/account/invest/holdings/{id}/trades 매매이력
|
||||||
* POST /api/account/invest/holdings/{id}/trades 매수/매도
|
* POST /api/account/invest/holdings/{id}/trades 매수/매도
|
||||||
|
* PUT /api/account/invest/trades/{id} 매매 수정
|
||||||
* DELETE /api/account/invest/trades/{id} 매매 삭제
|
* DELETE /api/account/invest/trades/{id} 매매 삭제
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -93,6 +94,14 @@ public class InvestController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.addTrade(id, req, current.getId()));
|
return ResponseEntity.status(HttpStatus.CREATED).body(investService.addTrade(id, req, current.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/trades/{id}")
|
||||||
|
public TradeResponse updateTrade(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody TradeRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return investService.updateTrade(id, req, current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/trades/{id}")
|
@DeleteMapping("/trades/{id}")
|
||||||
public ResponseEntity<Void> deleteTrade(
|
public ResponseEntity<Void> deleteTrade(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.sb.web.account.controller;
|
||||||
|
|
||||||
|
import com.sb.web.account.dto.QuickEntryRequest;
|
||||||
|
import com.sb.web.account.dto.QuickEntryResponse;
|
||||||
|
import com.sb.web.account.service.QuickEntryService;
|
||||||
|
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/quick-entries — 로그인 필요, 본인 데이터만)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/account/quick-entries")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QuickEntryController {
|
||||||
|
|
||||||
|
private final QuickEntryService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<QuickEntryResponse> list(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return service.list(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<QuickEntryResponse> create(
|
||||||
|
@Valid @RequestBody QuickEntryRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req, current.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
service.delete(id, current.getId());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public class AccountCategory implements Serializable {
|
|||||||
private Long memberId;
|
private Long memberId;
|
||||||
private String type; // INCOME / EXPENSE
|
private String type; // INCOME / EXPENSE
|
||||||
private String name;
|
private String name;
|
||||||
|
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ public class AccountEntry implements Serializable {
|
|||||||
private Long toWalletId; // 이체 입금 계좌
|
private Long toWalletId; // 이체 입금 계좌
|
||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||||
|
private String currency; // 외화 통화코드(USD 등). null/KRW = 원화
|
||||||
|
private BigDecimal originalAmount; // 외화 원본 금액
|
||||||
|
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||||
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||||
private String notifKey; // 알림 자동인식 중복방지 키
|
private String notifKey; // 알림 자동인식 중복방지 키
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public class Budget implements Serializable {
|
|||||||
private Long weekly;
|
private Long weekly;
|
||||||
private Long monthly;
|
private Long monthly;
|
||||||
private Long yearly;
|
private Long yearly;
|
||||||
|
private Integer year; // 예산 연도(월별 예산)
|
||||||
|
private Integer month; // 예산 월(1~12)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자는 자기 분류로 복사해 사용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class DefaultCategory implements Serializable {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String type; // INCOME / EXPENSE
|
||||||
|
private String name;
|
||||||
|
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||||
|
private Integer sortOrder;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class QuickEntry implements Serializable {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long memberId;
|
||||||
|
private String label;
|
||||||
|
private String type; // INCOME / EXPENSE
|
||||||
|
private String category;
|
||||||
|
private Long amount;
|
||||||
|
private String memo;
|
||||||
|
private Long walletId;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String walletName; // 조회 시 조인(표시용)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ public class Wallet implements Serializable {
|
|||||||
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
|
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
|
||||||
private LocalDate openingDate;
|
private LocalDate openingDate;
|
||||||
private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신)
|
private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신)
|
||||||
|
private Long loanAmount; // 대출 실행 금액(원금, 처음 빌린 금액) (LOAN 전용)
|
||||||
|
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25 (LOAN 전용)
|
||||||
|
private String loanMethod; // 상환방식: EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET (LOAN 전용)
|
||||||
|
private Integer loanMonths; // 대출기간(개월) (LOAN 전용)
|
||||||
|
private LocalDate loanStart; // 대출시작일 (LOAN 전용)
|
||||||
private Integer sortOrder; // 표시 순서 (타입 내 정렬)
|
private Integer sortOrder; // 표시 순서 (타입 내 정렬)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.PositiveOrZero;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -48,4 +49,10 @@ public class AccountEntryRequest {
|
|||||||
|
|
||||||
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
||||||
private List<Long> tagIds;
|
private List<Long> tagIds;
|
||||||
|
|
||||||
|
/** 외화 결제(선택). currency 가 있으면 amount 는 환산된 원화여야 한다. */
|
||||||
|
@Size(max = 3, message = "통화코드가 올바르지 않습니다.")
|
||||||
|
private String currency; // USD/JPY 등. null/KRW = 원화
|
||||||
|
private BigDecimal originalAmount; // 외화 원본 금액
|
||||||
|
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.sb.web.account.domain.AccountEntry;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ public class AccountEntryResponse {
|
|||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths;
|
private Integer installmentMonths;
|
||||||
private Boolean pending;
|
private Boolean pending;
|
||||||
|
private String currency;
|
||||||
|
private BigDecimal originalAmount;
|
||||||
|
private BigDecimal exchangeRate;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
|
|
||||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||||
@@ -42,6 +46,9 @@ public class AccountEntryResponse {
|
|||||||
.toWalletName(e.getToWalletName())
|
.toWalletName(e.getToWalletName())
|
||||||
.installmentMonths(e.getInstallmentMonths())
|
.installmentMonths(e.getInstallmentMonths())
|
||||||
.pending(Boolean.TRUE.equals(e.getPending()))
|
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||||
|
.currency(e.getCurrency())
|
||||||
|
.originalAmount(e.getOriginalAmount())
|
||||||
|
.exchangeRate(e.getExchangeRate())
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public class CategoryRequest {
|
|||||||
@NotBlank(message = "분류 이름을 입력하세요.")
|
@NotBlank(message = "분류 이름을 입력하세요.")
|
||||||
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
/** 대분류 id. null=대분류로 생성, 값=해당 대분류의 소분류로 생성/이동 */
|
||||||
|
private Long parentId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ public class CategoryResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String type;
|
private String type;
|
||||||
private String name;
|
private String name;
|
||||||
|
private Long parentId; // 대분류 id (null=대분류)
|
||||||
|
|
||||||
public static CategoryResponse from(AccountCategory c) {
|
public static CategoryResponse from(AccountCategory c) {
|
||||||
return CategoryResponse.builder().id(c.getId()).type(c.getType()).name(c.getName()).build();
|
return CategoryResponse.builder()
|
||||||
|
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자/푸시 텍스트 파싱 결과 (내역 추가 폼 자동 채움용). 저장하지 않음.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class ParsedEntryResponse {
|
||||||
|
|
||||||
|
private boolean recognized; // 거래로 인식되었는가
|
||||||
|
private String type; // INCOME / EXPENSE
|
||||||
|
private long amount;
|
||||||
|
private String merchant; // 가맹점/적요(메모 후보)
|
||||||
|
private LocalDate date;
|
||||||
|
}
|
||||||
@@ -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.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자주 쓰는 내역 생성 요청.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class QuickEntryRequest {
|
||||||
|
|
||||||
|
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
@Pattern(regexp = "INCOME|EXPENSE", message = "구분이 올바르지 않습니다.")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Size(max = 50)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@NotNull(message = "금액을 입력하세요.")
|
||||||
|
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||||
|
private Long amount;
|
||||||
|
|
||||||
|
@Size(max = 255)
|
||||||
|
private String memo;
|
||||||
|
|
||||||
|
private Long walletId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.QuickEntry;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자주 쓰는 내역 응답.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class QuickEntryResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String label;
|
||||||
|
private String type;
|
||||||
|
private String category;
|
||||||
|
private Long amount;
|
||||||
|
private String memo;
|
||||||
|
private Long walletId;
|
||||||
|
private String walletName;
|
||||||
|
|
||||||
|
public static QuickEntryResponse from(QuickEntry q) {
|
||||||
|
return QuickEntryResponse.builder()
|
||||||
|
.id(q.getId()).label(q.getLabel()).type(q.getType()).category(q.getCategory())
|
||||||
|
.amount(q.getAmount()).memo(q.getMemo()).walletId(q.getWalletId())
|
||||||
|
.walletName(q.getWalletName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import lombok.Data;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대출/카드 상환·납부 요청. 원금은 이체, 이자는 지출로 분리 생성된다.
|
* 대출/카드 상환·납부 요청. 원금은 이체, 이자·연회비는 지출로 분리 생성된다.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class RepaymentRequest {
|
public class RepaymentRequest {
|
||||||
@@ -28,6 +28,9 @@ public class RepaymentRequest {
|
|||||||
@PositiveOrZero(message = "이자는 0 이상이어야 합니다.")
|
@PositiveOrZero(message = "이자는 0 이상이어야 합니다.")
|
||||||
private Long interest;
|
private Long interest;
|
||||||
|
|
||||||
|
@PositiveOrZero(message = "연회비는 0 이상이어야 합니다.")
|
||||||
|
private Long annualFee;
|
||||||
|
|
||||||
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
||||||
private String memo;
|
private String memo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 복구(가져오기) 요청 — 엑셀 백업을 파싱한 결과.
|
||||||
|
* 계좌는 원본 ID(oldId) 기준으로 매핑(같은 이름 계좌가 있어도 정확). 분류·태그는 이름 기준(유니크).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RestoreRequest {
|
||||||
|
|
||||||
|
private List<Wal> wallets;
|
||||||
|
private List<Cat> categories;
|
||||||
|
private List<String> tags;
|
||||||
|
private List<Entry> entries;
|
||||||
|
private List<Recur> recurrings;
|
||||||
|
private List<BudgetRequest> budgets;
|
||||||
|
private List<Quick> quickEntries;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Wal {
|
||||||
|
private Long oldId; // 원본 계좌 id (매핑 키)
|
||||||
|
private String type;
|
||||||
|
private String name;
|
||||||
|
private String issuer;
|
||||||
|
private String accountNumber;
|
||||||
|
private String cardType;
|
||||||
|
private Long openingBalance;
|
||||||
|
private LocalDate openingDate;
|
||||||
|
private Long currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Cat {
|
||||||
|
private String type;
|
||||||
|
private String name;
|
||||||
|
private String parent; // 대분류명 (없으면 대분류)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Entry {
|
||||||
|
private LocalDate entryDate;
|
||||||
|
private String type;
|
||||||
|
private String category;
|
||||||
|
private Long amount;
|
||||||
|
private String memo;
|
||||||
|
private Long walletId; // 원본 계좌 id
|
||||||
|
private Long toWalletId; // 원본 입금 계좌 id(이체)
|
||||||
|
private Integer installmentMonths;
|
||||||
|
private List<String> tags; // 태그명
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Recur {
|
||||||
|
private String title;
|
||||||
|
private String type;
|
||||||
|
private Long amount;
|
||||||
|
private String category;
|
||||||
|
private String memo;
|
||||||
|
private Long walletId;
|
||||||
|
private Long toWalletId;
|
||||||
|
private String frequency;
|
||||||
|
private Integer dayOfMonth;
|
||||||
|
private Integer dayOfWeek;
|
||||||
|
private Integer monthOfYear;
|
||||||
|
private LocalDate startDate;
|
||||||
|
private LocalDate endDate;
|
||||||
|
private Boolean active;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Quick {
|
||||||
|
private String label;
|
||||||
|
private String type;
|
||||||
|
private String category;
|
||||||
|
private Long amount;
|
||||||
|
private String memo;
|
||||||
|
private Long walletId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.sb.web.account.dto;
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +17,7 @@ import java.time.LocalDate;
|
|||||||
public class WalletRequest {
|
public class WalletRequest {
|
||||||
|
|
||||||
@NotBlank(message = "유형을 선택하세요.")
|
@NotBlank(message = "유형을 선택하세요.")
|
||||||
@Pattern(regexp = "BANK|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
|
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@NotBlank(message = "이름을 입력하세요.")
|
@NotBlank(message = "이름을 입력하세요.")
|
||||||
@@ -37,4 +40,17 @@ public class WalletRequest {
|
|||||||
|
|
||||||
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
|
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
|
||||||
private Long currentValue;
|
private Long currentValue;
|
||||||
|
|
||||||
|
// ===== 대출(LOAN) 전용 =====
|
||||||
|
private Long loanAmount; // 대출 실행 금액(원금)
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", inclusive = true)
|
||||||
|
@DecimalMax(value = "100.0", message = "이자율은 100% 이하여야 합니다.")
|
||||||
|
private BigDecimal loanRate; // 연이자율(%)
|
||||||
|
|
||||||
|
@Pattern(regexp = "EQUAL_PAYMENT|EQUAL_PRINCIPAL|BULLET|", message = "상환방식이 올바르지 않습니다.")
|
||||||
|
private String loanMethod; // 상환방식
|
||||||
|
|
||||||
|
private Integer loanMonths; // 대출기간(개월)
|
||||||
|
private LocalDate loanStart; // 대출시작일
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.sb.web.account.domain.Wallet;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,13 @@ public class WalletResponse {
|
|||||||
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
|
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
|
||||||
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
|
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
|
||||||
|
|
||||||
|
// 대출(LOAN) 전용
|
||||||
|
private Long loanAmount; // 대출 실행 금액(원금)
|
||||||
|
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25
|
||||||
|
private String loanMethod; // EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET
|
||||||
|
private Integer loanMonths; // 대출기간(개월)
|
||||||
|
private LocalDate loanStart; // 대출시작일
|
||||||
|
|
||||||
public static WalletResponse from(Wallet w, long balance) {
|
public static WalletResponse from(Wallet w, long balance) {
|
||||||
return WalletResponse.builder()
|
return WalletResponse.builder()
|
||||||
.id(w.getId())
|
.id(w.getId())
|
||||||
@@ -43,6 +51,11 @@ public class WalletResponse {
|
|||||||
.openingDate(w.getOpeningDate())
|
.openingDate(w.getOpeningDate())
|
||||||
.balance(balance)
|
.balance(balance)
|
||||||
.currentValue(w.getCurrentValue())
|
.currentValue(w.getCurrentValue())
|
||||||
|
.loanAmount(w.getLoanAmount())
|
||||||
|
.loanRate(w.getLoanRate())
|
||||||
|
.loanMethod(w.getLoanMethod())
|
||||||
|
.loanMonths(w.getLoanMonths())
|
||||||
|
.loanStart(w.getLoanStart())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ public interface AccountEntryMapper {
|
|||||||
/** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */
|
/** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */
|
||||||
List<Map<String, Object>> monthlyWalletFlow(@Param("memberId") Long memberId);
|
List<Map<String, Object>> monthlyWalletFlow(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 주별 순현금흐름(수입−지출, 계좌 지정분) — {wk(=주 월요일 yyyy-MM-dd), net} (주간 순자산 추이용) */
|
||||||
|
List<Map<String, Object>> weeklyWalletFlow(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
/** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */
|
/** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */
|
||||||
List<Map<String, Object>> statsByPeriod(@Param("memberId") Long memberId,
|
List<Map<String, Object>> statsByPeriod(@Param("memberId") Long memberId,
|
||||||
@Param("unit") String unit,
|
@Param("unit") String unit,
|
||||||
@@ -66,6 +69,11 @@ public interface AccountEntryMapper {
|
|||||||
/** 미확인(확인 필요) 내역 개수 */
|
/** 미확인(확인 필요) 내역 개수 */
|
||||||
int countPending(@Param("memberId") Long memberId);
|
int countPending(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 자동인식 분류 학습: 같은 메모 키워드로 과거에 가장 많이 쓴 분류 1건(없으면 null) */
|
||||||
|
String findLearnedCategory(@Param("memberId") Long memberId,
|
||||||
|
@Param("type") String type,
|
||||||
|
@Param("memo") String memo);
|
||||||
|
|
||||||
/** 미확인 내역 확정 (pending=0, 분류/계좌 보정) */
|
/** 미확인 내역 확정 (pending=0, 분류/계좌 보정) */
|
||||||
int confirm(@Param("id") Long id, @Param("memberId") Long memberId,
|
int confirm(@Param("id") Long id, @Param("memberId") Long memberId,
|
||||||
@Param("category") String category, @Param("walletId") Long walletId);
|
@Param("category") String category, @Param("walletId") Long walletId);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.sb.web.account.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 복구 시 회원의 기존 가계부 데이터를 전부 삭제(트랜잭션 내에서 호출).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface BackupMapper {
|
||||||
|
|
||||||
|
int deleteEntryTags(@Param("memberId") Long memberId);
|
||||||
|
int deleteEntries(@Param("memberId") Long memberId);
|
||||||
|
int deleteRecurrings(@Param("memberId") Long memberId);
|
||||||
|
int deleteBudgets(@Param("memberId") Long memberId);
|
||||||
|
int deleteBudgetIncome(@Param("memberId") Long memberId);
|
||||||
|
int deleteQuickEntries(@Param("memberId") Long memberId);
|
||||||
|
int deleteInvestTrades(@Param("memberId") Long memberId);
|
||||||
|
int deleteInvestHoldings(@Param("memberId") Long memberId);
|
||||||
|
int deleteCategories(@Param("memberId") Long memberId);
|
||||||
|
int deleteTags(@Param("memberId") Long memberId);
|
||||||
|
int deleteWallets(@Param("memberId") Long memberId);
|
||||||
|
}
|
||||||
@@ -12,13 +12,24 @@ import java.util.List;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface BudgetMapper {
|
public interface BudgetMapper {
|
||||||
|
|
||||||
List<Budget> findByMember(@Param("memberId") Long memberId);
|
List<Budget> findByMember(@Param("memberId") Long memberId,
|
||||||
|
@Param("year") Integer year,
|
||||||
|
@Param("month") Integer month);
|
||||||
|
|
||||||
Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
int countByCategory(@Param("memberId") Long memberId,
|
int countByCategory(@Param("memberId") Long memberId,
|
||||||
@Param("category") String category,
|
@Param("category") String category,
|
||||||
@Param("excludeId") Long excludeId);
|
@Param("excludeId") Long excludeId,
|
||||||
|
@Param("year") Integer year,
|
||||||
|
@Param("month") Integer month);
|
||||||
|
|
||||||
|
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류는 덮어씀). 복사된 건수 반환 */
|
||||||
|
int copyMonth(@Param("memberId") Long memberId,
|
||||||
|
@Param("fromYear") Integer fromYear,
|
||||||
|
@Param("fromMonth") Integer fromMonth,
|
||||||
|
@Param("toYear") Integer toYear,
|
||||||
|
@Param("toMonth") Integer toMonth);
|
||||||
|
|
||||||
int insert(Budget budget);
|
int insert(Budget budget);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ public interface CategoryMapper {
|
|||||||
|
|
||||||
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 이름으로 조회 (기본분류 불러오기 중복 확인용) */
|
||||||
|
AccountCategory findByMemberTypeName(@Param("memberId") Long memberId,
|
||||||
|
@Param("type") String type,
|
||||||
|
@Param("name") String name);
|
||||||
|
|
||||||
|
/** 해당 대분류(parent_id)에 속한 소분류 개수 */
|
||||||
|
int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId);
|
||||||
|
|
||||||
int countByName(@Param("memberId") Long memberId,
|
int countByName(@Param("memberId") Long memberId,
|
||||||
@Param("type") String type,
|
@Param("type") String type,
|
||||||
@Param("name") String name,
|
@Param("name") String name,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.sb.web.account.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.DefaultCategory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본(디폴트) 분류 매퍼 — 전체 공용(소유자 격리 없음).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface DefaultCategoryMapper {
|
||||||
|
|
||||||
|
List<DefaultCategory> findAll();
|
||||||
|
|
||||||
|
DefaultCategory findById(@Param("id") Long id);
|
||||||
|
|
||||||
|
int countChildren(@Param("parentId") Long parentId);
|
||||||
|
|
||||||
|
int countByName(@Param("type") String type, @Param("name") String name, @Param("excludeId") Long excludeId);
|
||||||
|
|
||||||
|
int insert(DefaultCategory category);
|
||||||
|
|
||||||
|
int update(DefaultCategory category);
|
||||||
|
|
||||||
|
int delete(@Param("id") Long id);
|
||||||
|
|
||||||
|
int updateSortOrder(@Param("id") Long id, @Param("sortOrder") int sortOrder);
|
||||||
|
|
||||||
|
Integer maxSortOrder(@Param("type") String type);
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ public interface InvestMapper {
|
|||||||
|
|
||||||
int insertTrade(InvestTrade trade);
|
int insertTrade(InvestTrade trade);
|
||||||
|
|
||||||
|
int updateTrade(InvestTrade trade);
|
||||||
|
|
||||||
int deleteTrade(@Param("id") Long id, @Param("memberId") Long memberId);
|
int deleteTrade(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
int deleteTradesByHolding(@Param("holdingId") Long holdingId, @Param("memberId") Long memberId);
|
int deleteTradesByHolding(@Param("holdingId") Long holdingId, @Param("memberId") Long memberId);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.sb.web.account.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.QuickEntry;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자주 쓰는 내역 매퍼 — 소유자(member_id) 격리.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface QuickEntryMapper {
|
||||||
|
|
||||||
|
List<QuickEntry> findByMember(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
QuickEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
int insert(QuickEntry e);
|
||||||
|
|
||||||
|
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
Integer maxSortOrder(@Param("memberId") Long memberId);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.sb.web.account.dto.AccountEntryResponse;
|
|||||||
import com.sb.web.account.dto.AccountSummary;
|
import com.sb.web.account.dto.AccountSummary;
|
||||||
import com.sb.web.account.dto.ConfirmRequest;
|
import com.sb.web.account.dto.ConfirmRequest;
|
||||||
import com.sb.web.account.dto.NotificationRequest;
|
import com.sb.web.account.dto.NotificationRequest;
|
||||||
|
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||||
import com.sb.web.account.dto.AccountTagRequest;
|
import com.sb.web.account.dto.AccountTagRequest;
|
||||||
import com.sb.web.account.dto.AccountTagResponse;
|
import com.sb.web.account.dto.AccountTagResponse;
|
||||||
import com.sb.web.account.dto.CategoryStat;
|
import com.sb.web.account.dto.CategoryStat;
|
||||||
@@ -26,6 +27,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -128,6 +130,66 @@ public class AccountService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 주별 순자산 추이 (월요일 기준 버킷). 기본 13주(최근 약 3개월). */
|
||||||
|
public List<NetWorthPoint> netWorthTrendWeekly(Long memberId, Integer weeks) {
|
||||||
|
int n = (weeks == null || weeks <= 0 || weeks > 53) ? 13 : weeks;
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate currentMon = today.minusDays(today.getDayOfWeek().getValue() - 1L); // 이번 주 월요일
|
||||||
|
LocalDate startMon = currentMon.minusWeeks(n - 1L);
|
||||||
|
|
||||||
|
Map<String, Long> flow = new HashMap<>();
|
||||||
|
for (Map<String, Object> row : mapper.weeklyWalletFlow(memberId)) {
|
||||||
|
Object net = row.get("net");
|
||||||
|
flow.put((String) row.get("wk"), net == null ? 0L : ((Number) net).longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개시잔액: 개시주가 시작주 이전(또는 미상)이면 기준선, 범위 내면 해당 주 버킷
|
||||||
|
List<Wallet> ws = walletMapper.findByMember(memberId);
|
||||||
|
Map<String, Long> openingByWeek = new HashMap<>();
|
||||||
|
long baseline = 0;
|
||||||
|
for (Wallet w : ws) {
|
||||||
|
long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance();
|
||||||
|
if (ob == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LocalDate od = w.getOpeningDate();
|
||||||
|
LocalDate om = od == null ? null : od.minusDays(od.getDayOfWeek().getValue() - 1L);
|
||||||
|
if (om == null || om.isBefore(startMon)) {
|
||||||
|
baseline += ob;
|
||||||
|
} else if (!om.isAfter(currentMon)) {
|
||||||
|
openingByWeek.merge(om.toString(), ob, Long::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String startKey = startMon.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 (LocalDate m = startMon; !m.isAfter(currentMon); m = m.plusWeeks(1)) {
|
||||||
|
String key = m.toString();
|
||||||
|
running += flow.getOrDefault(key, 0L);
|
||||||
|
running += openingByWeek.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) {
|
public List<PeriodStat> stats(Long memberId, String unit, Integer year, Integer month) {
|
||||||
String u = switch (unit == null ? "" : unit.toUpperCase()) {
|
String u = switch (unit == null ? "" : unit.toUpperCase()) {
|
||||||
@@ -172,6 +234,9 @@ public class AccountService {
|
|||||||
.walletId(req.getWalletId())
|
.walletId(req.getWalletId())
|
||||||
.toWalletId(transfer ? req.getToWalletId() : null)
|
.toWalletId(transfer ? req.getToWalletId() : null)
|
||||||
.installmentMonths(installmentOf(req))
|
.installmentMonths(installmentOf(req))
|
||||||
|
.currency(req.getCurrency())
|
||||||
|
.originalAmount(req.getOriginalAmount())
|
||||||
|
.exchangeRate(req.getExchangeRate())
|
||||||
.build();
|
.build();
|
||||||
mapper.insert(entry);
|
mapper.insert(entry);
|
||||||
applyTags(entry.getId(), req.getTagIds(), memberId);
|
applyTags(entry.getId(), req.getTagIds(), memberId);
|
||||||
@@ -191,6 +256,9 @@ public class AccountService {
|
|||||||
entry.setWalletId(req.getWalletId());
|
entry.setWalletId(req.getWalletId());
|
||||||
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
||||||
entry.setInstallmentMonths(installmentOf(req));
|
entry.setInstallmentMonths(installmentOf(req));
|
||||||
|
entry.setCurrency(req.getCurrency());
|
||||||
|
entry.setOriginalAmount(req.getOriginalAmount());
|
||||||
|
entry.setExchangeRate(req.getExchangeRate());
|
||||||
mapper.update(entry);
|
mapper.update(entry);
|
||||||
|
|
||||||
mapper.deleteEntryTagsByEntryId(id);
|
mapper.deleteEntryTagsByEntryId(id);
|
||||||
@@ -216,27 +284,48 @@ public class AccountService {
|
|||||||
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||||
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||||
*/
|
*/
|
||||||
|
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
|
||||||
|
public ParsedEntryResponse parseText(NotificationRequest req) {
|
||||||
|
CardNotificationParser.Parsed p =
|
||||||
|
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||||
|
return ParsedEntryResponse.builder()
|
||||||
|
.recognized(p.isRecognized())
|
||||||
|
.type(p.isIncome() ? "INCOME" : "EXPENSE")
|
||||||
|
.amount(p.getAmount())
|
||||||
|
.merchant(p.getMerchant())
|
||||||
|
.date(p.getDate())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
||||||
CardNotificationParser.Parsed p =
|
CardNotificationParser.Parsed p =
|
||||||
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||||
if (!p.isCard() || p.isCanceled() || p.getAmount() <= 0) {
|
if (!p.isRecognized() || p.getAmount() <= 0) {
|
||||||
return null; // 결제 승인 알림 아님 / 취소 → 무시
|
return null; // 결제/이체 알림 아님 → 무시
|
||||||
}
|
}
|
||||||
// 중복방지
|
// 중복방지
|
||||||
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
|
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 카드사 → 등록된 카드(CARD) 매칭
|
// 카드 결제 → 카드(CARD) 매칭 / 은행 입출금·이체 → 은행(BANK) 매칭
|
||||||
Long walletId = matchCardWalletId(p.getIssuer(), memberId);
|
String walletType = p.isBankTransfer() ? "BANK" : "CARD";
|
||||||
|
Long walletId = matchWalletId(p.getIssuer(), memberId, walletType);
|
||||||
|
|
||||||
|
// 분류 자동학습: 같은 메모(가맹점) 키워드로 과거에 확정한 분류가 있으면 그대로 채워준다.
|
||||||
|
String type = p.isIncome() ? "INCOME" : "EXPENSE";
|
||||||
|
String merchant = p.getMerchant();
|
||||||
|
String learnedCategory = (merchant != null && !merchant.isBlank())
|
||||||
|
? mapper.findLearnedCategory(memberId, type, merchant)
|
||||||
|
: null;
|
||||||
|
|
||||||
AccountEntry entry = AccountEntry.builder()
|
AccountEntry entry = AccountEntry.builder()
|
||||||
.memberId(memberId)
|
.memberId(memberId)
|
||||||
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
|
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
|
||||||
.type("EXPENSE")
|
.type(type)
|
||||||
.category(null)
|
.category(learnedCategory)
|
||||||
.amount(p.getAmount())
|
.amount(p.getAmount())
|
||||||
.memo(p.getMerchant())
|
.memo(merchant)
|
||||||
.walletId(walletId)
|
.walletId(walletId)
|
||||||
.toWalletId(null)
|
.toWalletId(null)
|
||||||
.installmentMonths(null)
|
.installmentMonths(null)
|
||||||
@@ -260,38 +349,38 @@ public class AccountService {
|
|||||||
return toResponse(mapper.findByIdAndMember(id, memberId));
|
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 양방향 부분일치). 미매칭이면 카드가 1개뿐일 때 그 카드로. */
|
/** 카드사/은행명으로 등록된 해당 type(CARD/BANK) 지갑 id 찾기 (issuer/name 유연 매칭).
|
||||||
private Long matchCardWalletId(String issuer, Long memberId) {
|
* 미매칭이라도 그 type 지갑이 정확히 1개면 그 지갑으로 기본 매칭. */
|
||||||
List<Wallet> cards = walletMapper.findByMember(memberId).stream()
|
private Long matchWalletId(String issuer, Long memberId, String walletType) {
|
||||||
.filter(w -> "CARD".equals(w.getType()))
|
List<Wallet> wallets = walletMapper.findByMember(memberId).stream()
|
||||||
|
.filter(w -> walletType.equals(w.getType()))
|
||||||
.toList();
|
.toList();
|
||||||
if (issuer != null) {
|
if (issuer != null) {
|
||||||
Long matched = cards.stream()
|
Long matched = wallets.stream()
|
||||||
.filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer))
|
.filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer))
|
||||||
.map(Wallet::getId)
|
.map(Wallet::getId)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (matched != null) return matched;
|
if (matched != null) return matched;
|
||||||
}
|
}
|
||||||
// 카드사 미감지/미매칭이라도 등록된 카드가 정확히 1개면 그 카드로 기본 매칭
|
return wallets.size() == 1 ? wallets.get(0).getId() : null;
|
||||||
return cards.size() == 1 ? cards.get(0).getId() : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 양방향 부분일치 (KB국민 ↔ 국민 등) */
|
/** 표기 차이를 흡수한 카드사 매칭 (KB국민 ↔ 국민 ↔ 국민카드 ↔ KB카드 등) */
|
||||||
private boolean overlaps(String walletField, String issuer) {
|
private boolean overlaps(String walletField, String issuer) {
|
||||||
if (walletField == null || issuer == null) return false;
|
return CardIssuerMatcher.matches(walletField, issuer);
|
||||||
return walletField.contains(issuer) || issuer.contains(walletField);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== 상환/납부 ===================== */
|
/* ===================== 상환/납부 ===================== */
|
||||||
|
|
||||||
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
|
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자·연회비는 지출로 분리 생성 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void repay(RepaymentRequest req, Long memberId) {
|
public void repay(RepaymentRequest req, Long memberId) {
|
||||||
long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L;
|
long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L;
|
||||||
long interest = req.getInterest() != null ? req.getInterest() : 0L;
|
long interest = req.getInterest() != null ? req.getInterest() : 0L;
|
||||||
if (principal <= 0 && interest <= 0) {
|
long annualFee = req.getAnnualFee() != null ? req.getAnnualFee() : 0L;
|
||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "원금 또는 이자를 입력하세요.");
|
if (principal <= 0 && interest <= 0 && annualFee <= 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "원금·이자·연회비 중 하나는 입력하세요.");
|
||||||
}
|
}
|
||||||
requireOwnedWallet(req.getFromWalletId(), memberId);
|
requireOwnedWallet(req.getFromWalletId(), memberId);
|
||||||
Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId);
|
Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId);
|
||||||
@@ -301,6 +390,9 @@ public class AccountService {
|
|||||||
if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) {
|
if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) {
|
||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다.");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다.");
|
||||||
}
|
}
|
||||||
|
if (annualFee > 0 && !"CARD".equals(target.getType())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "연회비는 카드 상환에만 입력할 수 있습니다.");
|
||||||
|
}
|
||||||
if (req.getFromWalletId().equals(req.getTargetWalletId())) {
|
if (req.getFromWalletId().equals(req.getTargetWalletId())) {
|
||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다.");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다.");
|
||||||
}
|
}
|
||||||
@@ -329,6 +421,18 @@ public class AccountService {
|
|||||||
.memo(req.getMemo() != null ? req.getMemo() : "이자")
|
.memo(req.getMemo() != null ? req.getMemo() : "이자")
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
// 연회비: 지출 (카드, 출금 계좌에서, 분류=연회비)
|
||||||
|
if (annualFee > 0) {
|
||||||
|
mapper.insert(AccountEntry.builder()
|
||||||
|
.memberId(memberId)
|
||||||
|
.entryDate(req.getEntryDate())
|
||||||
|
.type("EXPENSE")
|
||||||
|
.category("연회비")
|
||||||
|
.amount(annualFee)
|
||||||
|
.walletId(req.getFromWalletId())
|
||||||
|
.memo(req.getMemo() != null ? req.getMemo() : "연회비")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== 계좌/카드 (사용자별) ===================== */
|
/* ===================== 계좌/카드 (사용자별) ===================== */
|
||||||
@@ -367,7 +471,7 @@ public class AccountService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 순자산 = 총자산(BANK) − 총부채(CARD/LOAN) */
|
/** 순자산 = 총자산(BANK/CASH/INVEST) − 총부채(CARD/LOAN) */
|
||||||
public NetWorthResponse netWorth(Long memberId) {
|
public NetWorthResponse netWorth(Long memberId) {
|
||||||
Map<Long, Long> balances = balanceMap(memberId);
|
Map<Long, Long> balances = balanceMap(memberId);
|
||||||
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
|
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
|
||||||
@@ -384,7 +488,7 @@ public class AccountService {
|
|||||||
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
||||||
assets += bal + wi.cashDelta + wi.stockEval;
|
assets += bal + wi.cashDelta + wi.stockEval;
|
||||||
}
|
}
|
||||||
} else if ("BANK".equals(w.getType())) {
|
} else if ("BANK".equals(w.getType()) || "CASH".equals(w.getType())) {
|
||||||
assets += bal;
|
assets += bal;
|
||||||
} else {
|
} else {
|
||||||
liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시
|
liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시
|
||||||
@@ -431,6 +535,12 @@ public class AccountService {
|
|||||||
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
|
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
|
||||||
wallet.setOpeningDate(req.getOpeningDate());
|
wallet.setOpeningDate(req.getOpeningDate());
|
||||||
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
|
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
|
||||||
|
boolean isLoan = "LOAN".equals(req.getType());
|
||||||
|
wallet.setLoanAmount(isLoan ? req.getLoanAmount() : null);
|
||||||
|
wallet.setLoanRate(isLoan ? req.getLoanRate() : null);
|
||||||
|
wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null);
|
||||||
|
wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null);
|
||||||
|
wallet.setLoanStart(isLoan ? req.getLoanStart() : null);
|
||||||
walletMapper.update(wallet);
|
walletMapper.update(wallet);
|
||||||
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
|
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
|
||||||
}
|
}
|
||||||
@@ -460,6 +570,7 @@ public class AccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Wallet toWallet(WalletRequest req) {
|
private Wallet toWallet(WalletRequest req) {
|
||||||
|
boolean isLoan = "LOAN".equals(req.getType());
|
||||||
return Wallet.builder()
|
return Wallet.builder()
|
||||||
.type(req.getType())
|
.type(req.getType())
|
||||||
.name(req.getName().trim())
|
.name(req.getName().trim())
|
||||||
@@ -469,6 +580,11 @@ public class AccountService {
|
|||||||
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
|
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
|
||||||
.openingDate(req.getOpeningDate())
|
.openingDate(req.getOpeningDate())
|
||||||
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
|
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
|
||||||
|
.loanAmount(isLoan ? req.getLoanAmount() : null)
|
||||||
|
.loanRate(isLoan ? req.getLoanRate() : null)
|
||||||
|
.loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null)
|
||||||
|
.loanMonths(isLoan ? req.getLoanMonths() : null)
|
||||||
|
.loanStart(isLoan ? req.getLoanStart() : null)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.sb.web.account.dto.AccountEntryRequest;
|
||||||
|
import com.sb.web.account.dto.AccountTagRequest;
|
||||||
|
import com.sb.web.account.dto.BudgetRequest;
|
||||||
|
import com.sb.web.account.dto.CategoryRequest;
|
||||||
|
import com.sb.web.account.dto.QuickEntryRequest;
|
||||||
|
import com.sb.web.account.dto.RecurringRequest;
|
||||||
|
import com.sb.web.account.dto.RestoreRequest;
|
||||||
|
import com.sb.web.account.dto.WalletRequest;
|
||||||
|
import com.sb.web.account.mapper.BackupMapper;
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 가져오기(복구) — 기존 데이터를 전부 삭제하고 백업(이름 기준)을 새로 재생성한다.
|
||||||
|
* 전체를 한 트랜잭션으로 처리해 중간 실패 시 롤백(기존 데이터 보존)된다.
|
||||||
|
* ※ 투자 종목/매매내역은 백업에 포함되지 않아 복구 시 삭제된다(평가액 계좌 자체는 복원).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BackupService {
|
||||||
|
|
||||||
|
private final BackupMapper backupMapper;
|
||||||
|
private final AccountService accountService;
|
||||||
|
private final CategoryService categoryService;
|
||||||
|
private final RecurringService recurringService;
|
||||||
|
private final BudgetService budgetService;
|
||||||
|
private final QuickEntryService quickEntryService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void restore(Long memberId, RestoreRequest req) {
|
||||||
|
if (isEmpty(req)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "복구할 데이터가 비어 있습니다. 백업 파일을 확인해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 기존 데이터 전부 삭제 (FK 안전 순서)
|
||||||
|
backupMapper.deleteEntryTags(memberId);
|
||||||
|
backupMapper.deleteEntries(memberId);
|
||||||
|
backupMapper.deleteRecurrings(memberId);
|
||||||
|
backupMapper.deleteBudgets(memberId);
|
||||||
|
backupMapper.deleteBudgetIncome(memberId);
|
||||||
|
backupMapper.deleteQuickEntries(memberId);
|
||||||
|
backupMapper.deleteInvestTrades(memberId);
|
||||||
|
backupMapper.deleteInvestHoldings(memberId);
|
||||||
|
backupMapper.deleteCategories(memberId);
|
||||||
|
backupMapper.deleteTags(memberId);
|
||||||
|
backupMapper.deleteWallets(memberId);
|
||||||
|
|
||||||
|
// 2) 계좌 → 원본 id(oldId) → 새 id (같은 이름 계좌가 있어도 정확)
|
||||||
|
Map<Long, Long> walletMap = new HashMap<>();
|
||||||
|
if (req.getWallets() != null) {
|
||||||
|
for (RestoreRequest.Wal w : req.getWallets()) {
|
||||||
|
if (w == null || w.getName() == null) continue;
|
||||||
|
WalletRequest wr = new WalletRequest();
|
||||||
|
wr.setType(w.getType());
|
||||||
|
wr.setName(w.getName());
|
||||||
|
wr.setIssuer(w.getIssuer());
|
||||||
|
wr.setAccountNumber(w.getAccountNumber());
|
||||||
|
wr.setCardType(w.getCardType());
|
||||||
|
wr.setOpeningBalance(w.getOpeningBalance());
|
||||||
|
wr.setOpeningDate(w.getOpeningDate());
|
||||||
|
wr.setCurrentValue(w.getCurrentValue());
|
||||||
|
Long newId = accountService.createWallet(wr, memberId).getId();
|
||||||
|
if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 태그 → 이름→새 ID
|
||||||
|
Map<String, Long> tagMap = new HashMap<>();
|
||||||
|
if (req.getTags() != null) {
|
||||||
|
for (String name : req.getTags()) {
|
||||||
|
if (name == null || name.isBlank()) continue;
|
||||||
|
AccountTagRequest tr = new AccountTagRequest();
|
||||||
|
tr.setName(name.trim());
|
||||||
|
tagMap.put(name.trim(), accountService.createTag(tr, memberId).getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 분류 — 대분류 먼저, 소분류는 부모 이름으로 매핑 ("TYPE|name" → id)
|
||||||
|
Map<String, Long> catMap = new HashMap<>();
|
||||||
|
if (req.getCategories() != null) {
|
||||||
|
for (RestoreRequest.Cat c : req.getCategories()) {
|
||||||
|
if (c.getName() == null || (c.getParent() != null && !c.getParent().isBlank())) continue;
|
||||||
|
CategoryRequest cr = new CategoryRequest();
|
||||||
|
cr.setType(c.getType());
|
||||||
|
cr.setName(c.getName());
|
||||||
|
catMap.put(key(c.getType(), c.getName()), categoryService.create(cr, memberId).getId());
|
||||||
|
}
|
||||||
|
for (RestoreRequest.Cat c : req.getCategories()) {
|
||||||
|
if (c.getName() == null || c.getParent() == null || c.getParent().isBlank()) continue;
|
||||||
|
CategoryRequest cr = new CategoryRequest();
|
||||||
|
cr.setType(c.getType());
|
||||||
|
cr.setName(c.getName());
|
||||||
|
cr.setParentId(catMap.get(key(c.getType(), c.getParent())));
|
||||||
|
categoryService.create(cr, memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 내역
|
||||||
|
if (req.getEntries() != null) {
|
||||||
|
for (RestoreRequest.Entry e : req.getEntries()) {
|
||||||
|
if (e.getEntryDate() == null || e.getType() == null) continue;
|
||||||
|
Long wid = walletMap.get(e.getWalletId());
|
||||||
|
Long twid = walletMap.get(e.getToWalletId());
|
||||||
|
if (badTransfer(e.getType(), wid, twid)) continue; // 이체 계좌가 없거나 같으면 건너뜀
|
||||||
|
AccountEntryRequest er = new AccountEntryRequest();
|
||||||
|
er.setEntryDate(e.getEntryDate());
|
||||||
|
er.setType(e.getType());
|
||||||
|
er.setCategory(e.getCategory());
|
||||||
|
er.setAmount(e.getAmount());
|
||||||
|
er.setMemo(e.getMemo());
|
||||||
|
er.setWalletId(wid);
|
||||||
|
er.setToWalletId(twid);
|
||||||
|
er.setInstallmentMonths(e.getInstallmentMonths());
|
||||||
|
if (e.getTags() != null) {
|
||||||
|
er.setTagIds(e.getTags().stream().map(tagMap::get).filter(Objects::nonNull).toList());
|
||||||
|
}
|
||||||
|
accountService.create(er, memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 고정지출
|
||||||
|
if (req.getRecurrings() != null) {
|
||||||
|
for (RestoreRequest.Recur r : req.getRecurrings()) {
|
||||||
|
if (r.getTitle() == null) continue;
|
||||||
|
Long wid = walletMap.get(r.getWalletId());
|
||||||
|
Long twid = walletMap.get(r.getToWalletId());
|
||||||
|
if (badTransfer(r.getType(), wid, twid)) continue;
|
||||||
|
RecurringRequest rr = new RecurringRequest();
|
||||||
|
rr.setTitle(r.getTitle());
|
||||||
|
rr.setType(r.getType());
|
||||||
|
rr.setAmount(r.getAmount());
|
||||||
|
rr.setCategory(r.getCategory());
|
||||||
|
rr.setMemo(r.getMemo());
|
||||||
|
rr.setWalletId(wid);
|
||||||
|
rr.setToWalletId(twid);
|
||||||
|
rr.setFrequency(r.getFrequency());
|
||||||
|
rr.setDayOfMonth(r.getDayOfMonth());
|
||||||
|
rr.setDayOfWeek(r.getDayOfWeek());
|
||||||
|
rr.setMonthOfYear(r.getMonthOfYear());
|
||||||
|
rr.setStartDate(r.getStartDate());
|
||||||
|
rr.setEndDate(r.getEndDate());
|
||||||
|
rr.setActive(r.getActive() == null || r.getActive());
|
||||||
|
recurringService.create(rr, memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
|
||||||
|
if (req.getBudgets() != null) {
|
||||||
|
java.time.LocalDate today = java.time.LocalDate.now();
|
||||||
|
for (BudgetRequest b : req.getBudgets()) {
|
||||||
|
if (b == null || b.getCategory() == null) continue;
|
||||||
|
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) 자주 쓰는 내역
|
||||||
|
if (req.getQuickEntries() != null) {
|
||||||
|
for (RestoreRequest.Quick q : req.getQuickEntries()) {
|
||||||
|
if (q.getType() == null) continue;
|
||||||
|
QuickEntryRequest qr = new QuickEntryRequest();
|
||||||
|
qr.setLabel(q.getLabel());
|
||||||
|
qr.setType(q.getType());
|
||||||
|
qr.setCategory(q.getCategory());
|
||||||
|
qr.setAmount(q.getAmount());
|
||||||
|
qr.setMemo(q.getMemo());
|
||||||
|
qr.setWalletId(walletMap.get(q.getWalletId()));
|
||||||
|
quickEntryService.create(qr, memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[restore] member={} wallets={} entries={} restored",
|
||||||
|
memberId, walletMap.size(), req.getEntries() == null ? 0 : req.getEntries().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String type, String name) {
|
||||||
|
return type + "|" + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이체인데 출금/입금 계좌가 없거나 같으면 복구에서 제외(검증 오류로 전체 롤백 방지) */
|
||||||
|
private static boolean badTransfer(String type, Long walletId, Long toWalletId) {
|
||||||
|
return "TRANSFER".equals(type) && (walletId == null || toWalletId == null || walletId.equals(toWalletId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEmpty(RestoreRequest r) {
|
||||||
|
return empty(r.getWallets()) && empty(r.getCategories()) && empty(r.getTags())
|
||||||
|
&& empty(r.getEntries()) && empty(r.getRecurrings()) && empty(r.getBudgets())
|
||||||
|
&& empty(r.getQuickEntries());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean empty(List<?> l) {
|
||||||
|
return l == null || l.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,36 +31,46 @@ public class BudgetService {
|
|||||||
private final BudgetMapper budgetMapper;
|
private final BudgetMapper budgetMapper;
|
||||||
private final AccountEntryMapper entryMapper;
|
private final AccountEntryMapper entryMapper;
|
||||||
|
|
||||||
public List<BudgetResponse> list(Long memberId) {
|
public List<BudgetResponse> list(Long memberId, int year, int month) {
|
||||||
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
|
return budgetMapper.findByMember(memberId, year, month).stream().map(BudgetResponse::from).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BudgetResponse create(BudgetRequest req, Long memberId) {
|
public BudgetResponse create(BudgetRequest req, Long memberId, int year, int month) {
|
||||||
String category = req.getCategory().trim();
|
String category = req.getCategory().trim();
|
||||||
if (budgetMapper.countByCategory(memberId, category, null) > 0) {
|
if (budgetMapper.countByCategory(memberId, category, null, year, month) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||||
}
|
}
|
||||||
Budget budget = toBudget(req, category);
|
Budget budget = toBudget(req, category);
|
||||||
budget.setMemberId(memberId);
|
budget.setMemberId(memberId);
|
||||||
|
budget.setYear(year);
|
||||||
|
budget.setMonth(month);
|
||||||
budgetMapper.insert(budget);
|
budgetMapper.insert(budget);
|
||||||
return BudgetResponse.from(budget);
|
return BudgetResponse.from(budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
|
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
|
||||||
mustFind(id, memberId);
|
Budget existing = mustFind(id, memberId);
|
||||||
String category = req.getCategory().trim();
|
String category = req.getCategory().trim();
|
||||||
if (budgetMapper.countByCategory(memberId, category, id) > 0) {
|
if (budgetMapper.countByCategory(memberId, category, id, existing.getYear(), existing.getMonth()) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||||
}
|
}
|
||||||
Budget budget = toBudget(req, category);
|
Budget budget = toBudget(req, category);
|
||||||
budget.setId(id);
|
budget.setId(id);
|
||||||
budget.setMemberId(memberId);
|
budget.setMemberId(memberId);
|
||||||
|
budget.setYear(existing.getYear());
|
||||||
|
budget.setMonth(existing.getMonth());
|
||||||
budgetMapper.update(budget);
|
budgetMapper.update(budget);
|
||||||
return BudgetResponse.from(budget);
|
return BudgetResponse.from(budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류 덮어씀). 복사된 건수 반환 */
|
||||||
|
@Transactional
|
||||||
|
public int copy(Long memberId, int fromYear, int fromMonth, int toYear, int toMonth) {
|
||||||
|
return budgetMapper.copyMonth(memberId, fromYear, fromMonth, toYear, toMonth);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long memberId) {
|
public void delete(Long id, Long memberId) {
|
||||||
mustFind(id, memberId);
|
mustFind(id, memberId);
|
||||||
@@ -79,7 +89,7 @@ public class BudgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<BudgetStatus> result = new ArrayList<>();
|
List<BudgetStatus> result = new ArrayList<>();
|
||||||
for (Budget b : budgetMapper.findByMember(memberId)) {
|
for (Budget b : budgetMapper.findByMember(memberId, year, month)) {
|
||||||
long monthlyBudget = monthlyBudget(b, daysInMonth);
|
long monthlyBudget = monthlyBudget(b, daysInMonth);
|
||||||
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
|
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
|
||||||
result.add(BudgetStatus.builder()
|
result.add(BudgetStatus.builder()
|
||||||
@@ -103,7 +113,8 @@ public class BudgetService {
|
|||||||
int y = year != null ? year : Year.now().getValue();
|
int y = year != null ? year : Year.now().getValue();
|
||||||
int m = month != null ? month : 1;
|
int m = month != null ? month : 1;
|
||||||
|
|
||||||
List<Budget> budgets = budgetMapper.findByMember(memberId);
|
// 기간 차트는 선택 월의 예산을 기준으로 각 버킷에 적용(월별 예산 도입 전 동작과 동일)
|
||||||
|
List<Budget> budgets = budgetMapper.findByMember(memberId, y, m);
|
||||||
Map<String, Long> expense = new HashMap<>();
|
Map<String, Long> expense = new HashMap<>();
|
||||||
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
|
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
|
||||||
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
|
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드사명 유연 매칭.
|
||||||
|
* 알림에서 감지한 카드사와 사용자가 등록한 카드의 「카드사/이름」을, 표기 차이
|
||||||
|
* (KB / 국민 / 국민카드 / KB카드, 농협 / NH, 비씨 / BC 등)에 관계없이 같은 카드사로 인식한다.
|
||||||
|
*/
|
||||||
|
public final class CardIssuerMatcher {
|
||||||
|
|
||||||
|
private CardIssuerMatcher() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 두 표기가 같은 카드사를 가리키면 true */
|
||||||
|
public static boolean matches(String walletField, String issuer) {
|
||||||
|
String a = canon(walletField);
|
||||||
|
String b = canon(issuer);
|
||||||
|
if (a.isEmpty() || b.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return a.equals(b) || a.contains(b) || b.contains(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공백·접미사(카드/은행/페이/뱅크) 제거 + 동일 카드사 별칭 통합 후 소문자 키 */
|
||||||
|
static String canon(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String x = s.replaceAll("\\s+", "").toLowerCase()
|
||||||
|
.replace("카드", "").replace("은행", "").replace("페이", "").replace("뱅크", "");
|
||||||
|
if (x.contains("kb") || x.contains("국민")) {
|
||||||
|
return "국민";
|
||||||
|
}
|
||||||
|
if (x.contains("nh") || x.contains("농협")) {
|
||||||
|
return "농협";
|
||||||
|
}
|
||||||
|
if (x.equals("bc") || x.contains("비씨")) {
|
||||||
|
return "비씨";
|
||||||
|
}
|
||||||
|
if (x.contains("씨티") || x.contains("시티") || x.contains("citi")) {
|
||||||
|
return "씨티";
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,66 +10,93 @@ import java.util.regex.Matcher;
|
|||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카드사 결제 푸시 알림 텍스트 파싱.
|
* 결제/이체 푸시 알림 텍스트 파싱.
|
||||||
* 카드사마다 문구가 달라 휴리스틱으로 카드사·금액·가맹점·승인/취소를 추출한다.
|
* - 카드: 승인 → 지출, 취소/환불 → 수입(환불)
|
||||||
|
* - 은행: 입금 → 수입, 출금/이체 → 지출
|
||||||
|
* 문구가 제각각이라 휴리스틱으로 종류·수입/지출·카드사(은행)·금액·가맹점·날짜를 추출한다.
|
||||||
* (서버에 두어 APK 재빌드 없이 규칙 보강 가능)
|
* (서버에 두어 APK 재빌드 없이 규칙 보강 가능)
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class CardNotificationParser {
|
public class CardNotificationParser {
|
||||||
|
|
||||||
// 감지할 카드사 (긴 이름 우선)
|
// 감지할 카드사/은행 (긴 이름 우선)
|
||||||
private static final List<String> ISSUERS = List.of(
|
private static final List<String> ISSUERS = List.of(
|
||||||
"KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC",
|
"KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC",
|
||||||
"농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주"
|
"농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주",
|
||||||
|
"기업", "산업", "새마을", "신협", "우체국", "케이뱅크", "K뱅크"
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
||||||
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
||||||
|
|
||||||
// 광고/이벤트 푸시에 흔한 표현 — 강한 승인신호(승인/출금)가 없으면 결제로 보지 않는다.
|
// 명백한 광고/이벤트 표현 — 거래신호(승인/입출금/취소)가 있어도 무조건 광고로 보고 제외.
|
||||||
private static final Pattern AD_HINT = Pattern.compile(
|
// (실제 카드 승인/은행 입출금 알림에는 거의 등장하지 않는 표현들)
|
||||||
"이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집");
|
private static final Pattern AD_BLOCK = Pattern.compile(
|
||||||
|
"이벤트|광고|쿠폰|당첨|응모|추첨|프로모션|배너|모집|혜택|초대|수신거부|마케팅|사은품|경품|세일|"
|
||||||
|
+ "바로가기|자세히\\s*보기|클릭|구독|뉴스레터|안내문자|광고성");
|
||||||
|
// 약한 광고 표현 — 명확한 거래신호 없이 '결제'만 있을 때만 광고로 간주.
|
||||||
|
// (실거래 영수증/알림에도 '포인트 적립' 등이 붙는 경우가 있어 무조건 차단하지 않음)
|
||||||
|
private static final Pattern AD_WEAK = Pattern.compile(
|
||||||
|
"적립|포인트|캐시백|마일리지|리워드|무료|증정|할인");
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Builder
|
@Builder
|
||||||
public static class Parsed {
|
public static class Parsed {
|
||||||
private boolean card; // 카드 결제 알림으로 인식되었는가
|
private boolean recognized; // 가계부에 넣을 알림으로 인식되었는가
|
||||||
private boolean canceled; // 취소/취소승인
|
private boolean income; // true=수입(입금/환불), false=지출(승인/출금/이체)
|
||||||
private String issuer; // 감지된 카드사 (없으면 null)
|
private boolean bankTransfer; // true=은행 입출금/이체, false=카드 결제
|
||||||
private long amount; // 금액(원)
|
private String issuer; // 감지된 카드사/은행 (없으면 null)
|
||||||
private String merchant; // 가맹점(추정)
|
private long amount; // 금액(원)
|
||||||
private LocalDate date; // 거래일(추정, 없으면 null)
|
private String merchant; // 카드:가맹점 / 은행:적요(추정)
|
||||||
private String notifKey; // 중복방지 키
|
private LocalDate date; // 거래일(추정, 없으면 오늘)
|
||||||
|
private String notifKey; // 중복방지 키
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 결제 알림 파싱. card=false 면 결제 알림이 아님(무시 대상). */
|
/** 알림 파싱. recognized=false 면 가계부 대상 아님(무시). */
|
||||||
public Parsed parse(String title, String text, LocalDate today) {
|
public Parsed parse(String title, String text, LocalDate today) {
|
||||||
String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim();
|
String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim();
|
||||||
if (raw.isBlank()) return notCard();
|
if (raw.isBlank()) return notRecognized();
|
||||||
|
|
||||||
|
boolean deposit = raw.contains("입금");
|
||||||
|
boolean withdraw = raw.contains("출금") || raw.contains("이체") || raw.contains("송금");
|
||||||
|
boolean refund = raw.contains("취소") || raw.contains("환불");
|
||||||
|
boolean approval = raw.contains("승인") || raw.contains("일시불") || raw.contains("할부");
|
||||||
|
// '결제'는 광고 문구("결제하고 적립")에도 흔해 약신호로만 취급(단독으론 광고 의심)
|
||||||
|
boolean weakApproval = raw.contains("결제");
|
||||||
|
|
||||||
boolean approved = raw.contains("승인") || raw.contains("결제") || raw.contains("출금");
|
|
||||||
boolean canceled = raw.contains("취소") || raw.contains("환불");
|
|
||||||
Matcher am = AMOUNT.matcher(raw);
|
Matcher am = AMOUNT.matcher(raw);
|
||||||
if (!am.find() || (!approved && !canceled)) {
|
boolean realSignal = approval || deposit || withdraw || refund; // 명확한 거래신호
|
||||||
return notCard();
|
boolean anySignal = realSignal || weakApproval;
|
||||||
|
if (!am.find() || !anySignal) {
|
||||||
|
return notRecognized();
|
||||||
}
|
}
|
||||||
// 광고/이벤트 차단: 강한 승인신호(승인/출금/취소/환불)가 없는데 광고성 표현이 있으면 결제 아님
|
// 1) 명백한 광고/이벤트 표현이면 거래신호가 있어도 무시(광고 푸시·문자 오등록 방지)
|
||||||
boolean strongSignal = raw.contains("승인") || raw.contains("출금") || canceled;
|
if (AD_BLOCK.matcher(raw).find()) {
|
||||||
if (!strongSignal && AD_HINT.matcher(raw).find()) {
|
return notRecognized();
|
||||||
return notCard();
|
}
|
||||||
|
// 2) 약한 광고 표현(적립/포인트/할인 등)은 명확한 거래신호 없이 '결제'만 있으면 광고로 간주
|
||||||
|
if (!realSignal && AD_WEAK.matcher(raw).find()) {
|
||||||
|
return notRecognized();
|
||||||
}
|
}
|
||||||
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
||||||
|
|
||||||
|
// 수입/지출: 입금·취소·환불 = 수입, 그 외(승인/결제/출금/이체) = 지출
|
||||||
|
boolean income = deposit || refund;
|
||||||
|
// 은행 이체성: 카드 승인 신호 없이 입금/출금/이체 → 은행 거래(카드사 대신 은행 계좌에 매칭)
|
||||||
|
boolean bankTransfer = (deposit || withdraw) && !approval;
|
||||||
|
|
||||||
String issuer = detectIssuer(raw);
|
String issuer = detectIssuer(raw);
|
||||||
String merchant = guessMerchant(raw);
|
String merchant = guessMerchant(raw);
|
||||||
LocalDate date = guessDate(raw, today);
|
LocalDate date = guessDate(raw, today);
|
||||||
|
|
||||||
String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|"
|
String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|"
|
||||||
+ (merchant == null ? "" : merchant) + "|" + date + "|" + (canceled ? "C" : "A");
|
+ (merchant == null ? "" : merchant) + "|" + date + "|"
|
||||||
|
+ (income ? "I" : "E") + (bankTransfer ? "B" : "C");
|
||||||
|
|
||||||
return Parsed.builder()
|
return Parsed.builder()
|
||||||
.card(true)
|
.recognized(true)
|
||||||
.canceled(canceled)
|
.income(income)
|
||||||
|
.bankTransfer(bankTransfer)
|
||||||
.issuer(issuer)
|
.issuer(issuer)
|
||||||
.amount(amount)
|
.amount(amount)
|
||||||
.merchant(merchant)
|
.merchant(merchant)
|
||||||
@@ -78,8 +105,8 @@ public class CardNotificationParser {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Parsed notCard() {
|
private Parsed notRecognized() {
|
||||||
return Parsed.builder().card(false).build();
|
return Parsed.builder().recognized(false).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String detectIssuer(String raw) {
|
private String detectIssuer(String raw) {
|
||||||
@@ -108,7 +135,7 @@ public class CardNotificationParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가맹점 추정: 잡음(카드사·승인문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤
|
* 가맹점/적요 추정: 잡음(카드사·문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤
|
||||||
* 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정)
|
* 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정)
|
||||||
*/
|
*/
|
||||||
private String guessMerchant(String raw) {
|
private String guessMerchant(String raw) {
|
||||||
@@ -118,12 +145,12 @@ public class CardNotificationParser {
|
|||||||
.replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간
|
.replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간
|
||||||
.replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜
|
.replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜
|
||||||
.replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등)
|
.replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등)
|
||||||
.replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|체크|신용", " ")
|
.replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|이체|송금|체크|신용", " ")
|
||||||
.replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동)
|
.replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동)
|
||||||
for (String c : ISSUERS) {
|
for (String c : ISSUERS) {
|
||||||
s = s.replace(c, " ");
|
s = s.replace(c, " ");
|
||||||
}
|
}
|
||||||
s = s.replace("카드", " ");
|
s = s.replace("카드", " ").replace("은행", " ");
|
||||||
String[] tokens = s.trim().split("\\s+");
|
String[] tokens = s.trim().split("\\s+");
|
||||||
String best = null;
|
String best = null;
|
||||||
for (String t : tokens) {
|
for (String t : tokens) {
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
package com.sb.web.account.service;
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
import com.sb.web.account.domain.AccountCategory;
|
import com.sb.web.account.domain.AccountCategory;
|
||||||
|
import com.sb.web.account.domain.DefaultCategory;
|
||||||
import com.sb.web.account.dto.CategoryRequest;
|
import com.sb.web.account.dto.CategoryRequest;
|
||||||
import com.sb.web.account.dto.CategoryResponse;
|
import com.sb.web.account.dto.CategoryResponse;
|
||||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||||
import com.sb.web.account.mapper.BudgetMapper;
|
import com.sb.web.account.mapper.BudgetMapper;
|
||||||
import com.sb.web.account.mapper.CategoryMapper;
|
import com.sb.web.account.mapper.CategoryMapper;
|
||||||
|
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||||
import com.sb.web.common.exception.ApiException;
|
import com.sb.web.common.exception.ApiException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기.
|
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,6 +28,7 @@ public class CategoryService {
|
|||||||
private final CategoryMapper categoryMapper;
|
private final CategoryMapper categoryMapper;
|
||||||
private final AccountEntryMapper entryMapper;
|
private final AccountEntryMapper entryMapper;
|
||||||
private final BudgetMapper budgetMapper;
|
private final BudgetMapper budgetMapper;
|
||||||
|
private final DefaultCategoryMapper defaultCategoryMapper;
|
||||||
|
|
||||||
public List<CategoryResponse> list(Long memberId) {
|
public List<CategoryResponse> list(Long memberId) {
|
||||||
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
|
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
|
||||||
@@ -35,14 +40,30 @@ public class CategoryService {
|
|||||||
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||||
}
|
}
|
||||||
|
validateParent(req.getParentId(), req.getType(), memberId);
|
||||||
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
|
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
|
||||||
int order = (max == null ? 0 : max + 1);
|
int order = (max == null ? 0 : max + 1);
|
||||||
AccountCategory c = AccountCategory.builder()
|
AccountCategory c = AccountCategory.builder()
|
||||||
.memberId(memberId).type(req.getType()).name(name).sortOrder(order).build();
|
.memberId(memberId).type(req.getType()).name(name)
|
||||||
|
.parentId(req.getParentId()).sortOrder(order).build();
|
||||||
categoryMapper.insert(c);
|
categoryMapper.insert(c);
|
||||||
return CategoryResponse.from(c);
|
return CategoryResponse.from(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 대분류 지정 검증: null=대분류 / 값=대분류여야(2단계 제한·동일타입·본인) */
|
||||||
|
private void validateParent(Long parentId, String type, Long memberId) {
|
||||||
|
if (parentId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AccountCategory parent = categoryMapper.findByIdAndMember(parentId, memberId);
|
||||||
|
if (parent == null || !parent.getType().equals(type)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
if (parent.getParentId() != null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
|
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
|
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
|
||||||
@@ -56,7 +77,8 @@ public class CategoryService {
|
|||||||
return list(memberId);
|
return list(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */
|
/** 이름 변경 + 대분류 이동. 이름 변경 시 기존 내역·예산의 분류명도 함께 갱신.
|
||||||
|
* 주의: parentId 는 항상 현재값(또는 새 대분류)을 보내야 한다(미전송 시 대분류로 풀림). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
||||||
AccountCategory c = mustFind(id, memberId);
|
AccountCategory c = mustFind(id, memberId);
|
||||||
@@ -64,8 +86,20 @@ public class CategoryService {
|
|||||||
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||||
}
|
}
|
||||||
|
Long newParent = req.getParentId();
|
||||||
|
if (newParent != null) {
|
||||||
|
if (newParent.equals(id)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
|
||||||
|
}
|
||||||
|
// 소분류를 가진 대분류는 소분류로 바꿀 수 없음(3단계 방지)
|
||||||
|
if (categoryMapper.countChildren(memberId, id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateParent(newParent, c.getType(), memberId);
|
||||||
String oldName = c.getName();
|
String oldName = c.getName();
|
||||||
c.setName(newName);
|
c.setName(newName);
|
||||||
|
c.setParentId(newParent);
|
||||||
categoryMapper.update(c);
|
categoryMapper.update(c);
|
||||||
if (!oldName.equals(newName)) {
|
if (!oldName.equals(newName)) {
|
||||||
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
||||||
@@ -79,21 +113,48 @@ public class CategoryService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long memberId) {
|
public void delete(Long id, Long memberId) {
|
||||||
mustFind(id, memberId);
|
mustFind(id, memberId);
|
||||||
|
if (categoryMapper.countChildren(memberId, id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||||
|
}
|
||||||
categoryMapper.delete(id, memberId);
|
categoryMapper.delete(id, memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */
|
/** 관리자가 정의한 기본(디폴트) 분류를 내 분류로 복사 (대/소분류 계층 유지, 같은 이름은 건너뜀) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<CategoryResponse> importFromEntries(Long memberId) {
|
public List<CategoryResponse> importDefaults(Long memberId) {
|
||||||
for (String type : new String[]{"INCOME", "EXPENSE"}) {
|
List<DefaultCategory> defaults = defaultCategoryMapper.findAll();
|
||||||
for (String name : entryMapper.findDistinctCategories(memberId, type)) {
|
// 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑
|
||||||
categoryMapper.insertIgnore(AccountCategory.builder()
|
Map<Long, Long> majorMap = new HashMap<>();
|
||||||
.memberId(memberId).type(type).name(name).sortOrder(0).build());
|
for (DefaultCategory d : defaults) {
|
||||||
|
if (d.getParentId() != null) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
majorMap.put(d.getId(), ensureCategory(memberId, d.getType(), d.getName(), null));
|
||||||
|
}
|
||||||
|
// 2) 소분류 생성 (매핑된 대분류 아래; 부모 매핑이 없으면 대분류로 취급)
|
||||||
|
for (DefaultCategory d : defaults) {
|
||||||
|
if (d.getParentId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensureCategory(memberId, d.getType(), d.getName(), majorMap.get(d.getParentId()));
|
||||||
}
|
}
|
||||||
return list(memberId);
|
return list(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 같은 이름 분류가 있으면 그 id, 없으면 생성해서 id 반환 */
|
||||||
|
private Long ensureCategory(Long memberId, String type, String name, Long parentId) {
|
||||||
|
AccountCategory existing = categoryMapper.findByMemberTypeName(memberId, type, name);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing.getId();
|
||||||
|
}
|
||||||
|
Integer max = categoryMapper.maxSortOrder(memberId, type);
|
||||||
|
AccountCategory c = AccountCategory.builder()
|
||||||
|
.memberId(memberId).type(type).name(name).parentId(parentId)
|
||||||
|
.sortOrder(max == null ? 0 : max + 1).build();
|
||||||
|
categoryMapper.insert(c);
|
||||||
|
return c.getId();
|
||||||
|
}
|
||||||
|
|
||||||
private AccountCategory mustFind(Long id, Long memberId) {
|
private AccountCategory mustFind(Long id, Long memberId) {
|
||||||
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.DefaultCategory;
|
||||||
|
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본(디폴트) 분류 초기 시드. default_category 가 비어 있을 때만 한 번 생성한다.
|
||||||
|
* (관리자가 이후 자유롭게 수정/삭제 가능 — 비어 있을 때만 채우므로 운영 데이터를 덮어쓰지 않음)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DefaultCategorySeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final DefaultCategoryMapper mapper;
|
||||||
|
|
||||||
|
// 대분류 → 소분류 목록 (입력 순서 유지)
|
||||||
|
private static final Map<String, List<String>> EXPENSE = new LinkedHashMap<>();
|
||||||
|
private static final Map<String, List<String>> INCOME = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
EXPENSE.put("식비", List.of("외식", "식료품", "카페/간식", "배달"));
|
||||||
|
EXPENSE.put("교통", List.of("대중교통", "택시", "주유", "주차/통행료"));
|
||||||
|
EXPENSE.put("주거/통신", List.of("월세/관리비", "공과금", "통신비", "인터넷"));
|
||||||
|
EXPENSE.put("생활", List.of("생필품", "의류/미용", "가구/가전"));
|
||||||
|
EXPENSE.put("건강/의료", List.of("병원", "약국", "운동"));
|
||||||
|
EXPENSE.put("문화/여가", List.of("영화/공연", "여행", "취미", "구독서비스"));
|
||||||
|
EXPENSE.put("교육", List.of("학원", "도서", "강의"));
|
||||||
|
EXPENSE.put("경조사", List.of("축의금", "부의금", "선물"));
|
||||||
|
EXPENSE.put("금융", List.of("보험", "대출이자", "수수료"));
|
||||||
|
EXPENSE.put("기타", List.of());
|
||||||
|
|
||||||
|
INCOME.put("급여", List.of("월급", "상여금"));
|
||||||
|
INCOME.put("부수입", List.of("용돈", "이자/배당", "환급"));
|
||||||
|
INCOME.put("기타수입", List.of("중고판매", "기타"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (!mapper.findAll().isEmpty()) {
|
||||||
|
return; // 이미 데이터가 있으면 시드하지 않음
|
||||||
|
}
|
||||||
|
int n = seed("EXPENSE", EXPENSE) + seed("INCOME", INCOME);
|
||||||
|
log.info("[default-category] 기본 분류 시드 생성: {}건", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int seed(String type, Map<String, List<String>> tree) {
|
||||||
|
int count = 0;
|
||||||
|
int majorOrder = 0;
|
||||||
|
for (Map.Entry<String, List<String>> e : tree.entrySet()) {
|
||||||
|
DefaultCategory major = DefaultCategory.builder()
|
||||||
|
.type(type).name(e.getKey()).parentId(null).sortOrder(majorOrder++).build();
|
||||||
|
mapper.insert(major); // 생성키(id) 채워짐
|
||||||
|
count++;
|
||||||
|
int subOrder = 0;
|
||||||
|
for (String child : e.getValue()) {
|
||||||
|
mapper.insert(DefaultCategory.builder()
|
||||||
|
.type(type).name(child).parentId(major.getId()).sortOrder(subOrder++).build());
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.DefaultCategory;
|
||||||
|
import com.sb.web.account.dto.CategoryReorderRequest;
|
||||||
|
import com.sb.web.account.dto.CategoryRequest;
|
||||||
|
import com.sb.web.account.dto.CategoryResponse;
|
||||||
|
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||||
|
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). 2단계(대/소분류) 제한.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DefaultCategoryService {
|
||||||
|
|
||||||
|
private final DefaultCategoryMapper mapper;
|
||||||
|
|
||||||
|
public List<CategoryResponse> list() {
|
||||||
|
return mapper.findAll().stream().map(this::toResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CategoryResponse create(CategoryRequest req) {
|
||||||
|
String name = req.getName().trim();
|
||||||
|
if (mapper.countByName(req.getType(), name, null) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||||
|
}
|
||||||
|
validateParent(req.getParentId(), req.getType());
|
||||||
|
Integer max = mapper.maxSortOrder(req.getType());
|
||||||
|
DefaultCategory c = DefaultCategory.builder()
|
||||||
|
.type(req.getType()).name(name).parentId(req.getParentId())
|
||||||
|
.sortOrder(max == null ? 0 : max + 1).build();
|
||||||
|
mapper.insert(c);
|
||||||
|
return toResponse(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CategoryResponse update(Long id, CategoryRequest req) {
|
||||||
|
DefaultCategory c = mustFind(id);
|
||||||
|
String newName = req.getName().trim();
|
||||||
|
if (mapper.countByName(c.getType(), newName, id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||||
|
}
|
||||||
|
Long newParent = req.getParentId();
|
||||||
|
if (newParent != null) {
|
||||||
|
if (newParent.equals(id)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (mapper.countChildren(id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateParent(newParent, c.getType());
|
||||||
|
c.setName(newName);
|
||||||
|
c.setParentId(newParent);
|
||||||
|
mapper.update(c);
|
||||||
|
return toResponse(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
mustFind(id);
|
||||||
|
if (mapper.countChildren(id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||||
|
}
|
||||||
|
mapper.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<CategoryResponse> reorder(CategoryReorderRequest req) {
|
||||||
|
int i = 0;
|
||||||
|
for (Long id : req.getIds()) {
|
||||||
|
DefaultCategory c = mapper.findById(id);
|
||||||
|
if (c != null && c.getType().equals(req.getType())) {
|
||||||
|
mapper.updateSortOrder(id, i++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateParent(Long parentId, String type) {
|
||||||
|
if (parentId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DefaultCategory parent = mapper.findById(parentId);
|
||||||
|
if (parent == null || !parent.getType().equals(type)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
if (parent.getParentId() != null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefaultCategory mustFind(Long id) {
|
||||||
|
DefaultCategory c = mapper.findById(id);
|
||||||
|
if (c == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "기본 분류를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryResponse toResponse(DefaultCategory c) {
|
||||||
|
return CategoryResponse.builder()
|
||||||
|
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환율 조회. open.er-api.com(무료·무인증) 사용. 통화(base)별로 하루 1회만 호출하고 캐시.
|
||||||
|
* 실패해도 예외를 던지지 않고 null(또는 직전 캐시) 반환.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class FxService {
|
||||||
|
|
||||||
|
private static final String URL = "https://open.er-api.com/v6/latest/{base}";
|
||||||
|
private final RestClient restClient = RestClient.builder().build();
|
||||||
|
|
||||||
|
private record Cached(LocalDate date, JsonNode rates) {}
|
||||||
|
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
|
||||||
|
public BigDecimal getRate(String from, String to) {
|
||||||
|
if (from == null || to == null) return null;
|
||||||
|
String base = from.trim().toUpperCase();
|
||||||
|
String target = to.trim().toUpperCase();
|
||||||
|
if (base.isEmpty() || target.isEmpty()) return null;
|
||||||
|
if (base.equals(target)) return BigDecimal.ONE;
|
||||||
|
JsonNode rates = ratesFor(base);
|
||||||
|
if (rates == null) return null;
|
||||||
|
JsonNode r = rates.get(target);
|
||||||
|
return (r != null && r.isNumber()) ? r.decimalValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode ratesFor(String base) {
|
||||||
|
Cached c = cache.get(base);
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
if (c != null && c.date().equals(today)) return c.rates();
|
||||||
|
try {
|
||||||
|
JsonNode root = restClient.get()
|
||||||
|
.uri(URL, base)
|
||||||
|
.header("User-Agent", "Mozilla/5.0")
|
||||||
|
.retrieve()
|
||||||
|
.body(JsonNode.class);
|
||||||
|
if (root == null || !"success".equals(root.path("result").asText())) {
|
||||||
|
return c != null ? c.rates() : null;
|
||||||
|
}
|
||||||
|
JsonNode rates = root.path("rates");
|
||||||
|
if (rates.isMissingNode() || !rates.isObject()) {
|
||||||
|
return c != null ? c.rates() : null;
|
||||||
|
}
|
||||||
|
cache.put(base, new Cached(today, rates));
|
||||||
|
return rates;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("환율 조회 실패 base={}: {}", base, e.toString());
|
||||||
|
return c != null ? c.rates() : null; // 실패 시 직전 캐시라도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,50 @@ public class InvestService {
|
|||||||
return TradeResponse.from(t);
|
return TradeResponse.from(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public TradeResponse updateTrade(Long id, TradeRequest req, Long memberId) {
|
||||||
|
InvestTrade existing = investMapper.findTradeByIdAndMember(id, memberId);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
Long holdingId = existing.getHoldingId();
|
||||||
|
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
|
||||||
|
long fee = req.getFee() != null ? req.getFee() : 0L;
|
||||||
|
|
||||||
|
// 편집을 반영한 시점별 시뮬레이션 — 어느 시점에도 보유수량이 음수가 되면 거부
|
||||||
|
java.util.List<InvestTrade> sim = new java.util.ArrayList<>();
|
||||||
|
for (InvestTrade t : investMapper.findTradesByHolding(holdingId, memberId)) {
|
||||||
|
if (t.getId().equals(id)) {
|
||||||
|
sim.add(InvestTrade.builder().id(id).tradeType(type).tradeDate(req.getTradeDate())
|
||||||
|
.quantity(req.getQuantity()).price(req.getPrice()).fee(fee).build());
|
||||||
|
} else {
|
||||||
|
sim.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sim.sort(java.util.Comparator.comparing(InvestTrade::getTradeDate)
|
||||||
|
.thenComparing(InvestTrade::getId));
|
||||||
|
BigDecimal running = BigDecimal.ZERO;
|
||||||
|
for (InvestTrade t : sim) {
|
||||||
|
if ("BUY".equals(t.getTradeType())) {
|
||||||
|
running = running.add(t.getQuantity());
|
||||||
|
} else {
|
||||||
|
running = running.subtract(t.getQuantity());
|
||||||
|
if (running.signum() < 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||||
|
"수정하면 보유수량이 음수가 됩니다. 매도 수량/순서를 확인하세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.setTradeType(type);
|
||||||
|
existing.setTradeDate(req.getTradeDate());
|
||||||
|
existing.setQuantity(req.getQuantity());
|
||||||
|
existing.setPrice(req.getPrice());
|
||||||
|
existing.setFee(fee);
|
||||||
|
investMapper.updateTrade(existing);
|
||||||
|
return TradeResponse.from(existing);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteTrade(Long id, Long memberId) {
|
public void deleteTrade(Long id, Long memberId) {
|
||||||
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
|
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.sb.web.account.domain.QuickEntry;
|
||||||
|
import com.sb.web.account.dto.QuickEntryRequest;
|
||||||
|
import com.sb.web.account.dto.QuickEntryResponse;
|
||||||
|
import com.sb.web.account.mapper.QuickEntryMapper;
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자주 쓰는 내역(빠른 등록 템플릿) 서비스 — 사용자별.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QuickEntryService {
|
||||||
|
|
||||||
|
private final QuickEntryMapper mapper;
|
||||||
|
|
||||||
|
public List<QuickEntryResponse> list(Long memberId) {
|
||||||
|
return mapper.findByMember(memberId).stream().map(QuickEntryResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public QuickEntryResponse create(QuickEntryRequest req, Long memberId) {
|
||||||
|
Integer max = mapper.maxSortOrder(memberId);
|
||||||
|
QuickEntry e = QuickEntry.builder()
|
||||||
|
.memberId(memberId)
|
||||||
|
.label(blankToNull(req.getLabel()))
|
||||||
|
.type(req.getType())
|
||||||
|
.category("EXPENSE".equals(req.getType()) || "INCOME".equals(req.getType()) ? blankToNull(req.getCategory()) : null)
|
||||||
|
.amount(req.getAmount())
|
||||||
|
.memo(blankToNull(req.getMemo()))
|
||||||
|
.walletId(req.getWalletId())
|
||||||
|
.sortOrder(max == null ? 0 : max + 1)
|
||||||
|
.build();
|
||||||
|
mapper.insert(e);
|
||||||
|
return QuickEntryResponse.from(mapper.findByIdAndMember(e.getId(), memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id, Long memberId) {
|
||||||
|
if (mapper.findByIdAndMember(id, memberId) == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "자주 쓰는 내역을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
mapper.delete(id, memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,15 +83,19 @@ public class RecurringService {
|
|||||||
|
|
||||||
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
|
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
|
||||||
boolean transfer = "TRANSFER".equals(r.getType());
|
boolean transfer = "TRANSFER".equals(r.getType());
|
||||||
|
// 메모가 없으면 제목으로 채워 '확인 필요' 목록에서 알아보기 쉽게 한다.
|
||||||
|
String memo = (r.getMemo() != null && !r.getMemo().isBlank()) ? r.getMemo() : r.getTitle();
|
||||||
return AccountEntry.builder()
|
return AccountEntry.builder()
|
||||||
.memberId(memberId)
|
.memberId(memberId)
|
||||||
.entryDate(date)
|
.entryDate(date)
|
||||||
.type(r.getType())
|
.type(r.getType())
|
||||||
.category(transfer ? null : r.getCategory())
|
.category(transfer ? null : r.getCategory())
|
||||||
.amount(r.getAmount())
|
.amount(r.getAmount())
|
||||||
.memo(r.getMemo())
|
.memo(memo)
|
||||||
.walletId(r.getWalletId())
|
.walletId(r.getWalletId())
|
||||||
.toWalletId(transfer ? r.getToWalletId() : null)
|
.toWalletId(transfer ? r.getToWalletId() : null)
|
||||||
|
// 실제 결제/이체가 일어났는지 사용자가 확인하도록 '확인 필요'로 생성
|
||||||
|
.pending(true)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.sb.web.admin.controller;
|
||||||
|
|
||||||
|
import com.sb.web.account.dto.CategoryReorderRequest;
|
||||||
|
import com.sb.web.account.dto.CategoryRequest;
|
||||||
|
import com.sb.web.account.dto.CategoryResponse;
|
||||||
|
import com.sb.web.account.service.DefaultCategoryService;
|
||||||
|
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 /default-categories 기본 분류 목록
|
||||||
|
* POST /default-categories 생성(대/소분류)
|
||||||
|
* PUT /default-categories/{id} 수정(이름·대분류)
|
||||||
|
* DELETE /default-categories/{id} 삭제
|
||||||
|
* PUT /default-categories/reorder 순서 변경
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/default-categories")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminCategoryController {
|
||||||
|
|
||||||
|
private final DefaultCategoryService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<CategoryResponse> list() {
|
||||||
|
return service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<CategoryResponse> create(@Valid @RequestBody CategoryRequest req) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public CategoryResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest req) {
|
||||||
|
return service.update(id, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
|
service.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
public List<CategoryResponse> reorder(@RequestBody CategoryReorderRequest req) {
|
||||||
|
return service.reorder(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.sb.web.admin.controller;
|
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.TagCategoryRequest;
|
||||||
import com.sb.web.board.dto.TagCategoryResponse;
|
import com.sb.web.board.dto.TagCategoryResponse;
|
||||||
import com.sb.web.board.dto.TagRequest;
|
import com.sb.web.board.dto.TagRequest;
|
||||||
@@ -68,16 +66,4 @@ public class AdminTagController {
|
|||||||
tagService.deleteTag(id);
|
tagService.deleteTag(id);
|
||||||
return ResponseEntity.noContent().build();
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.sb.web.admin.controller;
|
package com.sb.web.admin.controller;
|
||||||
|
|
||||||
|
import com.sb.web.admin.dto.PlanUpdateRequest;
|
||||||
import com.sb.web.admin.dto.RoleUpdateRequest;
|
import com.sb.web.admin.dto.RoleUpdateRequest;
|
||||||
import com.sb.web.admin.dto.StatusUpdateRequest;
|
import com.sb.web.admin.dto.StatusUpdateRequest;
|
||||||
import com.sb.web.admin.service.MemberAdminService;
|
import com.sb.web.admin.service.MemberAdminService;
|
||||||
@@ -36,6 +37,14 @@ public class MemberAdminController {
|
|||||||
return memberAdminService.updateRole(id, req.getRole(), current.getId());
|
return memberAdminService.updateRole(id, req.getRole(), current.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/plan")
|
||||||
|
public MemberAdminResponse updatePlan(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody PlanUpdateRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return memberAdminService.updatePlan(id, req.getPlan(), current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/status")
|
@PutMapping("/{id}/status")
|
||||||
public MemberAdminResponse updateStatus(
|
public MemberAdminResponse updateStatus(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sb.web.admin.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 멤버십 플랜 변경 요청.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PlanUpdateRequest {
|
||||||
|
|
||||||
|
@Pattern(regexp = "FREE|PREMIUM", message = "플랜은 FREE 또는 PREMIUM 이어야 합니다.")
|
||||||
|
private String plan;
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ public class MemberAdminService {
|
|||||||
return MemberAdminResponse.from(target);
|
return MemberAdminResponse.from(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MemberAdminResponse updatePlan(Long targetId, String plan, Long currentAdminId) {
|
||||||
|
Member target = mustFind(targetId);
|
||||||
|
memberMapper.updatePlan(targetId, plan);
|
||||||
|
target.setPlan(plan);
|
||||||
|
return MemberAdminResponse.from(target);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
|
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
|
||||||
Member target = mustFind(targetId);
|
Member target = mustFind(targetId);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class AuthController {
|
|||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||||
|
private final com.sb.web.point.PointService pointService;
|
||||||
|
|
||||||
/** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */
|
/** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */
|
||||||
@GetMapping("/signup-enabled")
|
@GetMapping("/signup-enabled")
|
||||||
@@ -52,8 +53,20 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public LoginResponse login(@Valid @RequestBody LoginRequest req) {
|
public LoginResponse login(@Valid @RequestBody LoginRequest req, HttpServletRequest request) {
|
||||||
return authService.login(req);
|
return authService.login(req, clientIp(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구글 OAuth 클라이언트 ID (비보호) — 프론트가 구글 버튼 노출/초기화에 사용. 미설정 시 빈 문자열 */
|
||||||
|
@GetMapping("/google-client-id")
|
||||||
|
public java.util.Map<String, String> googleClientId() {
|
||||||
|
return java.util.Map.of("clientId", authService.googleClientId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구글 로그인/가입 (비보호) — ID 토큰 검증 후 세션 발급 */
|
||||||
|
@PostMapping("/google")
|
||||||
|
public LoginResponse googleLogin(@Valid @RequestBody com.sb.web.auth.dto.GoogleLoginRequest req) {
|
||||||
|
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
@@ -67,6 +80,29 @@ public class AuthController {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 회원 탈퇴 — 본인 계정과 모든 데이터 삭제 */
|
||||||
|
@DeleteMapping("/me")
|
||||||
|
public ResponseEntity<Void> withdraw(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
authService.withdraw(current.getId(), AuthInterceptor.resolveToken(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */
|
||||||
|
@GetMapping("/points")
|
||||||
|
public java.util.Map<String, Long> points(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return java.util.Map.of("points", pointService.getPoints(current.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 포인트 적립/차감 내역 (최신순) */
|
||||||
|
@GetMapping("/point-history")
|
||||||
|
public java.util.List<com.sb.web.point.PointHistoryResponse> pointHistory(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return pointService.getHistory(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
public ResponseEntity<Void> changePassword(
|
public ResponseEntity<Void> changePassword(
|
||||||
@Valid @RequestBody PasswordChangeRequest req,
|
@Valid @RequestBody PasswordChangeRequest req,
|
||||||
@@ -84,6 +120,12 @@ public class AuthController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 회원 전체 프로필 (아바타·포인트 포함) — 재로그인 없이 최신값 동기화 */
|
||||||
|
@GetMapping("/profile")
|
||||||
|
public MemberResponse profile(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return authService.getProfile(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
/** 가입정보(이름/이메일) 변경 */
|
/** 가입정보(이름/이메일) 변경 */
|
||||||
@PutMapping("/profile")
|
@PutMapping("/profile")
|
||||||
public MemberResponse updateProfile(
|
public MemberResponse updateProfile(
|
||||||
@@ -92,4 +134,12 @@ public class AuthController {
|
|||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
|
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */
|
||||||
|
@PutMapping("/profile-image")
|
||||||
|
public MemberResponse updateProfileImage(
|
||||||
|
@RequestBody com.sb.web.auth.dto.ProfileImageRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return authService.updateProfileImage(current.getId(), req.getImage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class AuthSession {
|
|||||||
private String loginId;
|
private String loginId;
|
||||||
private String name;
|
private String name;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan;
|
||||||
private String provider;
|
private String provider;
|
||||||
private boolean rememberMe;
|
private boolean rememberMe;
|
||||||
private LocalDateTime expiresAt;
|
private LocalDateTime expiresAt;
|
||||||
@@ -34,6 +35,7 @@ public class AuthSession {
|
|||||||
.loginId(u.getLoginId())
|
.loginId(u.getLoginId())
|
||||||
.name(u.getName())
|
.name(u.getName())
|
||||||
.role(u.getRole())
|
.role(u.getRole())
|
||||||
|
.plan(u.getPlan())
|
||||||
.provider(u.getProvider())
|
.provider(u.getProvider())
|
||||||
.rememberMe(u.isRememberMe())
|
.rememberMe(u.isRememberMe())
|
||||||
.expiresAt(expiresAt)
|
.expiresAt(expiresAt)
|
||||||
@@ -46,6 +48,7 @@ public class AuthSession {
|
|||||||
.loginId(loginId)
|
.loginId(loginId)
|
||||||
.name(name)
|
.name(name)
|
||||||
.role(role)
|
.role(role)
|
||||||
|
.plan(plan)
|
||||||
.provider(provider)
|
.provider(provider)
|
||||||
.rememberMe(rememberMe)
|
.rememberMe(rememberMe)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ public class Member implements Serializable {
|
|||||||
private String password; // BCrypt 해시 (소셜 전용은 null)
|
private String password; // BCrypt 해시 (소셜 전용은 null)
|
||||||
private String name;
|
private String name;
|
||||||
private String email;
|
private String email;
|
||||||
private String provider; // LOCAL / NAVER
|
private String provider; // LOCAL / NAVER / GOOGLE
|
||||||
private String providerId; // 소셜 제공자 측 고유 ID
|
private String providerId; // 소셜 제공자 측 고유 ID
|
||||||
|
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
|
||||||
|
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
|
||||||
|
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
|
||||||
private String role; // USER / ADMIN
|
private String role; // USER / ADMIN
|
||||||
|
private String plan; // FREE / PREMIUM (멤버십)
|
||||||
|
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
|
||||||
|
private String planProduct; // 구독 상품 ID (premium_monthly 등)
|
||||||
|
private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false)
|
||||||
|
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구글 로그인 요청 — 프론트(Google Identity Services)에서 받은 ID 토큰.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class GoogleLoginRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "토큰이 없습니다.")
|
||||||
|
private String idToken;
|
||||||
|
|
||||||
|
private boolean rememberMe;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public class MemberAdminResponse {
|
|||||||
private String name;
|
private String name;
|
||||||
private String email;
|
private String email;
|
||||||
private String role; // USER / ADMIN
|
private String role; // USER / ADMIN
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private String provider; // LOCAL / 소셜
|
private String provider; // LOCAL / 소셜
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -29,6 +30,7 @@ public class MemberAdminResponse {
|
|||||||
.name(m.getName())
|
.name(m.getName())
|
||||||
.email(m.getEmail())
|
.email(m.getEmail())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
.status(m.getStatus())
|
.status(m.getStatus())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.createdAt(m.getCreatedAt())
|
.createdAt(m.getCreatedAt())
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ public class MemberResponse {
|
|||||||
private String email;
|
private String email;
|
||||||
private String provider;
|
private String provider;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
|
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
|
||||||
|
private String planProduct; // 구독 상품 ID
|
||||||
|
private Boolean planAutoRenew; // 자동 갱신 여부
|
||||||
|
private Long points; // 활동 포인트
|
||||||
|
private String googlePicture; // 구글 아바타 URL
|
||||||
|
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
||||||
|
|
||||||
public static MemberResponse from(Member m) {
|
public static MemberResponse from(Member m) {
|
||||||
return MemberResponse.builder()
|
return MemberResponse.builder()
|
||||||
@@ -26,6 +33,13 @@ public class MemberResponse {
|
|||||||
.email(m.getEmail())
|
.email(m.getEmail())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
|
.planExpiresAt(m.getPlanExpiresAt())
|
||||||
|
.planProduct(m.getPlanProduct())
|
||||||
|
.planAutoRenew(m.getPlanAutoRenew())
|
||||||
|
.points(m.getPoints())
|
||||||
|
.googlePicture(m.getGooglePicture())
|
||||||
|
.profileImage(m.getProfileImage())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ProfileImageRequest {
|
||||||
|
|
||||||
|
/** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */
|
||||||
|
private String image;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public class SessionUser implements Serializable {
|
|||||||
private String loginId;
|
private String loginId;
|
||||||
private String name;
|
private String name;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
private String provider;
|
private String provider;
|
||||||
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
|
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public class SessionUser implements Serializable {
|
|||||||
.loginId(m.getLoginId())
|
.loginId(m.getLoginId())
|
||||||
.name(m.getName())
|
.name(m.getName())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
|
|||||||
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
||||||
int updateName(@Param("token") String token, @Param("name") String name);
|
int updateName(@Param("token") String token, @Param("name") String name);
|
||||||
|
|
||||||
|
/** 멤버십 변경 시 백업 세션의 plan 동기화 */
|
||||||
|
int updatePlan(@Param("token") String token, @Param("plan") String plan);
|
||||||
|
|
||||||
int delete(@Param("token") String token);
|
int delete(@Param("token") String token);
|
||||||
|
|
||||||
int deleteExpired(@Param("now") LocalDateTime now);
|
int deleteExpired(@Param("now") LocalDateTime now);
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ public interface MemberMapper {
|
|||||||
Member findByProvider(@Param("provider") String provider,
|
Member findByProvider(@Param("provider") String provider,
|
||||||
@Param("providerId") String providerId);
|
@Param("providerId") String providerId);
|
||||||
|
|
||||||
|
/** 구글 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 구글 계정 모두). */
|
||||||
|
Member findByGoogleId(@Param("googleId") String googleId);
|
||||||
|
|
||||||
|
/** 구글 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 미연결만). */
|
||||||
|
Member findByEmailForLink(@Param("email") String email);
|
||||||
|
|
||||||
|
/** 기존 계정에 구글 sub 연결 (provider 유지). */
|
||||||
|
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId);
|
||||||
|
|
||||||
int countByLoginId(@Param("loginId") String loginId);
|
int countByLoginId(@Param("loginId") String loginId);
|
||||||
|
|
||||||
int insert(Member member);
|
int insert(Member member);
|
||||||
@@ -31,8 +40,27 @@ public interface MemberMapper {
|
|||||||
/** 프로필(이름/이메일) 변경 */
|
/** 프로필(이름/이메일) 변경 */
|
||||||
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
|
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
|
||||||
|
|
||||||
|
/** 구글 로그인 시 구글 아바타 URL 동기화 */
|
||||||
|
int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture);
|
||||||
|
|
||||||
|
/** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */
|
||||||
|
int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage);
|
||||||
|
|
||||||
int updateRole(@Param("id") Long id, @Param("role") String role);
|
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||||
|
|
||||||
|
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
|
||||||
|
|
||||||
|
/** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */
|
||||||
|
int updateMembership(@Param("id") Long id, @Param("plan") String plan,
|
||||||
|
@Param("expiresAt") java.time.LocalDateTime expiresAt,
|
||||||
|
@Param("product") String product, @Param("autoRenew") boolean autoRenew);
|
||||||
|
|
||||||
|
/** 구독 해지/재개 (자동 갱신 플래그) */
|
||||||
|
int updateAutoRenew(@Param("id") Long id, @Param("autoRenew") boolean autoRenew);
|
||||||
|
|
||||||
|
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
|
||||||
|
int downgradeExpired();
|
||||||
|
|
||||||
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
|
||||||
int deleteById(@Param("id") Long id);
|
int deleteById(@Param("id") Long id);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.sb.web.auth.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 시 가계부(BackupMapper) 외의 사용자 데이터 정리 — 게시판/포인트/결제/세션.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface WithdrawMapper {
|
||||||
|
|
||||||
|
// 게시판 — 내가 누른 표
|
||||||
|
int deletePostVotesByMember(@Param("memberId") Long memberId);
|
||||||
|
int deleteCommentVotesByMember(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
// 게시판 — 내 글에 달린 것들 (남이 단 댓글/표 포함)
|
||||||
|
int deleteCommentVotesOnMyPosts(@Param("memberId") Long memberId);
|
||||||
|
int deletePostVotesOnMyPosts(@Param("memberId") Long memberId);
|
||||||
|
int deletePostTagsOnMyPosts(@Param("memberId") Long memberId);
|
||||||
|
int deleteCommentsOnMyPosts(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
// 게시판 — 내 댓글(남의 글에 단 것)과 그 표
|
||||||
|
int deleteCommentVotesOnMyComments(@Param("memberId") Long memberId);
|
||||||
|
int deleteCommentsByAuthor(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
// 게시판 — 내 글
|
||||||
|
int deletePostsByAuthor(@Param("memberId") Long memberId);
|
||||||
|
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
// 신고 / 포인트 / 결제 / 세션
|
||||||
|
int deleteReportsByMember(@Param("memberId") Long memberId);
|
||||||
|
int deletePointHistory(@Param("memberId") Long memberId);
|
||||||
|
int deleteIapPurchases(@Param("memberId") Long memberId);
|
||||||
|
int deleteAuthSessions(@Param("memberId") Long memberId);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +38,12 @@ public class AuthService {
|
|||||||
private final RedisTemplate<String, Object> redisTemplate;
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||||
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
||||||
|
private final com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
|
||||||
|
private final com.sb.web.account.mapper.BackupMapper backupMapper;
|
||||||
|
|
||||||
|
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
||||||
|
private String googleClientId;
|
||||||
|
|
||||||
private static final String SESSION_PREFIX = "session:";
|
private static final String SESSION_PREFIX = "session:";
|
||||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
||||||
@@ -46,6 +53,12 @@ public class AuthService {
|
|||||||
private static final int SIGNUP_LIMIT = 5;
|
private static final int SIGNUP_LIMIT = 5;
|
||||||
private static final Duration SIGNUP_WINDOW = Duration.ofHours(1);
|
private static final Duration SIGNUP_WINDOW = Duration.ofHours(1);
|
||||||
|
|
||||||
|
// 무차별 대입 방지 — 실패한 시도만 카운트 (정상 로그인은 영향 없음)
|
||||||
|
private static final int LOGIN_LIMIT = 10;
|
||||||
|
private static final Duration LOGIN_WINDOW = Duration.ofMinutes(10);
|
||||||
|
private static final int VERIFY_PW_LIMIT = 5;
|
||||||
|
private static final Duration VERIFY_PW_WINDOW = Duration.ofMinutes(10);
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberResponse signup(SignupRequest req, String clientIp) {
|
public MemberResponse signup(SignupRequest req, String clientIp) {
|
||||||
if (!appSettingService.isSignupEnabled()) {
|
if (!appSettingService.isSignupEnabled()) {
|
||||||
@@ -57,7 +70,10 @@ public class AuthService {
|
|||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.");
|
||||||
}
|
}
|
||||||
// 2) IP 레이트리밋
|
// 2) IP 레이트리밋
|
||||||
rateLimitSignup(clientIp);
|
if (clientIp != null && !clientIp.isBlank()) {
|
||||||
|
enforceRateLimit("signup:rl:" + clientIp, SIGNUP_LIMIT, SIGNUP_WINDOW,
|
||||||
|
"회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
}
|
||||||
if (memberMapper.countByLoginId(req.getLoginId()) > 0) {
|
if (memberMapper.countByLoginId(req.getLoginId()) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
||||||
}
|
}
|
||||||
@@ -75,37 +91,56 @@ public class AuthService {
|
|||||||
return MemberResponse.from(member);
|
return MemberResponse.from(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IP당 가입 시도 횟수 제한 (Redis 슬라이딩 윈도우). 초과 시 429 */
|
/**
|
||||||
private void rateLimitSignup(String ip) {
|
* Redis 슬라이딩 윈도우 레이트리밋. 윈도우 내 호출이 limit 초과면 429.
|
||||||
if (ip == null || ip.isBlank()) {
|
* Redis 장애 시에는 통과(가용성 우선) — 차단보다 서비스 가용성을 우선한다.
|
||||||
return;
|
*/
|
||||||
}
|
private void enforceRateLimit(String key, int limit, Duration window, String message) {
|
||||||
String key = "signup:rl:" + ip;
|
try {
|
||||||
Long count = redisTemplate.opsForValue().increment(key);
|
Long count = redisTemplate.opsForValue().increment(key);
|
||||||
if (count != null && count == 1L) {
|
if (count != null && count == 1L) {
|
||||||
redisTemplate.expire(key, SIGNUP_WINDOW);
|
redisTemplate.expire(key, window);
|
||||||
}
|
}
|
||||||
if (count != null && count > SIGNUP_LIMIT) {
|
if (count != null && count > limit) {
|
||||||
log.warn("[signup] 레이트리밋 차단 ip={} count={}", ip, count);
|
log.warn("[ratelimit] 차단 key={} count={}", key, count);
|
||||||
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS,
|
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, message);
|
||||||
"회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
}
|
||||||
|
} catch (ApiException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ratelimit] Redis 오류로 레이트리밋 생략: {}", e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoginResponse login(LoginRequest req) {
|
public LoginResponse login(LoginRequest req, String clientIp) {
|
||||||
Member member = memberMapper.findByLoginId(req.getLoginId());
|
Member member = memberMapper.findByLoginId(req.getLoginId());
|
||||||
if (member == null
|
boolean passwordOk = member != null
|
||||||
|| member.getPassword() == null
|
&& member.getPassword() != null
|
||||||
|| !passwordEncoder.matches(req.getPassword(), member.getPassword())) {
|
&& passwordEncoder.matches(req.getPassword(), member.getPassword());
|
||||||
|
if (!passwordOk) {
|
||||||
|
// 실패한 시도만 IP 단위로 카운트 — 무차별 대입 방지(정상 로그인엔 영향 없음)
|
||||||
|
if (clientIp != null && !clientIp.isBlank()) {
|
||||||
|
enforceRateLimit("login:rl:" + clientIp, LOGIN_LIMIT, LOGIN_WINDOW,
|
||||||
|
"로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
}
|
||||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
if (!"ACTIVE".equals(member.getStatus())) {
|
if (!"ACTIVE".equals(member.getStatus())) {
|
||||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||||
}
|
}
|
||||||
|
// 로그인 성공 — 실패 카운터 초기화
|
||||||
|
if (clientIp != null && !clientIp.isBlank()) {
|
||||||
|
try { redisTemplate.delete("login:rl:" + clientIp); } catch (Exception ignore) { /* 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
Duration ttl = req.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
|
return issueSession(member, req.isRememberMe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세션 토큰 발급 → Redis + DB 백업. login/소셜로그인 공용. */
|
||||||
|
private LoginResponse issueSession(Member member, boolean rememberMe) {
|
||||||
|
Duration ttl = rememberMe ? REMEMBER_TTL : SESSION_TTL;
|
||||||
SessionUser session = SessionUser.from(member);
|
SessionUser session = SessionUser.from(member);
|
||||||
session.setRememberMe(req.isRememberMe());
|
session.setRememberMe(rememberMe);
|
||||||
|
|
||||||
String token = UUID.randomUUID().toString().replace("-", "");
|
String token = UUID.randomUUID().toString().replace("-", "");
|
||||||
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
||||||
@@ -114,9 +149,9 @@ public class AuthService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
||||||
}
|
}
|
||||||
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
|
|
||||||
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
|
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
|
||||||
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
|
log.info("[login] member={} provider={} (token issued, rememberMe={})",
|
||||||
|
member.getId(), member.getProvider(), rememberMe);
|
||||||
|
|
||||||
return LoginResponse.builder()
|
return LoginResponse.builder()
|
||||||
.token(token)
|
.token(token)
|
||||||
@@ -125,6 +160,87 @@ public class AuthService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 구글 로그인/가입 — ID 토큰 검증 후 provider=GOOGLE 회원 조회/생성하고 세션 발급. */
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse googleLogin(String idToken, boolean rememberMe) {
|
||||||
|
if (googleClientId == null || googleClientId.isBlank()) {
|
||||||
|
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "구글 로그인이 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
if (idToken == null || idToken.isBlank()) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
|
||||||
|
}
|
||||||
|
// 구글 ID 토큰 검증(서명·만료는 구글이 검증) → 클레임 반환
|
||||||
|
Map<?, ?> info;
|
||||||
|
try {
|
||||||
|
info = org.springframework.web.client.RestClient.create()
|
||||||
|
.get()
|
||||||
|
.uri("https://oauth2.googleapis.com/tokeninfo?id_token={t}", idToken)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "구글 인증에 실패했습니다.");
|
||||||
|
}
|
||||||
|
if (info == null || !googleClientId.equals(String.valueOf(info.get("aud")))) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 구글 토큰입니다.");
|
||||||
|
}
|
||||||
|
String sub = String.valueOf(info.get("sub"));
|
||||||
|
String email = info.get("email") == null ? null : String.valueOf(info.get("email"));
|
||||||
|
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
|
||||||
|
String name = info.get("name") != null ? String.valueOf(info.get("name"))
|
||||||
|
: (email != null ? email.split("@")[0] : "사용자");
|
||||||
|
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
|
||||||
|
|
||||||
|
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
|
||||||
|
Member member = memberMapper.findByGoogleId(sub);
|
||||||
|
|
||||||
|
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 구글 연결 — 중복 계정/데이터 분리 방지
|
||||||
|
if (member == null && email != null && !email.isBlank() && emailVerified) {
|
||||||
|
Member existing = memberMapper.findByEmailForLink(email);
|
||||||
|
if (existing != null) {
|
||||||
|
if (!"ACTIVE".equals(existing.getStatus())) {
|
||||||
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||||
|
}
|
||||||
|
memberMapper.linkGoogle(existing.getId(), sub);
|
||||||
|
existing.setGoogleId(sub);
|
||||||
|
member = existing;
|
||||||
|
log.info("[google] linked to existing member id={} provider={} email={}",
|
||||||
|
member.getId(), member.getProvider(), email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 그래도 없으면 신규 구글 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
|
||||||
|
if (member == null) {
|
||||||
|
if (!appSettingService.isSignupEnabled()) {
|
||||||
|
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
||||||
|
}
|
||||||
|
member = Member.builder()
|
||||||
|
.name(name)
|
||||||
|
.email(email)
|
||||||
|
.provider("GOOGLE")
|
||||||
|
.providerId(sub)
|
||||||
|
.googleId(sub)
|
||||||
|
.googlePicture(picture)
|
||||||
|
.role("USER")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.build();
|
||||||
|
memberMapper.insert(member);
|
||||||
|
log.info("[google] new member id={} email={}", member.getId(), email);
|
||||||
|
}
|
||||||
|
if (!"ACTIVE".equals(member.getStatus())) {
|
||||||
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||||
|
}
|
||||||
|
// 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
|
||||||
|
if (picture != null && !picture.equals(member.getGooglePicture())) {
|
||||||
|
memberMapper.updateGooglePicture(member.getId(), picture);
|
||||||
|
member.setGooglePicture(picture);
|
||||||
|
}
|
||||||
|
return issueSession(member, rememberMe);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String googleClientId() {
|
||||||
|
return googleClientId == null ? "" : googleClientId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||||
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
||||||
@@ -175,6 +291,51 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 — 사용자가 소유한 모든 데이터를 삭제하고 회원을 제거한다.
|
||||||
|
* (게시판 글/댓글/추천 · 가계부 전체 · 포인트 · 결제기록 · 세션 → 회원)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void withdraw(Long memberId, String token) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
// 1) 게시판 — 표/댓글/글/이미지 (참조 순서 고려)
|
||||||
|
withdrawMapper.deletePostVotesByMember(memberId);
|
||||||
|
withdrawMapper.deleteCommentVotesByMember(memberId);
|
||||||
|
withdrawMapper.deleteCommentVotesOnMyPosts(memberId);
|
||||||
|
withdrawMapper.deleteCommentVotesOnMyComments(memberId);
|
||||||
|
withdrawMapper.deleteCommentsOnMyPosts(memberId);
|
||||||
|
withdrawMapper.deleteCommentsByAuthor(memberId);
|
||||||
|
withdrawMapper.deletePostVotesOnMyPosts(memberId);
|
||||||
|
withdrawMapper.deletePostTagsOnMyPosts(memberId);
|
||||||
|
withdrawMapper.deletePostsByAuthor(memberId);
|
||||||
|
withdrawMapper.deleteBoardImagesByMember(memberId);
|
||||||
|
// 2) 가계부(계정) 데이터 (복구 삭제와 동일 순서)
|
||||||
|
backupMapper.deleteEntryTags(memberId);
|
||||||
|
backupMapper.deleteEntries(memberId);
|
||||||
|
backupMapper.deleteRecurrings(memberId);
|
||||||
|
backupMapper.deleteBudgets(memberId);
|
||||||
|
backupMapper.deleteBudgetIncome(memberId);
|
||||||
|
backupMapper.deleteQuickEntries(memberId);
|
||||||
|
backupMapper.deleteInvestTrades(memberId);
|
||||||
|
backupMapper.deleteInvestHoldings(memberId);
|
||||||
|
backupMapper.deleteCategories(memberId);
|
||||||
|
backupMapper.deleteTags(memberId);
|
||||||
|
backupMapper.deleteWallets(memberId);
|
||||||
|
// 3) 신고 / 포인트 / 결제 / 세션
|
||||||
|
withdrawMapper.deleteReportsByMember(memberId);
|
||||||
|
withdrawMapper.deletePointHistory(memberId);
|
||||||
|
withdrawMapper.deleteIapPurchases(memberId);
|
||||||
|
withdrawMapper.deleteAuthSessions(memberId);
|
||||||
|
// 4) 회원 삭제
|
||||||
|
memberMapper.deleteById(memberId);
|
||||||
|
// 5) 현재 세션(Redis) 정리
|
||||||
|
logout(token);
|
||||||
|
log.info("[withdraw] member {} ({}) 및 데이터 전체 삭제", memberId, member.getLoginId());
|
||||||
|
}
|
||||||
|
|
||||||
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
|
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
|
||||||
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
|
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
|
||||||
public void cleanupExpiredSessions() {
|
public void cleanupExpiredSessions() {
|
||||||
@@ -198,10 +359,22 @@ public class AuthService {
|
|||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다.");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다.");
|
||||||
}
|
}
|
||||||
if (password == null || !passwordEncoder.matches(password, member.getPassword())) {
|
if (password == null || !passwordEncoder.matches(password, member.getPassword())) {
|
||||||
|
// 실패한 시도만 회원 단위로 카운트 — 비밀번호 게이트 무차별 대입 방지
|
||||||
|
enforceRateLimit("verifypw:rl:" + memberId, VERIFY_PW_LIMIT, VERIFY_PW_WINDOW,
|
||||||
|
"비밀번호 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
||||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다.");
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 회원 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화용 */
|
||||||
|
public MemberResponse getProfile(Long memberId) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
return MemberResponse.from(member);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
||||||
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
||||||
@@ -225,6 +398,31 @@ public class AuthService {
|
|||||||
return MemberResponse.from(member);
|
return MemberResponse.from(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백.
|
||||||
|
* data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MemberResponse updateProfileImage(Long memberId, String image) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
String value = (image == null || image.isBlank()) ? null : image.trim();
|
||||||
|
if (value != null) {
|
||||||
|
if (!value.startsWith("data:image/")) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려)
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memberMapper.updateProfileImage(memberId, value);
|
||||||
|
member.setProfileImage(value);
|
||||||
|
log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId());
|
||||||
|
return MemberResponse.from(member);
|
||||||
|
}
|
||||||
|
|
||||||
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
||||||
private void syncSessionName(String token, String name) {
|
private void syncSessionName(String token, String name) {
|
||||||
if (token == null || token.isBlank()) {
|
if (token == null || token.isBlank()) {
|
||||||
@@ -251,6 +449,32 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 결제/멤버십 변경 시 활성 세션(Redis + DB 백업)의 plan 을 즉시 맞춘다 (재로그인 없이 유료 기능 개방). */
|
||||||
|
public void syncSessionPlan(String token, String plan) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = SESSION_PREFIX + token;
|
||||||
|
try {
|
||||||
|
Object cached = redisTemplate.opsForValue().get(key);
|
||||||
|
if (cached instanceof SessionUser u) {
|
||||||
|
u.setPlan(plan);
|
||||||
|
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
Duration remain = (ttl != null && ttl > 0)
|
||||||
|
? Duration.ofSeconds(ttl)
|
||||||
|
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
||||||
|
redisTemplate.opsForValue().set(key, u, remain);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[billing] Redis 세션 plan 동기화 실패(무시): {}", e.toString());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authSessionMapper.updatePlan(token, plan);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유료(PREMIUM) 전용 경로 보호. AuthInterceptor 가 먼저 세팅한 SessionUser 의 plan 을 검사한다.
|
||||||
|
* 프론트의 메뉴 잠금은 우회 가능하므로, 실제 API 접근은 여기서 차단한다.
|
||||||
|
* 관리자(ADMIN)는 플랜과 무관하게 모든 기능을 사용할 수 있다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PremiumInterceptor 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()) || "PREMIUM".equals(user.getPlan()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
response.getWriter().write("{\"status\":403,\"code\":\"PREMIUM_REQUIRED\",\"message\":\"유료 멤버십 전용 기능입니다.\"}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.sb.web.billing;
|
||||||
|
|
||||||
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import com.sb.web.billing.dto.MembershipResponse;
|
||||||
|
import com.sb.web.billing.dto.ProductResponse;
|
||||||
|
import com.sb.web.billing.dto.SubscriptionResponse;
|
||||||
|
import com.sb.web.billing.dto.VerifyRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인앱 결제 API. (/api/billing/** — 로그인 필요)
|
||||||
|
* GET /products 구독 상품 목록
|
||||||
|
* POST /google/verify Google Play 구매 검증 → 멤버십 부여
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/billing")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BillingController {
|
||||||
|
|
||||||
|
private final BillingService billingService;
|
||||||
|
|
||||||
|
@GetMapping("/products")
|
||||||
|
public List<ProductResponse> products() {
|
||||||
|
return billingService.products();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/google/verify")
|
||||||
|
public MembershipResponse verifyGoogle(
|
||||||
|
@Valid @RequestBody VerifyRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신) */
|
||||||
|
@GetMapping("/subscription")
|
||||||
|
public SubscriptionResponse subscription(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return billingService.getSubscription(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구독 해지 (자동 갱신 중단, 만료일까지 이용) */
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
public SubscriptionResponse cancel(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return billingService.cancel(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구독 재개 (만료 전 자동 갱신 재개) */
|
||||||
|
@PostMapping("/resume")
|
||||||
|
public SubscriptionResponse resume(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return billingService.resume(current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구매 복원 (기기 변경·재설치 시 최근 결제 기준 멤버십 복구) */
|
||||||
|
@PostMapping("/restore")
|
||||||
|
public SubscriptionResponse restore(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return billingService.restore(current.getId(), AuthInterceptor.resolveToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Play 실시간 개발자 알림(RTDN) 수신 — Pub/Sub push (비인증).
|
||||||
|
* 스캐폴드: 수신/로깅만. 실연동 시 메시지 검증 + 구독상태(갱신/해지/환불) 동기화 구현.
|
||||||
|
*/
|
||||||
|
@PostMapping("/google/rtdn")
|
||||||
|
public org.springframework.http.ResponseEntity<Void> rtdn(@RequestBody(required = false) java.util.Map<String, Object> body) {
|
||||||
|
billingService.handleRtdn(body);
|
||||||
|
return org.springframework.http.ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.sb.web.billing;
|
||||||
|
|
||||||
|
import com.sb.web.auth.domain.Member;
|
||||||
|
import com.sb.web.auth.mapper.MemberMapper;
|
||||||
|
import com.sb.web.auth.service.AuthService;
|
||||||
|
import com.sb.web.billing.domain.IapPurchase;
|
||||||
|
import com.sb.web.billing.dto.MembershipResponse;
|
||||||
|
import com.sb.web.billing.dto.ProductResponse;
|
||||||
|
import com.sb.web.billing.dto.SubscriptionResponse;
|
||||||
|
import com.sb.web.billing.dto.VerifyRequest;
|
||||||
|
import com.sb.web.billing.mapper.IapPurchaseMapper;
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인앱 결제(Google Play) 처리 — 상품 목록, 구매 검증 후 멤버십 부여.
|
||||||
|
* 스캐폴드: billing.test-mode=true 면 'test-' 토큰을 실제 검증 없이 승인한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BillingService {
|
||||||
|
|
||||||
|
private final MemberMapper memberMapper;
|
||||||
|
private final IapPurchaseMapper purchaseMapper;
|
||||||
|
private final GooglePlayVerifier googlePlayVerifier;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
/** 실연동 전 기본 true — 'test-' 토큰으로 결제 흐름 테스트 가능 */
|
||||||
|
@Value("${billing.test-mode:true}")
|
||||||
|
private boolean testMode;
|
||||||
|
|
||||||
|
/** 구독 상품 카탈로그 (가격은 표시용 — 실제 금액은 Play Console 기준) */
|
||||||
|
private static final Map<String, ProductResponse> CATALOG = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
CATALOG.put("premium_monthly", new ProductResponse("premium_monthly", "프리미엄 월간", 1, 2900));
|
||||||
|
CATALOG.put("premium_yearly", new ProductResponse("premium_yearly", "프리미엄 연간", 12, 29000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProductResponse> products() {
|
||||||
|
return List.copyOf(CATALOG.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Play 구매 검증 후 멤버십(PREMIUM) 부여/갱신.
|
||||||
|
* 같은 구매 토큰은 한 번만 처리(멱등). 세션 plan 도 즉시 동기화.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MembershipResponse verifyGoogle(Long memberId, String sessionToken, VerifyRequest req) {
|
||||||
|
ProductResponse product = CATALOG.get(req.getProductId());
|
||||||
|
if (product == null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "알 수 없는 상품입니다.");
|
||||||
|
}
|
||||||
|
String token = req.getPurchaseToken();
|
||||||
|
|
||||||
|
// 이미 처리된 결제면 현재 상태 반환 (중복 지급 방지)
|
||||||
|
IapPurchase existing = purchaseMapper.findByToken(token);
|
||||||
|
if (existing != null) {
|
||||||
|
return MembershipResponse.of(mustFind(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean test = testMode || token.startsWith("test-");
|
||||||
|
String platform;
|
||||||
|
if (test) {
|
||||||
|
platform = "TEST";
|
||||||
|
} else {
|
||||||
|
platform = "GOOGLE_PLAY";
|
||||||
|
if (!googlePlayVerifier.verify(req.getProductId(), token)) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "결제 검증에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 만료일이 미래면 그 시점부터 연장, 아니면 지금부터
|
||||||
|
Member member = mustFind(memberId);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime base = (member.getPlanExpiresAt() != null && member.getPlanExpiresAt().isAfter(now))
|
||||||
|
? member.getPlanExpiresAt() : now;
|
||||||
|
LocalDateTime expires = base.plusMonths(product.getMonths());
|
||||||
|
|
||||||
|
memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true);
|
||||||
|
purchaseMapper.insert(IapPurchase.builder()
|
||||||
|
.memberId(memberId).platform(platform).productId(req.getProductId())
|
||||||
|
.purchaseToken(token).orderId(req.getOrderId())
|
||||||
|
.status("VERIFIED").expiresAt(expires).build());
|
||||||
|
authService.syncSessionPlan(sessionToken, "PREMIUM");
|
||||||
|
|
||||||
|
log.info("[billing] PREMIUM granted member={} product={} platform={} expires={}",
|
||||||
|
memberId, req.getProductId(), platform, expires);
|
||||||
|
|
||||||
|
member.setPlan("PREMIUM");
|
||||||
|
member.setPlanExpiresAt(expires);
|
||||||
|
return MembershipResponse.of(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 구독 현황 */
|
||||||
|
public SubscriptionResponse getSubscription(Long memberId) {
|
||||||
|
return toSubscription(mustFind(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구매 복원 — 기기 변경·재설치 시 최근 결제 기록 기준으로 멤버십을 복구한다.
|
||||||
|
* (실연동에서는 Google Play 의 활성 구매를 조회해 동기화하도록 교체)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SubscriptionResponse restore(Long memberId, String sessionToken) {
|
||||||
|
Member member = mustFind(memberId);
|
||||||
|
IapPurchase latest = purchaseMapper.findLatestByMember(memberId);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
boolean stillValid = latest != null && latest.getExpiresAt() != null && latest.getExpiresAt().isAfter(now);
|
||||||
|
boolean needsRestore = !"PREMIUM".equals(member.getPlan())
|
||||||
|
|| member.getPlanExpiresAt() == null
|
||||||
|
|| member.getPlanExpiresAt().isBefore(latest != null && latest.getExpiresAt() != null ? latest.getExpiresAt() : now);
|
||||||
|
if (stillValid && needsRestore) {
|
||||||
|
memberMapper.updateMembership(memberId, "PREMIUM", latest.getExpiresAt(), latest.getProductId(), true);
|
||||||
|
authService.syncSessionPlan(sessionToken, "PREMIUM");
|
||||||
|
member.setPlan("PREMIUM");
|
||||||
|
member.setPlanExpiresAt(latest.getExpiresAt());
|
||||||
|
member.setPlanProduct(latest.getProductId());
|
||||||
|
member.setPlanAutoRenew(true);
|
||||||
|
log.info("[billing] 구매 복원 member={} expires={}", memberId, latest.getExpiresAt());
|
||||||
|
}
|
||||||
|
return toSubscription(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구독 해지 — 자동 갱신만 끈다. 이용은 만료일까지 유지(이후 무료 전환). */
|
||||||
|
@Transactional
|
||||||
|
public SubscriptionResponse cancel(Long memberId) {
|
||||||
|
Member m = mustFind(memberId);
|
||||||
|
if (!"PREMIUM".equals(m.getPlan())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
|
||||||
|
}
|
||||||
|
memberMapper.updateAutoRenew(memberId, false);
|
||||||
|
m.setPlanAutoRenew(false);
|
||||||
|
log.info("[billing] subscription canceled member={} (이용 만료일까지 유지)", memberId);
|
||||||
|
return toSubscription(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구독 재개 — 만료 전이면 자동 갱신을 다시 켠다. */
|
||||||
|
@Transactional
|
||||||
|
public SubscriptionResponse resume(Long memberId) {
|
||||||
|
Member m = mustFind(memberId);
|
||||||
|
if (!"PREMIUM".equals(m.getPlan())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
|
||||||
|
}
|
||||||
|
memberMapper.updateAutoRenew(memberId, true);
|
||||||
|
m.setPlanAutoRenew(true);
|
||||||
|
return toSubscription(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionResponse toSubscription(Member m) {
|
||||||
|
boolean premium = "PREMIUM".equals(m.getPlan());
|
||||||
|
boolean autoRenew = premium && Boolean.TRUE.equals(m.getPlanAutoRenew());
|
||||||
|
ProductResponse p = m.getPlanProduct() == null ? null : CATALOG.get(m.getPlanProduct());
|
||||||
|
String label = p != null ? p.getLabel() : (premium ? "프리미엄" : null);
|
||||||
|
return SubscriptionResponse.builder()
|
||||||
|
.premium(premium)
|
||||||
|
.planProduct(m.getPlanProduct())
|
||||||
|
.planLabel(label)
|
||||||
|
.expiresAt(m.getPlanExpiresAt())
|
||||||
|
.autoRenew(autoRenew)
|
||||||
|
.nextBillingAt(autoRenew ? m.getPlanExpiresAt() : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTDN(실시간 개발자 알림) 처리 — 스캐폴드.
|
||||||
|
* 실연동: message.data(base64 JSON) 디코드 → subscriptionNotification 의 purchaseToken 으로
|
||||||
|
* Play Developer API 재조회 후 멤버십 동기화(갱신/해지/만료/환불).
|
||||||
|
*/
|
||||||
|
public void handleRtdn(Map<String, Object> body) {
|
||||||
|
try {
|
||||||
|
Object message = body == null ? null : body.get("message");
|
||||||
|
String data = null;
|
||||||
|
if (message instanceof Map<?, ?> m && m.get("data") != null) {
|
||||||
|
data = new String(java.util.Base64.getDecoder().decode(String.valueOf(m.get("data"))),
|
||||||
|
java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
log.info("[billing][rtdn] 수신 (스캐폴드 — 미처리): {}", data != null ? data : body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[billing][rtdn] 파싱 실패: {}", e.toString());
|
||||||
|
}
|
||||||
|
// TODO(실연동): 구독 상태 변경(SUBSCRIPTION_RENEWED/CANCELED/EXPIRED/REVOKED) 반영
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member mustFind(Long memberId) {
|
||||||
|
Member m = memberMapper.findById(memberId);
|
||||||
|
if (m == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sb.web.billing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Play 구매 토큰 검증 추상화.
|
||||||
|
* 실연동(androidpublisher API + 서비스 계정) 구현체로 교체하면 된다.
|
||||||
|
*/
|
||||||
|
public interface GooglePlayVerifier {
|
||||||
|
|
||||||
|
/** 구매 토큰이 유효하면 true. 검증 미구성/실패 시 예외 또는 false. */
|
||||||
|
boolean verify(String productId, String purchaseToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.sb.web.billing;
|
||||||
|
|
||||||
|
import com.sb.web.auth.mapper.MemberMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료된 유료 멤버십 자동 강등. 매시간 실행.
|
||||||
|
* (만료일이 NULL 인 관리자 영구 부여는 강등되지 않음)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PremiumExpiryScheduler {
|
||||||
|
|
||||||
|
private final MemberMapper memberMapper;
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 5 * * * *") // 매시 5분
|
||||||
|
public void downgradeExpired() {
|
||||||
|
int n = memberMapper.downgradeExpired();
|
||||||
|
if (n > 0) {
|
||||||
|
log.info("[billing] 만료 멤버십 강등: {}건", n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sb.web.billing;
|
||||||
|
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실연동 전 스텁. 실제 Google Play 검증은 아직 구성되지 않았다.
|
||||||
|
* (테스트 모드에서는 BillingService 가 이 검증을 건너뛴다.)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StubGooglePlayVerifier implements GooglePlayVerifier {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verify(String productId, String purchaseToken) {
|
||||||
|
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"구글 결제 검증이 아직 구성되지 않았습니다. (테스트 모드로만 결제 가능)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.sb.web.billing.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인앱 결제 기록 (iap_purchase).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class IapPurchase {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long memberId;
|
||||||
|
private String platform; // GOOGLE_PLAY / TEST
|
||||||
|
private String productId;
|
||||||
|
private String purchaseToken;
|
||||||
|
private String orderId;
|
||||||
|
private String status; // VERIFIED / FAILED
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.sb.web.billing.dto;
|
||||||
|
|
||||||
|
import com.sb.web.auth.domain.Member;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 후 멤버십 상태.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class MembershipResponse {
|
||||||
|
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
|
private LocalDateTime planExpiresAt; // 만료일 (NULL=만료없음)
|
||||||
|
private boolean premium;
|
||||||
|
|
||||||
|
public static MembershipResponse of(Member m) {
|
||||||
|
boolean premium = "PREMIUM".equals(m.getPlan());
|
||||||
|
return new MembershipResponse(m.getPlan(), m.getPlanExpiresAt(), premium);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.sb.web.billing.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구독 상품 (업그레이드 화면 표시용).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ProductResponse {
|
||||||
|
|
||||||
|
private String id; // Google Play 상품 ID
|
||||||
|
private String label; // 표시 이름
|
||||||
|
private int months; // 부여 개월수
|
||||||
|
private int priceKrw; // 표시용 가격(원) — 실제 결제 금액은 Play Console 기준(스캐폴드 표시용)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.sb.web.billing.dto;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정기결제(구독) 현황 — 계정정보 화면 표시용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class SubscriptionResponse {
|
||||||
|
|
||||||
|
private boolean premium; // 현재 유료 여부
|
||||||
|
private String planProduct; // 구독 상품 ID
|
||||||
|
private String planLabel; // 상품 표시 이름 (프리미엄 월간 등)
|
||||||
|
private LocalDateTime expiresAt; // 이용 만료일
|
||||||
|
private boolean autoRenew; // 자동 갱신 여부
|
||||||
|
private LocalDateTime nextBillingAt; // 다음 결제일 (자동 갱신 시 = 만료일, 해지 시 null)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sb.web.billing.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Play 구매 검증 요청.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VerifyRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
/** Play 구매 토큰 (테스트는 'test-' 로 시작) */
|
||||||
|
@NotBlank
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
private String orderId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sb.web.billing.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.billing.domain.IapPurchase;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인앱 결제 기록 매퍼.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface IapPurchaseMapper {
|
||||||
|
|
||||||
|
int insert(IapPurchase purchase);
|
||||||
|
|
||||||
|
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
|
||||||
|
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
|
||||||
|
|
||||||
|
/** 회원의 가장 최근(만료일 먼) 결제 — 구매 복원용 */
|
||||||
|
IapPurchase findLatestByMember(@Param("memberId") Long memberId);
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import com.sb.web.board.dto.PageResponse;
|
|||||||
import com.sb.web.board.dto.PostDetail;
|
import com.sb.web.board.dto.PostDetail;
|
||||||
import com.sb.web.board.dto.PostRequest;
|
import com.sb.web.board.dto.PostRequest;
|
||||||
import com.sb.web.board.dto.PostSummary;
|
import com.sb.web.board.dto.PostSummary;
|
||||||
|
import com.sb.web.board.dto.Recommender;
|
||||||
import com.sb.web.board.dto.TagCategoryResponse;
|
import com.sb.web.board.dto.TagCategoryResponse;
|
||||||
|
import com.sb.web.board.dto.VoteRequest;
|
||||||
|
import com.sb.web.board.dto.VoteResponse;
|
||||||
import com.sb.web.board.service.BoardService;
|
import com.sb.web.board.service.BoardService;
|
||||||
import com.sb.web.board.service.TagService;
|
import com.sb.web.board.service.TagService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -50,6 +53,24 @@ public class BoardController {
|
|||||||
return boardService.list(page, size, category, tag, keyword, searchType);
|
return boardService.list(page, size, category, tag, keyword, searchType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 내 글 모아보기 */
|
||||||
|
@GetMapping("/my/posts")
|
||||||
|
public PageResponse<PostSummary> myPosts(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return boardService.myPosts(current.getId(), page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 내 댓글 모아보기 */
|
||||||
|
@GetMapping("/my/comments")
|
||||||
|
public PageResponse<com.sb.web.board.dto.MyCommentResponse> myComments(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return boardService.myComments(current.getId(), page, size);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/posts/{id}")
|
@GetMapping("/posts/{id}")
|
||||||
public PostDetail get(
|
public PostDetail get(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@@ -99,15 +120,41 @@ public class BoardController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tags")
|
/** 공지 등록 (관리자) — 목록 최상단 고정 */
|
||||||
public List<String> tags() {
|
@PostMapping("/posts/{id}/notice")
|
||||||
return boardService.allTags();
|
public ResponseEntity<Void> notice(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
boardService.setNotice(id, true, current);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */
|
/** 공지 해제 (관리자) */
|
||||||
|
@PostMapping("/posts/{id}/unnotice")
|
||||||
|
public ResponseEntity<Void> unnotice(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
boardService.setNotice(id, false, current);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목록 태그 필터 — 게시판 지정 시 그 게시판에 매핑된 그룹의 태그만 */
|
||||||
|
@GetMapping("/tags")
|
||||||
|
public List<String> tags(@RequestParam(required = false) String category) {
|
||||||
|
if (category == null || category.isBlank()) {
|
||||||
|
return boardService.allTags();
|
||||||
|
}
|
||||||
|
return tagService.listWritableGroups(category).stream()
|
||||||
|
.flatMap(g -> g.getTags().stream())
|
||||||
|
.map(com.sb.web.board.dto.TagResponse::getName)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 글 작성 시 선택 가능한 태그 (해당 게시판에 매핑된 그룹만) */
|
||||||
@GetMapping("/tag-groups")
|
@GetMapping("/tag-groups")
|
||||||
public List<TagCategoryResponse> tagGroups() {
|
public List<TagCategoryResponse> tagGroups(@RequestParam(required = false) String category) {
|
||||||
return tagService.listWritableGroups();
|
return tagService.listWritableGroups(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/posts/{id}/comments")
|
@PostMapping("/posts/{id}/comments")
|
||||||
@@ -125,4 +172,55 @@ public class BoardController {
|
|||||||
boardService.deleteComment(commentId, current);
|
boardService.deleteComment(commentId, current);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 게시글 추천/비추천 (토글) */
|
||||||
|
@PostMapping("/posts/{id}/vote")
|
||||||
|
public VoteResponse votePost(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody VoteRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return boardService.votePost(id, req.getType(), current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시글 추천(👍)한 사람 목록 */
|
||||||
|
@GetMapping("/posts/{id}/recommenders")
|
||||||
|
public List<Recommender> recommenders(@PathVariable Long id) {
|
||||||
|
return boardService.recommenders(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 추천/비추천 (토글) */
|
||||||
|
@PostMapping("/comments/{commentId}/vote")
|
||||||
|
public VoteResponse voteComment(
|
||||||
|
@PathVariable Long commentId,
|
||||||
|
@Valid @RequestBody VoteRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return boardService.voteComment(commentId, req.getType(), current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시글 신고 (누적 5건 시 블라인드) */
|
||||||
|
@PostMapping("/posts/{id}/report")
|
||||||
|
public ResponseEntity<Void> reportPost(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
boardService.reportPost(id, current);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 신고 (누적 5건 시 블라인드) */
|
||||||
|
@PostMapping("/comments/{commentId}/report")
|
||||||
|
public ResponseEntity<Void> reportComment(
|
||||||
|
@PathVariable Long commentId,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
boardService.reportComment(commentId, current);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 블라인드 해제 (관리자) */
|
||||||
|
@PostMapping("/comments/{commentId}/unblock")
|
||||||
|
public ResponseEntity<Void> unblockComment(
|
||||||
|
@PathVariable Long commentId,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
boardService.unblockComment(commentId, current);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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.domain.BoardImage;
|
||||||
|
import com.sb.web.board.service.BoardImageService;
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 인라인 이미지.
|
||||||
|
* POST /api/board/images 업로드(로그인 필요) → {url: "/api/images/{id}"}
|
||||||
|
* GET /api/images/{id} 이미지 바이트(공개 — img src 로 인증 없이 로드)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BoardImageController {
|
||||||
|
|
||||||
|
private final BoardImageService service;
|
||||||
|
|
||||||
|
// 앱(Capacitor) 출처가 localhost 라 상대경로가 안 통하므로 절대 URL 로 반환
|
||||||
|
@Value("${app.public-url:}")
|
||||||
|
private String publicUrl;
|
||||||
|
|
||||||
|
@PostMapping("/api/board/images")
|
||||||
|
public Map<String, String> upload(
|
||||||
|
@RequestParam("image") MultipartFile file,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException {
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지가 비어 있습니다.");
|
||||||
|
}
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
if (contentType == null || !contentType.startsWith("image/")) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 파일만 업로드할 수 있습니다.");
|
||||||
|
}
|
||||||
|
Long id = service.save(current.getId(), contentType, file.getBytes());
|
||||||
|
String base = (publicUrl == null) ? "" : publicUrl.replaceAll("/+$", "");
|
||||||
|
return Map.of("url", base + "/api/images/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/images/{id}")
|
||||||
|
public ResponseEntity<byte[]> get(@PathVariable Long id) {
|
||||||
|
BoardImage img = service.get(id);
|
||||||
|
if (img == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(img.getContentType()))
|
||||||
|
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
|
||||||
|
.body(img.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.sb.web.board.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 인라인 이미지. board_image 테이블과 매핑(DB 저장).
|
||||||
|
* 본문에는 base64 대신 /api/images/{id} URL 만 들어간다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BoardImage {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long memberId;
|
||||||
|
private String contentType;
|
||||||
|
private byte[] data;
|
||||||
|
private Integer byteSize;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ public class Comment implements Serializable {
|
|||||||
private Long postId;
|
private Long postId;
|
||||||
private Long authorId;
|
private Long authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
|
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||||
|
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||||
private String content;
|
private String content;
|
||||||
|
private Boolean blocked; // 신고 누적 블라인드
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ public class Post implements Serializable {
|
|||||||
private String content;
|
private String content;
|
||||||
private Long authorId;
|
private Long authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
|
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||||
|
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||||
private Integer viewCount;
|
private Integer viewCount;
|
||||||
private Boolean blocked;
|
private Boolean blocked;
|
||||||
private String blockReason;
|
private String blockReason;
|
||||||
|
private Boolean notice; // 공지(상단 고정) — 관리자만 설정
|
||||||
private String category; // 게시판 구분: community/saving/tips
|
private String category; // 게시판 구분: community/saving/tips
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.sb.web.board.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음).
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class BoardSettingRequest {
|
|
||||||
private Long tagCategoryId;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.sb.web.board.dto;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 게시판 설정 응답.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
public class BoardSettingResponse {
|
|
||||||
private Long tagCategoryId;
|
|
||||||
}
|
|
||||||
@@ -16,16 +16,28 @@ public class CommentResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long authorId;
|
private Long authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
|
private String authorGooglePicture;
|
||||||
|
private String authorProfileImage;
|
||||||
private String content;
|
private String content;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private Integer upCount; // 추천 수
|
||||||
|
private Integer downCount; // 비추천 수
|
||||||
|
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||||
|
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출
|
||||||
|
private Boolean blocked; // 신고 누적 블라인드(관리자만 본문 열람)
|
||||||
|
|
||||||
public static CommentResponse from(Comment c) {
|
public static CommentResponse from(Comment c) {
|
||||||
return CommentResponse.builder()
|
return CommentResponse.builder()
|
||||||
.id(c.getId())
|
.id(c.getId())
|
||||||
.authorId(c.getAuthorId())
|
.authorId(c.getAuthorId())
|
||||||
.authorName(c.getAuthorName())
|
.authorName(c.getAuthorName())
|
||||||
|
.authorGooglePicture(c.getAuthorGooglePicture())
|
||||||
|
.authorProfileImage(c.getAuthorProfileImage())
|
||||||
.content(c.getContent())
|
.content(c.getContent())
|
||||||
|
.blocked(Boolean.TRUE.equals(c.getBlocked()))
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
.upCount(0)
|
||||||
|
.downCount(0)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sb.web.board.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 게시글의 댓글별 추천/비추천 집계 + 현재 사용자 투표 (N+1 방지용 일괄 조회 결과).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CommentVoteSummary {
|
||||||
|
|
||||||
|
private Long commentId;
|
||||||
|
private int upCount;
|
||||||
|
private int downCount;
|
||||||
|
private String myVote; // UP / DOWN / null
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sb.web.board.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 댓글 모아보기 항목 (어느 글의 댓글인지 함께).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MyCommentResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long postId;
|
||||||
|
private String postTitle;
|
||||||
|
private String category;
|
||||||
|
}
|
||||||
@@ -19,14 +19,21 @@ public class PostDetail {
|
|||||||
private String content;
|
private String content;
|
||||||
private Long authorId;
|
private Long authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
|
private String authorGooglePicture;
|
||||||
|
private String authorProfileImage;
|
||||||
private Integer viewCount;
|
private Integer viewCount;
|
||||||
private Boolean blocked;
|
private Boolean blocked;
|
||||||
private String blockReason;
|
private String blockReason;
|
||||||
|
private Boolean notice;
|
||||||
private String category;
|
private String category;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
private List<CommentResponse> comments;
|
private List<CommentResponse> comments;
|
||||||
|
private Integer upCount; // 추천 수
|
||||||
|
private Integer downCount; // 비추천 수
|
||||||
|
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||||
|
private List<Recommender> recommenders; // 추천(👍)한 사람 목록 (아바타 표기용)
|
||||||
|
|
||||||
public static PostDetail of(Post p, List<String> tags, List<CommentResponse> comments) {
|
public static PostDetail of(Post p, List<String> tags, List<CommentResponse> comments) {
|
||||||
return PostDetail.builder()
|
return PostDetail.builder()
|
||||||
@@ -35,9 +42,12 @@ public class PostDetail {
|
|||||||
.content(p.getContent())
|
.content(p.getContent())
|
||||||
.authorId(p.getAuthorId())
|
.authorId(p.getAuthorId())
|
||||||
.authorName(p.getAuthorName())
|
.authorName(p.getAuthorName())
|
||||||
|
.authorGooglePicture(p.getAuthorGooglePicture())
|
||||||
|
.authorProfileImage(p.getAuthorProfileImage())
|
||||||
.viewCount(p.getViewCount())
|
.viewCount(p.getViewCount())
|
||||||
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
||||||
.blockReason(p.getBlockReason())
|
.blockReason(p.getBlockReason())
|
||||||
|
.notice(Boolean.TRUE.equals(p.getNotice()))
|
||||||
.category(p.getCategory())
|
.category(p.getCategory())
|
||||||
.createdAt(p.getCreatedAt())
|
.createdAt(p.getCreatedAt())
|
||||||
.updatedAt(p.getUpdatedAt())
|
.updatedAt(p.getUpdatedAt())
|
||||||
@@ -54,6 +64,7 @@ public class PostDetail {
|
|||||||
.authorName(p.getAuthorName())
|
.authorName(p.getAuthorName())
|
||||||
.viewCount(p.getViewCount())
|
.viewCount(p.getViewCount())
|
||||||
.blocked(true)
|
.blocked(true)
|
||||||
|
.notice(Boolean.TRUE.equals(p.getNotice()))
|
||||||
.category(p.getCategory())
|
.category(p.getCategory())
|
||||||
.createdAt(p.getCreatedAt())
|
.createdAt(p.getCreatedAt())
|
||||||
.tags(List.of())
|
.tags(List.of())
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public class PostRequest {
|
|||||||
/** 게시판 구분: community/saving/tips. 없으면 community */
|
/** 게시판 구분: community/saving/tips. 없으면 community */
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
|
/** 공지 등록 여부 (관리자만 반영). 작성 시 선택 */
|
||||||
|
private Boolean notice;
|
||||||
|
|
||||||
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
||||||
private List<Long> tagIds;
|
private List<Long> tagIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ public class PostSummary {
|
|||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private String title;
|
private String title;
|
||||||
|
private String category; // 내 글 모아보기 등 교차 게시판 링크용
|
||||||
|
private Long authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
|
private String authorGooglePicture;
|
||||||
|
private String authorProfileImage;
|
||||||
private Integer viewCount;
|
private Integer viewCount;
|
||||||
private Integer commentCount;
|
private Integer commentCount;
|
||||||
|
private Integer upCount; // 추천 수
|
||||||
|
private Integer downCount; // 비추천 수
|
||||||
|
private Boolean hot; // 인기 글(추천 50+ & 1일 이내) 상단 노출
|
||||||
private Boolean blocked;
|
private Boolean blocked;
|
||||||
|
private Boolean notice;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sb.web.board.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천(👍)한 사람 (본문 하단 아바타·이름 표기용).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class Recommender {
|
||||||
|
|
||||||
|
private Long memberId;
|
||||||
|
private String name;
|
||||||
|
private String googlePicture;
|
||||||
|
private String profileImage;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 태그 카테고리 생성/수정 요청.
|
* 태그 카테고리 생성/수정 요청.
|
||||||
*/
|
*/
|
||||||
@@ -15,4 +17,7 @@ public class TagCategoryRequest {
|
|||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
/** 이 그룹을 사용할 게시판 (community/saving/tips). 비어있으면 전체 게시판. */
|
||||||
|
private List<String> boards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ public class TagCategoryResponse {
|
|||||||
private String name;
|
private String name;
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private List<TagResponse> tags;
|
private List<TagResponse> tags;
|
||||||
|
private List<String> boards; // 이 그룹을 사용할 게시판 (비어있으면 전체)
|
||||||
|
|
||||||
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags) {
|
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags, List<String> boards) {
|
||||||
return TagCategoryResponse.builder()
|
return TagCategoryResponse.builder()
|
||||||
.id(c.getId())
|
.id(c.getId())
|
||||||
.name(c.getName())
|
.name(c.getName())
|
||||||
.sortOrder(c.getSortOrder())
|
.sortOrder(c.getSortOrder())
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
|
.boards(boards)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sb.web.board.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천/비추천 요청. 같은 표를 다시 보내면 취소(토글), 반대면 전환.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VoteRequest {
|
||||||
|
|
||||||
|
@Pattern(regexp = "UP|DOWN", message = "투표는 UP 또는 DOWN 이어야 합니다.")
|
||||||
|
private String type;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sb.web.board.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천/비추천 후 최신 집계 + 현재 사용자 상태.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VoteResponse {
|
||||||
|
|
||||||
|
private int upCount;
|
||||||
|
private int downCount;
|
||||||
|
private String myVote; // UP / DOWN / null
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.sb.web.board.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.board.domain.BoardImage;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface BoardImageMapper {
|
||||||
|
|
||||||
|
int insert(BoardImage image);
|
||||||
|
|
||||||
|
BoardImage findById(@Param("id") Long id);
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -18,4 +18,13 @@ public interface CommentMapper {
|
|||||||
int delete(@Param("id") Long id);
|
int delete(@Param("id") Long id);
|
||||||
|
|
||||||
int deleteByPostId(@Param("postId") Long postId);
|
int deleteByPostId(@Param("postId") Long postId);
|
||||||
|
|
||||||
|
/** 신고 누적 블라인드 설정 */
|
||||||
|
int updateBlocked(@Param("id") Long id, @Param("blocked") boolean blocked);
|
||||||
|
|
||||||
|
/** 내 댓글 모아보기 (글 제목·카테고리 포함) */
|
||||||
|
java.util.List<com.sb.web.board.dto.MyCommentResponse> findMyPage(
|
||||||
|
@Param("memberId") Long memberId, @Param("offset") int offset, @Param("size") int size);
|
||||||
|
|
||||||
|
long countMyComments(@Param("memberId") Long memberId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ public interface PostMapper {
|
|||||||
@Param("keyword") String keyword,
|
@Param("keyword") String keyword,
|
||||||
@Param("searchType") String searchType);
|
@Param("searchType") String searchType);
|
||||||
|
|
||||||
|
/** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */
|
||||||
|
List<PostSummary> findHotPosts(@Param("category") String category,
|
||||||
|
@Param("hours") int hours,
|
||||||
|
@Param("minUp") int minUp,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
/** 내 글 모아보기 */
|
||||||
|
List<PostSummary> findMyPage(@Param("memberId") Long memberId,
|
||||||
|
@Param("offset") int offset, @Param("size") int size);
|
||||||
|
|
||||||
|
long countMyPosts(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
Post findById(@Param("id") Long id);
|
Post findById(@Param("id") Long id);
|
||||||
|
|
||||||
int insert(Post post);
|
int insert(Post post);
|
||||||
@@ -35,4 +47,7 @@ public interface PostMapper {
|
|||||||
int updateBlocked(@Param("id") Long id,
|
int updateBlocked(@Param("id") Long id,
|
||||||
@Param("blocked") boolean blocked,
|
@Param("blocked") boolean blocked,
|
||||||
@Param("blockReason") String blockReason);
|
@Param("blockReason") String blockReason);
|
||||||
|
|
||||||
|
int updateNotice(@Param("id") Long id,
|
||||||
|
@Param("notice") boolean notice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.sb.web.board.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신고(report) 매퍼 — 글/댓글 신고 누적 집계.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ReportMapper {
|
||||||
|
|
||||||
|
/** 신고 추가 (회원당 대상별 1회 — 중복은 무시) */
|
||||||
|
int insertIgnore(@Param("targetType") String targetType,
|
||||||
|
@Param("targetId") Long targetId,
|
||||||
|
@Param("memberId") Long memberId,
|
||||||
|
@Param("reason") String reason);
|
||||||
|
|
||||||
|
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||||
|
|
||||||
|
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||||
|
|
||||||
|
int deleteByMember(@Param("memberId") Long memberId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sb.web.board.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 카테고리(그룹) ↔ 게시판 매핑 (tag_category_board).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TagCategoryBoardMapper {
|
||||||
|
|
||||||
|
/** 카테고리가 노출될 게시판 목록 */
|
||||||
|
List<String> findBoards(@Param("categoryId") Long categoryId);
|
||||||
|
|
||||||
|
void deleteByCategory(@Param("categoryId") Long categoryId);
|
||||||
|
|
||||||
|
void insert(@Param("categoryId") Long categoryId, @Param("board") String board);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.sb.web.board.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.board.dto.CommentVoteSummary;
|
||||||
|
import com.sb.web.board.dto.Recommender;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글/댓글 추천·비추천(post_vote / comment_vote) 매퍼.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface VoteMapper {
|
||||||
|
|
||||||
|
// ===== 게시글 =====
|
||||||
|
/** 현재 사용자의 게시글 투표(UP/DOWN) 또는 null */
|
||||||
|
String findPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 투표 등록/변경 (INSERT … ON DUPLICATE KEY UPDATE) */
|
||||||
|
int upsertPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId, @Param("type") String type);
|
||||||
|
|
||||||
|
int deletePostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
int countPostVotes(@Param("postId") Long postId, @Param("type") String type);
|
||||||
|
|
||||||
|
/** 추천(👍)한 사람 목록 (최신순) */
|
||||||
|
List<Recommender> findPostRecommenders(@Param("postId") Long postId);
|
||||||
|
|
||||||
|
int deletePostVotesByPost(@Param("postId") Long postId);
|
||||||
|
|
||||||
|
// ===== 댓글 =====
|
||||||
|
String findCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
int upsertCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId, @Param("type") String type);
|
||||||
|
|
||||||
|
int deleteCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
int countCommentVotes(@Param("commentId") Long commentId, @Param("type") String type);
|
||||||
|
|
||||||
|
/** 한 게시글의 댓글별 집계 + 현재 사용자 투표 (일괄) */
|
||||||
|
List<CommentVoteSummary> findCommentVoteSummary(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
int deleteCommentVotesByComment(@Param("commentId") Long commentId);
|
||||||
|
|
||||||
|
/** 게시글 삭제 시 그 글의 모든 댓글 투표 정리 */
|
||||||
|
int deleteCommentVotesByPost(@Param("postId") Long postId);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user